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..33e660b 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -2,97 +2,440 @@ name: CI
on:
push:
- branches: [ "master" ]
+ branches: [master]
+ # A module release is published by manually pushing a renpy/vX.Y.Z, rpgmaker/vX.Y.Z or
+ # zanzarah/vX.Y.Z tag. Each tag selects exactly one module and its own version lineage.
+ tags: ['renpy/v*', 'rpgmaker/v*', 'zanzarah/v*']
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
+ 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.13.0"
+ 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
- uses: actions/cache@v4
+ - name: Cache vcpkg binaries
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
- key: vcpkg-${{ matrix.arch }}-${{ hashFiles('vcpkg.json') }}
- path: |
- ${{env.VCPKG_DEFAULT_BINARY_CACHE}}
+ path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }}
+ key: vcpkg-${{ steps.runner-image.outputs.identity }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }}
+
+ - name: Prepare the pinned Python environment
+ shell: pwsh
+ run: |
+ "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 source-check tools
+ shell: pwsh
+ run: |
+ 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
+
+ - name: Provision binary-analysis tools
+ if: matrix.job != 'source'
+ shell: pwsh
+ run: |
+ $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
- - name: Setup vcpkg
+ - name: Provision the x64 leak-debugging tools
+ if: matrix.job == 'x64'
+ 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
+ $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"
+ }
+
+ $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: Verify repository sources
+ id: verify-source
+ if: matrix.job == 'source'
+ shell: pwsh
run: |
- cd ${{github.workspace}}/build/${{ matrix.arch }}-release
- cpack --config CPackConfig.cmake -C RelWithDebInfo
+ $evidence = Join-Path $env:RUNNER_TEMP 'evidence-source'
+ ./build.ps1 verify-source -ExportDir $evidence -Priority normal
- - name: Upload artifacts
- uses: actions/upload-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 `
+ -FuzzSeconds $fuzzSeconds -LeakWarmup $leakWarmup `
+ -LeakIterations $leakIterations -LeakWindows 3 -Priority normal
+
+ - name: Upload verification evidence
+ if: always() && (steps.verify-source.outcome != 'skipped' || steps.verify-arch.outcome != 'skipped')
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- name: observer-modules-${{ matrix.arch }}
- path: ${{github.workspace}}/build/${{ matrix.arch }}-release/*.zip
+ 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
+ include-hidden-files: true
+ retention-days: ${{ github.event_name == 'pull_request' && 7 || 30 }}
+
+ - name: Upload verified packages and symbols
+ if: success() && github.event_name == 'push' && matrix.job != 'source'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: packages-${{ matrix.job }}
+ path: ${{ runner.temp }}/evidence-${{ matrix.job }}/packages
+ if-no-files-found: error
+ retention-days: 30
release:
- needs: build
- runs-on: ubuntu-latest
- if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+ name: release
+ needs: verification
+ if: startsWith(github.ref, 'refs/tags/renpy/v') || startsWith(github.ref, 'refs/tags/rpgmaker/v') || startsWith(github.ref, 'refs/tags/zanzarah/v')
+ # Serialize fresh publications of the same module so two releases cannot race the history
+ # lookup, the version gate and the publish. A job-level group cannot read step outputs, so
+ # the module comes from github.ref_name; cancel-in-progress is off so a running publication
+ # is never killed.
+ concurrency:
+ group: release-${{ startsWith(github.ref_name, 'renpy/') && 'renpy' || startsWith(github.ref_name, 'rpgmaker/') && 'rpgmaker' || 'zanzarah' }}
+ cancel-in-progress: false
+ runs-on: windows-2022
+ timeout-minutes: 30
+ permissions:
+ contents: write
+
steps:
- - name: Download all artifacts
- uses: actions/download-artifact@v4
+ - name: Check out the repository
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
- merge-multiple: true
- path: ./artifacts
+ persist-credentials: false
- - name: Generate release tag
- id: tag
- run: echo "tag=$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT
+ - name: Set up uv
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ version: "0.13.0"
+ enable-cache: true
+ cache-dependency-glob: build/uv.lock
+ cache-suffix: release
- - name: Create GitHub Release
- uses: softprops/action-gh-release@v2
+ - name: Prepare the pinned Python environment
+ shell: pwsh
+ run: |
+ "TEMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV
+ "TMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV
+ uv sync --project build --frozen
+
+ - name: Collect the verified evidence and package bundles
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
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
+ path: ${{ runner.temp }}/artifacts
+
+ - name: Resolve the selected module from the release tag
+ id: tag
+ shell: pwsh
+ run: |
+ # `uv run --directory build` changes the working directory, so a relative --repository
+ # would validate build/src instead of the checked out repository root.
+ uv run --directory build --frozen --no-sync python -m core.release tag `
+ --repository "$env:GITHUB_WORKSPACE" --tag $env:GITHUB_REF_NAME `
+ --github-output $env:GITHUB_OUTPUT
+
+ - name: Resolve the previous release of the same module
+ id: previous
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ $repo = '${{ github.repository }}'
+ $tag = $env:GITHUB_REF_NAME
+ # The exact tag is probed before any lineage work: only a confirmed HTTP 404 means the
+ # release does not exist yet, and every other failure is fatal. The captured output is
+ # joined into one string so a 404 body line cannot hide the status.
+ $probe = (gh api "repos/$repo/releases/tags/$tag" --jq '.tag_name' 2>&1 | Out-String)
+ $exists = $LASTEXITCODE -eq 0
+ if (-not $exists -and $probe -notmatch 'HTTP 404') {
+ throw "Unable to determine whether $tag is already published:`n$probe"
+ }
+ "exists=$($exists.ToString().ToLower())" | Add-Content -Path $env:GITHUB_OUTPUT
+ $resolved = Join-Path $env:RUNNER_TEMP 'previous-tag.txt'
+ if ($exists) {
+ # A rerun never reconstructs the lineage from the mutable history: it downloads the
+ # immutable release.json published with the tag and reuses the predecessor recorded
+ # there, so a concurrent or hand-inserted release cannot change the comparison.
+ $current = Join-Path $env:RUNNER_TEMP 'current'
+ New-Item -ItemType Directory -Force -Path $current | Out-Null
+ gh release download $tag --pattern 'release.json' --dir $current
+ if ($LASTEXITCODE -ne 0) {
+ throw "The published release $tag publishes no readable release.json."
+ }
+ uv run --directory build --frozen --no-sync python -m core.release previous `
+ --tag $tag --current-exists --current-report (Join-Path $current 'release.json') `
+ --output $resolved
+ }
+ else {
+ # Every page of the release history is scanned so an older lineage never disappears
+ # behind a fixed page of newer releases. Draft and prerelease entries are emitted too
+ # and filtered out by the lineage helper.
+ $listing = Join-Path $env:RUNNER_TEMP 'releases.jsonl'
+ New-Item -ItemType File -Force -Path $listing | Out-Null
+ gh api --paginate "repos/$repo/releases?per_page=100" `
+ --jq '.[] | {tag: .tag_name, draft: .draft, prerelease: .prerelease}' |
+ Set-Content -LiteralPath $listing
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Unable to list the published releases.'
+ }
+ uv run --directory build --frozen --no-sync python -m core.release previous `
+ --tag $tag --releases $listing --output $resolved
+ }
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Unable to resolve the previous release of this module.'
+ }
+ # The resolved predecessor is written as a bare tag; an empty file means a first release.
+ $previous = (Get-Content -Raw -LiteralPath $resolved).Trim()
+ $manifest = ''
+ if (-not [string]::IsNullOrWhiteSpace($previous)) {
+ $directory = Join-Path $env:RUNNER_TEMP 'previous'
+ New-Item -ItemType Directory -Force -Path $directory | Out-Null
+ gh release download $previous --pattern 'packages.json' --dir $directory
+ if ($LASTEXITCODE -ne 0) {
+ throw "The previous module release $previous publishes no readable packages.json."
+ }
+ $manifest = Join-Path $directory 'packages.json'
+ }
+ "manifest=$manifest" | Add-Content -Path $env:GITHUB_OUTPUT
+
+ - name: Gate the selected module through the public build tool
+ shell: pwsh
+ env:
+ MODULE: ${{ steps.tag.outputs.module }}
+ PREVIOUS_MANIFEST: ${{ steps.previous.outputs.manifest }}
+ run: |
+ $downloads = Join-Path $env:RUNNER_TEMP 'artifacts'
+ $gate = Join-Path $env:RUNNER_TEMP 'release-gate'
+ $assets = Join-Path $env:RUNNER_TEMP 'release-assets'
+ New-Item -ItemType Directory -Force -Path $gate, $assets | Out-Null
+ $manifests = @(
+ foreach ($arch in @('x86', 'x64', 'arm64')) {
+ $path = Join-Path $downloads "packages-$arch\packages.json"
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
+ throw "A verified package manifest is missing: $path"
+ }
+ $path
+ }
+ )
+ Get-ChildItem -LiteralPath $downloads -Directory -Filter 'packages-*' |
+ ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Filter '*.zip' } |
+ Where-Object { $_.Name.StartsWith("$env:MODULE-", [StringComparison]::Ordinal) } |
+ ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $assets }
+ $previous = @()
+ if (-not [string]::IsNullOrWhiteSpace($env:PREVIOUS_MANIFEST)) {
+ $previous = @('--previous', $env:PREVIOUS_MANIFEST)
+ }
+ uv run --directory build --frozen --no-sync python -m core.release gate `
+ --module $env:MODULE --tag $env:GITHUB_REF_NAME `
+ --repository "$env:GITHUB_WORKSPACE" `
+ --output $gate --assets $assets @previous @manifests
+
+ - name: Publish the module release
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ $downloads = Join-Path $env:RUNNER_TEMP 'artifacts'
+ $gate = Join-Path $env:RUNNER_TEMP 'release-gate'
+ $assets = Join-Path $env:RUNNER_TEMP 'release-assets'
+ $tag = $env:GITHUB_REF_NAME
+ $packages = @(
+ Get-ChildItem -LiteralPath $assets -Filter '*.zip' |
+ ForEach-Object { $_.FullName }
+ )
+ if ($packages.Count -ne 6) {
+ throw "Expected six selected-module archives, found $($packages.Count)."
+ }
+ $evidence = @(
+ Get-ChildItem -LiteralPath $downloads -Directory -Filter 'evidence-*' |
+ ForEach-Object {
+ $bundle = Join-Path $gate "$($_.Name).zip"
+ Compress-Archive -Path (Join-Path $_.FullName '*') -DestinationPath $bundle
+ $bundle
+ }
+ )
+ if ($evidence.Count -ne 4) {
+ throw "Expected four evidence bundles, found $($evidence.Count)."
+ }
+ $report = Get-Content -Raw -LiteralPath (Join-Path $gate 'release.json') | ConvertFrom-Json
+ $title = $report.title
+ # Only the byte-deterministic release assets are compared on an idempotent rerun; the
+ # evidence bundles are re-compressed each run and are only required to be present.
+ $deterministic = $packages + @(
+ (Join-Path $gate 'packages.json'), (Join-Path $gate 'release.json'),
+ (Join-Path $gate 'notes.md')
+ )
+ # Whether the exact tag is already published was resolved once, before the lineage
+ # lookup; reuse that recorded result instead of probing a second time.
+ $alreadyPublished = '${{ steps.previous.outputs.exists }}' -eq 'true'
+ if ($alreadyPublished) {
+ $published = Join-Path $env:RUNNER_TEMP 'published'
+ New-Item -ItemType Directory -Force -Path $published | Out-Null
+ gh release download $tag --dir $published
+ if ($LASTEXITCODE -ne 0) {
+ throw "Unable to download the published assets of $tag."
+ }
+ foreach ($file in $deterministic) {
+ $name = Split-Path -Leaf $file
+ $publishedFile = Join-Path $published $name
+ if (-not (Test-Path -LiteralPath $publishedFile -PathType Leaf)) {
+ throw "The published release $tag is missing $name."
+ }
+ if ((Get-FileHash -LiteralPath $publishedFile -Algorithm SHA256).Hash -ne
+ (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash) {
+ throw "The published asset $name does not match the verified bytes."
+ }
+ }
+ foreach ($file in $evidence) {
+ if (-not (Test-Path -LiteralPath (Join-Path $published (Split-Path -Leaf $file)) -PathType Leaf)) {
+ throw "The published release $tag is missing $(Split-Path -Leaf $file)."
+ }
+ }
+ }
+ else {
+ gh release create $tag --verify-tag --title $title `
+ --notes-file (Join-Path $gate 'notes.md') @deterministic @evidence
+ if ($LASTEXITCODE -ne 0) {
+ throw "Unable to publish the release $tag."
+ }
+ }
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..3273fcc
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,97 @@
+# 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; CMake use inside a vcpkg port is acceptable.
+
+The build tooling is plain Python: `build/main.py` is the command contract, `build/native.py` drives vcpkg and MSBuild,
+and the remaining operations are direct functions over the retained `core` validators. There is no dependency graph,
+content-addressed store, scheduler or template renderer to keep in sync; MSBuild owns project evaluation, dependencies
+and incremental state under the stable `out/native` tree.
+
+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 C:\path\to\evidence
+.\build.ps1 verify-arch -Arch x64 -ExportDir C:\path\to\evidence
+.\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..155b2c2 100644
--- a/README.md
+++ b/README.md
@@ -39,8 +39,8 @@ specific files as needed without having to unpack the entire archive.
### Module Installation
-1. Download the [latest release](https://github.com/refaim/ObserverModules/releases/latest) of the module you're
- interested in
+1. Open the release listing for the module you want (see [Module downloads](#module-downloads)) and unpack the
+ published archive for your architecture
2. Note that the archive contains an observer_user.ini file. If you're installing multiple modules or already have this
file in your Observer modules folder, you'll need to manually merge these ini files to ensure all modules work
correctly
@@ -48,6 +48,24 @@ specific files as needed without having to unpack the entire archive.
folder with your FAR Manager installation)
4. Restart FAR Manager for the changes to take effect
+### Module downloads
+
+Each module owns its version in `src/modules//VERSION`, its own `renpy/vX.Y.Z`, `rpgmaker/vX.Y.Z` or
+`zanzarah/vX.Y.Z` release lineage, and its own release listing. The declared versions below are what the current
+sources build; each module's listing shows the releases that are actually published, and every published archive names
+the module, its version and the target architecture:
+
+| Module | Declared version | Release listing | ChangeLog |
+|--------|------------------|-----------------|-----------|
+| Ren'Py | 3.1.0 | [renpy releases](https://github.com/refaim/ObserverModules/releases?q=renpy%2Fv) | [src/modules/renpy/ChangeLog](src/modules/renpy/ChangeLog) |
+| RPG Maker | 1.1.0 | [rpgmaker releases](https://github.com/refaim/ObserverModules/releases?q=rpgmaker%2Fv) | [src/modules/rpgmaker/ChangeLog](src/modules/rpgmaker/ChangeLog) |
+| Zanzarah | 2.1.0 | [zanzarah releases](https://github.com/refaim/ObserverModules/releases?q=zanzarah%2Fv) | [src/modules/zanzarah/ChangeLog](src/modules/zanzarah/ChangeLog) |
+
+Each module keeps a human-written `ChangeLog`; the release body is that file's top entry for the released version, so
+the notes describe real behavior instead of a generated commit list. Every module archive carries the module DLL, its
+`observer_user.ini`, the project `license.txt`, the dependency license notices for that module and the module's raw
+`ChangeLog` at the archive root.
+
### Working with Archives
1. Find an archive of the supported format in FAR Manager
@@ -69,7 +87,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
@@ -78,15 +95,30 @@ specific files as needed without having to unpack the entire archive.
| [luxrck/rgssad](https://github.com/luxrck/rgssad) | [MIT](licenses/rgssad.txt) |
| [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) |
+| [zanzapak](https://aluigi.altervista.org/papers.htm#others-file) | [GPL-2.0-or-later](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..e1b11ca
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,43 @@
+#requires -Version 7.4
+
+[CmdletBinding(PositionalBinding = $false)]
+param(
+ # PowerShell parses an unquoted `x86,x64,arm64` in argument mode as an array literal. Collect the
+ # command line as written so that splatting cannot expand one written value into several driver
+ # arguments, which is how every documented multi-value example used to fail.
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [AllowEmptyCollection()]
+ [object[]] $DriverArguments = @()
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$forwarded = [string[]]@(
+ foreach ($argument in $DriverArguments) {
+ if ($argument -is [string]) {
+ $argument
+ }
+ elseif ($argument -is [System.Collections.IEnumerable]) {
+ # Rejoin any array literal, so every multi-value option keeps its documented comma form.
+ [string]::Join(',', @($argument | ForEach-Object { [string]$_ }))
+ }
+ else {
+ [string]$argument
+ }
+ }
+)
+
+$buildProject = Join-Path $PSScriptRoot 'build'
+$driverScript = Join-Path $buildProject 'main.py'
+$env:UV_CACHE_DIR = Join-Path $buildProject '.uv-cache'
+
+if ($env:OBSERVER_BUILD_PRINT_ARGV -eq '1') {
+ # Contract-test seam: report the driver argv instead of building anything. The distinctive
+ # failure code keeps an accidentally leaked variable from passing as a successful build.
+ ConvertTo-Json -InputObject (@($driverScript) + $forwarded) -Compress
+ exit 97
+}
+
+& uv run --project $buildProject --frozen --no-sync python $driverScript @forwarded
+exit $LASTEXITCODE
diff --git a/build/Observer.proj b/build/Observer.proj
new file mode 100644
index 0000000..94a20f8
--- /dev/null
+++ b/build/Observer.proj
@@ -0,0 +1,34 @@
+
+
+
+
+ Debug
+ x64
+ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\out\native\'))
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
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..9cc81f3
--- /dev/null
+++ b/build/ObserverFuzz.props
@@ -0,0 +1,38 @@
+
+
+
+
+ i386
+ x86_64
+
+
+
+
+ /clang:-mllvm /clang:-asan-stack-dynamic-alloca=0 %(AdditionalOptions)
+
+
+ Console
+ $(LLVMRuntimeDir)\clang_rt.asan-$(ObserverFuzzRuntimeSuffix).lib;$(LLVMRuntimeDir)\clang_rt.asan_cxx-$(ObserverFuzzRuntimeSuffix).lib;%(AdditionalDependencies)
+ /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.fuzzer-$(ObserverFuzzRuntimeSuffix).lib" /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.asan-$(ObserverFuzzRuntimeSuffix).lib" /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.asan_cxx-$(ObserverFuzzRuntimeSuffix).lib" /INFERASANLIBS:NO %(AdditionalOptions)
+
+
+
+
+
+
+
diff --git a/build/ObserverModuleVersion.props b/build/ObserverModuleVersion.props
new file mode 100644
index 0000000..e6bc795
--- /dev/null
+++ b/build/ObserverModuleVersion.props
@@ -0,0 +1,45 @@
+
+
+
+
+ $(RepositoryRoot)src\modules\$(ObserverModuleName)\VERSION
+ $([System.IO.File]::ReadAllText('$(ObserverModuleVersionFile)'))
+ $(ObserverModuleVersionRaw.Trim())
+
+ $([System.Text.RegularExpressions.Regex]::IsMatch('$(ObserverModuleVersion)', '^(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})$'))
+ $([System.Version]::Parse('$(ObserverModuleVersion)').Major)
+ $([System.Version]::Parse('$(ObserverModuleVersion)').Minor)
+ $([System.Version]::Parse('$(ObserverModuleVersion)').Build)
+
+
+
+
+ OBSERVER_MODULE_VERSION_MAJOR=$(ObserverModuleVersionMajor);OBSERVER_MODULE_VERSION_MINOR=$(ObserverModuleVersionMinor);OBSERVER_MODULE_VERSION_PATCH=$(ObserverModuleVersionPatch);%(PreprocessorDefinitions)
+
+
+ OBSERVER_MODULE_NAME=$(ObserverModuleName);OBSERVER_MODULE_FILE=$(ObserverModuleName).so;OBSERVER_MODULE_VERSION=$(ObserverModuleVersion);OBSERVER_MODULE_VERSION_MAJOR=$(ObserverModuleVersionMajor);OBSERVER_MODULE_VERSION_MINOR=$(ObserverModuleVersionMinor);OBSERVER_MODULE_VERSION_PATCH=$(ObserverModuleVersionPatch);%(PreprocessorDefinitions)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..837fa04
--- /dev/null
+++ b/build/ObserverProject.props
@@ -0,0 +1,100 @@
+
+
+
+ $([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
+
+ clang_rt.profile-i386.lib
+ clang_rt.profile-x86_64.lib
+
+
+
+
+ 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)
+ /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)
+ $(LLVMRuntimeDir)\$(ObserverProfileRuntime);%(AdditionalDependencies)
+ /NODEFAULTLIB:clang_rt.profile.lib %(AdditionalOptions)
+
+
+
+
+
+
+
+
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/checks/psscriptanalyzer.ps1 b/build/checks/psscriptanalyzer.ps1
new file mode 100644
index 0000000..c7398cb
--- /dev/null
+++ b/build/checks/psscriptanalyzer.ps1
@@ -0,0 +1,21 @@
+#requires -Version 7.4
+
+[CmdletBinding(PositionalBinding = $false)]
+param(
+ [Parameter(Mandatory)][string] $Module,
+ [Parameter(Mandatory)][string] $Settings,
+ [Parameter(Mandatory)][string] $Output,
+ [Parameter(ValueFromRemainingArguments = $true)][AllowEmptyCollection()][string[]] $Source = @()
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+Import-Module -Name $Module
+
+# One branchless pass: the analyzer accepts a pipeline of paths, so an empty list simply yields
+# no records. The raw records are serialised with the severity kept as its name, and the caller
+# maps RuleName/Severity/Message/Line/Column/ScriptPath into SARIF.
+$records = @($Source | Invoke-ScriptAnalyzer -Settings $Settings)
+ConvertTo-Json -InputObject @($records) -Depth 4 -EnumsAsStrings |
+ Set-Content -LiteralPath $Output -Encoding utf8
diff --git a/build/core/__init__.py b/build/core/__init__.py
new file mode 100644
index 0000000..a21e5f7
--- /dev/null
+++ b/build/core/__init__.py
@@ -0,0 +1 @@
+"""Core primitives for the ObserverModules build tooling."""
diff --git a/build/core/binary_audit.py b/build/core/binary_audit.py
new file mode 100644
index 0000000..884eb32
--- /dev/null
+++ b/build/core/binary_audit.py
@@ -0,0 +1,141 @@
+"""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, *,
+ executable: bool = False,
+) -> None:
+ """Gate one release PE.
+
+ A shipped Observer module is a DLL that must export exactly ``LoadSubModule``
+ and ``UnloadSubModule``. The leak probe is a self-contained EXE, so with
+ ``executable=True`` it must instead carry *no* exports at all; the machine,
+ dependency and BinSkim policies are unchanged for either kind.
+ """
+
+ 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 = set() if executable else {"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)
+ pe.add_argument("--executable", action="store_true")
+ 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, executable=args.executable)
+ 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__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/changelog.py b/build/core/changelog.py
new file mode 100644
index 0000000..f31185d
--- /dev/null
+++ b/build/core/changelog.py
@@ -0,0 +1,78 @@
+"""Read the latest human-written ChangeLog entry of one module as release notes."""
+
+from __future__ import annotations
+
+from pathlib import Path, PurePosixPath
+import re
+
+from core.module_version import ModuleVersionError, parse_version, version_path
+
+
+CHANGELOG_FILE = "ChangeLog"
+_HEADER = re.compile(r"Version[ \t]+(\S+)")
+_UNDERLINE = re.compile(r"-+")
+_BULLET = re.compile(r"[ \t]*[*+][ \t]+(.+)")
+
+
+class ChangelogError(RuntimeError):
+ """A module ChangeLog is missing, malformed, or does not describe the release."""
+
+
+def changelog_path(module: str) -> str:
+ """Return the repository-relative human ChangeLog of one module."""
+
+ try:
+ path = version_path(module)
+ except ModuleVersionError as error:
+ raise ChangelogError(f"unknown module: {module}") from error
+ return PurePosixPath(path).with_name(CHANGELOG_FILE).as_posix()
+
+
+def _canonical(version: str, what: str) -> str:
+ try:
+ parse_version(version)
+ except ModuleVersionError as error:
+ raise ChangelogError(f"{what} must be a canonical X.Y.Z version: {version!r}") from error
+ return version
+
+
+def latest_notes(repository: Path, module: str, expected_version: str) -> str:
+ """Return the module's latest ChangeLog entry body as Markdown bullets.
+
+ The top entry must name exactly ``expected_version``; only that entry's bullets are
+ rendered, so an older ``Version`` section can never leak into the release body.
+ """
+
+ path = repository / changelog_path(module)
+ _canonical(expected_version, "expected module version")
+ try:
+ text = path.read_text(encoding="utf-8-sig")
+ except UnicodeDecodeError as error:
+ raise ChangelogError(f"module ChangeLog is not valid UTF-8: {path}") from error
+ except OSError as error:
+ raise ChangelogError(f"module ChangeLog is missing or unreadable: {path}") from error
+
+ lines = [line.rstrip() for line in text.splitlines()]
+ if not lines or (header := _HEADER.fullmatch(lines[0])) is None:
+ raise ChangelogError(f"module ChangeLog has no 'Version X.Y.Z' header: {path}")
+ version = _canonical(header.group(1), "ChangeLog version")
+ if version != expected_version:
+ raise ChangelogError(
+ f"module ChangeLog version {version!r} does not match {expected_version!r}"
+ )
+ if len(lines) < 2 or _UNDERLINE.fullmatch(lines[1]) is None:
+ raise ChangelogError(f"module ChangeLog version is not underlined: {path}")
+
+ bullets: list[str] = []
+ for line in lines[2:]:
+ if _HEADER.fullmatch(line) is not None:
+ break
+ if not line:
+ continue
+ bullet = _BULLET.fullmatch(line)
+ if bullet is None:
+ raise ChangelogError(f"module ChangeLog entry body is not a bullet: {line!r}")
+ bullets.append(bullet.group(1))
+ if not bullets:
+ raise ChangelogError(f"module ChangeLog latest entry has no notes: {path}")
+ return "\n".join(f"- {bullet}" for bullet in bullets)
diff --git a/build/core/clean.py b/build/core/clean.py
new file mode 100644
index 0000000..1b5ac58
--- /dev/null
+++ b/build/core/clean.py
@@ -0,0 +1,119 @@
+"""Remove only the repository-owned generated output trees.
+
+The direct build keeps every generated byte below three explicit directories --
+``out/native`` (MSBuild and vcpkg artifacts and test reports), ``out/reports``
+(the source, analysis and diagnostic reports) and ``out/packages`` (release
+archives). ``clean`` removes exactly those and nothing else, so a neighbouring
+``out/sol-team`` checkout or any other unrelated directory is preserved. Every
+recursive removal is refused when the target, or anything below it, is a reparse
+point, so a link can never redirect the delete outside the repository.
+"""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Sequence
+import os
+from pathlib import Path
+import shutil
+import stat
+
+
+_MODES = ("all", "reports")
+_OWNED = {
+ "all": ("native", "reports", "packages"),
+ "reports": ("reports", "packages"),
+}
+_REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
+
+
+class CleanError(RuntimeError):
+ """A generated output tree cannot be proven safe to remove."""
+
+
+def _is_reparse(path: Path) -> bool:
+ try:
+ information = os.lstat(path)
+ except FileNotFoundError:
+ return False
+ attributes = getattr(information, "st_file_attributes", 0)
+ return stat.S_ISLNK(information.st_mode) or bool(attributes & _REPARSE)
+
+
+def _require_plain_tree(path: Path) -> None:
+ """Refuse a reparse point anywhere at or below ``path`` before removal."""
+
+ if _is_reparse(path):
+ raise CleanError(f"reparse point is forbidden in generated output: {path}")
+ if not path.is_dir():
+ return
+ for directory, directories, files in path.walk(follow_symlinks=False):
+ for name in (*directories, *files):
+ child = directory / name
+ if _is_reparse(child):
+ raise CleanError(f"reparse point is forbidden in generated output: {child}")
+
+
+def clean(repository: Path | str, mode: str = "all") -> tuple[Path, ...]:
+ """Remove every owned output directory for ``mode`` and return what was removed."""
+
+ if mode not in _MODES:
+ raise ValueError(f"unsupported clean mode: {mode}")
+ out = Path(repository).resolve(strict=True) / "out"
+ if not out.is_dir():
+ return ()
+ if _is_reparse(out):
+ raise CleanError(f"reparse point is forbidden in generated output: {out}")
+ removed: list[Path] = []
+ for name in _OWNED[mode]:
+ target = out / name
+ if not os.path.lexists(target):
+ continue
+ _require_plain_tree(target)
+ if target.is_dir():
+ shutil.rmtree(target)
+ else:
+ target.unlink()
+ removed.append(target)
+ return tuple(removed)
+
+
+def prepare_fresh(directory: Path | str, boundary: Path | str) -> Path:
+ """Create an empty ``directory`` under ``boundary``, refusing every hazard.
+
+ The path must stay inside ``boundary`` and no hop from ``boundary`` down to
+ the directory, nor anything below it, may be a reparse point. Only then is
+ the previous tree removed and a fresh one created, so a link can never
+ redirect the delete outside the owned output root.
+ """
+
+ base = Path(os.path.abspath(os.fspath(boundary)))
+ target = Path(os.path.abspath(os.fspath(directory)))
+ if target != base and base not in target.parents:
+ raise CleanError(f"generated output escapes its owned root: {directory}")
+ current = target
+ while True:
+ if _is_reparse(current):
+ raise CleanError(f"reparse point is forbidden in generated output: {current}")
+ if current == base:
+ break
+ current = current.parent
+ if os.path.lexists(target):
+ _require_plain_tree(target)
+ shutil.rmtree(target)
+ target.mkdir(parents=True)
+ return target
+
+
+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__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/cpp_coverage.py b/build/core/cpp_coverage.py
new file mode 100644
index 0000000..dbbb0a4
--- /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__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/doctor.py b/build/core/doctor.py
new file mode 100644
index 0000000..b43891d
--- /dev/null
+++ b/build/core/doctor.py
@@ -0,0 +1,88 @@
+"""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_fuzzer_runtimes,
+ resolve_profile_runtimes,
+ 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")
+ probe("profile-runtimes", lambda: resolve_profile_runtimes(require_toolchain()),
+ lambda value: "profile " + "/".join(architecture for architecture, _ in value))
+ probe("fuzzer-runtimes", lambda: resolve_fuzzer_runtimes(require_toolchain()),
+ lambda value: "libFuzzer " + "/".join(architecture for architecture, _ in value))
+ 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__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/host.py b/build/core/host.py
new file mode 100644
index 0000000..9078330
--- /dev/null
+++ b/build/core/host.py
@@ -0,0 +1,110 @@
+"""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, ...]
+ fuzz: 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",)),
+ # Timed libFuzzer runs are x64-only; x86 runs the replay-only gate over the same targets.
+ ("fuzz", ("x86", "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(item for item in runnable if item in {"x86", "x64"}),
+ tuple(deferred),
+ )
diff --git a/build/core/leak.py b/build/core/leak.py
new file mode 100644
index 0000000..be3f487
--- /dev/null
+++ b/build/core/leak.py
@@ -0,0 +1,353 @@
+"""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 _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 _json(name: str, value: object) -> None:
+ _write_json(_output() / name, value)
+
+
+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) -> tuple[bool, bool]:
+ """Report whether this call set `+ust`, and whether traces are live from process start."""
+
+ if not gflags.is_file():
+ return False, False
+ try:
+ current = _run_gflags(gflags, image)
+ except OSError as error:
+ if getattr(error, "winerror", None) != 740:
+ raise
+ return False, False
+ 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, True
+ try:
+ _change_stack_traces(gflags, image, True)
+ except OSError as error:
+ if getattr(error, "winerror", None) != 740:
+ raise
+ return False, False
+ return True, 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(
+ directory: str, umdh_value: str, mode: str, scenario: str,
+ warmup: int, iterations: int, windows: int,
+) -> Path:
+ """Run one probe under UMDH and return the directory of its window snapshots."""
+
+ probe, umdh = _file(str(Path(directory) / BINARIES[0]), "leak probe"), _file(umdh_value, "UMDH")
+ gflags = umdh.with_name("gflags.exe")
+ 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, traced = _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, "stackTracesFromProcessStart": traced})
+ return snapshot_dir
+
+
+def _diff(
+ umdh_value: str, directory: str, label: str, before: Path, after: Path, destination: Path
+) -> dict[str, object]:
+ """Compare one snapshot pair and record its report beside the parsed growth."""
+
+ report = destination / f"{label}.txt"
+ environment = os.environ | {"_NT_SYMBOL_PATH": directory, "OANOCACHE": "1"}
+ result = subprocess.run([umdh_value, "-d", str(before), str(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)}
+ document = {"label": label, "totalIncrease": total, "positiveStacks": stacks, "report": report.name}
+ _write_json(destination / f"{label}.json", document)
+ return document
+
+
+def _summary(
+ mode: str, scenario: str, warmup: int, iterations: int, windows: int,
+ tolerance: int, records: Sequence[dict[str, object]],
+) -> None:
+ 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
+ _json("summary.json", {"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})
+
+
+def _measure(args: Sequence[str]) -> None:
+ """Capture one scenario, diff every window pair, and judge the slope in one process."""
+
+ directory, umdh_value, mode, scenario, *values = _exact("measure", args, 8)
+ _selection(mode, scenario)
+ warmup, iterations = _count(values[0], "warmup"), _count(values[1], "iterations")
+ windows, tolerance = _count(values[2], "windows", minimum=3), _count(values[3], "tolerance", minimum=0)
+ snapshots = _capture(directory, umdh_value, mode, scenario, warmup, iterations, windows)
+ destination = _output() / "diffs"
+ destination.mkdir()
+ 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"))
+ records = [
+ _diff(umdh_value, directory, label, before, after, destination)
+ for label, before, after in specs
+ ]
+ _summary(mode, scenario, warmup, iterations, windows, tolerance, records)
+
+
+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, "measure": _measure, "gate": _gate,
+}
+
+
+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, measure, or gate")
+ _ACTIONS[arguments[0]](arguments[1:])
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/module_version.py b/build/core/module_version.py
new file mode 100644
index 0000000..e25d9dd
--- /dev/null
+++ b/build/core/module_version.py
@@ -0,0 +1,159 @@
+"""Per-module release versions, content identity, and binary version evidence."""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Iterable, Sequence
+import hashlib
+from pathlib import Path
+import re
+
+import pywintypes
+import win32api
+
+
+MODULES = ("renpy", "rpgmaker", "zanzarah")
+VERSION_FILE = "VERSION"
+# The props file injects the version macros into every module translation unit, so it belongs to
+# the inputs every project signs; the resource script is compiled only into the shipped DLLs.
+VERSION_PROPS = "build/ObserverModuleVersion.props"
+VERSION_RESOURCE = "src/modules/version.rc"
+_FUZZ_PREFIX = "fuzz-"
+# Dependency bytes are chosen by the manifest and the repository-owned overlay tree; a bump there
+# changes what is statically linked into every shipped module.
+_DEPENDENCY_MANIFEST = "vcpkg.json"
+_OPTIONAL_DEPENDENCY_INPUTS = ("vcpkg-configuration.json",)
+_DEPENDENCY_TREE = "build/vcpkg"
+_COMPONENT = r"(?:0|[1-9][0-9]{0,4})"
+_VERSION = re.compile(rf"{_COMPONENT}\.{_COMPONENT}\.{_COMPONENT}")
+_MAXIMUM = 0xFFFF
+
+
+class ModuleVersionError(RuntimeError):
+ """A module version is missing, malformed, or absent from the built binary."""
+
+
+def version_path(module: str) -> str:
+ """Return the repository-relative single source of truth for one module."""
+
+ if module not in MODULES:
+ raise ModuleVersionError(f"unknown module: {module}")
+ return f"src/modules/{module}/{VERSION_FILE}"
+
+
+def project_module(project: str) -> str | None:
+ """Return the module whose VERSION decides one project's compiled version macros."""
+
+ if project in MODULES:
+ return project
+ module = project.removeprefix(_FUZZ_PREFIX)
+ return module if module != project and module in MODULES else None
+
+
+def project_version_inputs(project: str) -> tuple[str, ...]:
+ """Return the version inputs one project must sign beyond its declared inputs."""
+
+ module = project_module(project)
+ if module is None:
+ return ()
+ resource = (VERSION_RESOURCE,) if project in MODULES else ()
+ return (*resource, version_path(module))
+
+
+def shared_content_inputs(repository: Path) -> tuple[str, ...]:
+ """Return the repository-wide files that decide every module's shipped bytes."""
+
+ names = [VERSION_PROPS, VERSION_RESOURCE, _DEPENDENCY_MANIFEST]
+ names.extend(
+ name for name in _OPTIONAL_DEPENDENCY_INPUTS if (repository / name).is_file()
+ )
+ names.extend(
+ path.relative_to(repository).as_posix()
+ for path in (repository / _DEPENDENCY_TREE).rglob("*")
+ if path.is_file()
+ )
+ return tuple(sorted(set(names)))
+
+
+def parse_version(version: str) -> tuple[int, int, int]:
+ """Validate one ``X.Y.Z`` release version and return its numeric components."""
+
+ if _VERSION.fullmatch(version) is None:
+ raise ModuleVersionError(f"module version must be X.Y.Z: {version!r}")
+ major, minor, patch = (int(part) for part in version.split("."))
+ if max(major, minor, patch) > _MAXIMUM:
+ raise ModuleVersionError(f"module version component exceeds {_MAXIMUM}: {version}")
+ return major, minor, patch
+
+
+def read_version(repository: Path, module: str) -> str:
+ """Read and validate one module's declared version from its VERSION file."""
+
+ path = repository / version_path(module)
+ try:
+ content = path.read_text(encoding="utf-8")
+ except OSError as error:
+ raise ModuleVersionError(f"module version file is unreadable: {path}") from error
+ version = content.strip()
+ parse_version(version)
+ return version
+
+
+def content_identity(repository: Path, paths: Iterable[str]) -> str:
+ """Hash the exact first-party inputs that decide whether a module changed."""
+
+ names = sorted(set(paths))
+ if not names:
+ raise ModuleVersionError("module content identity requires at least one input")
+ digest = hashlib.sha256()
+ for name in names:
+ if name.rsplit("/", 1)[-1] == VERSION_FILE:
+ raise ModuleVersionError(f"module content identity must exclude {name}")
+ with (repository / name).open("rb") as stream:
+ entry = hashlib.file_digest(stream, "sha256").hexdigest()
+ digest.update(f"{name}\0{entry}\n".encode("utf-8"))
+ return digest.hexdigest()
+
+
+def _quad(high: int, low: int) -> tuple[int, int, int, int]:
+ return (high >> 16 & 0xFFFF, high & 0xFFFF, low >> 16 & 0xFFFF, low & 0xFFFF)
+
+
+def binary_version(binary: Path) -> tuple[tuple[int, ...], tuple[int, ...]]:
+ """Return the FileVersion and ProductVersion of a PE VERSIONINFO resource."""
+
+ try:
+ information = win32api.GetFileVersionInfo(str(binary), "\\")
+ except pywintypes.error as error:
+ raise ModuleVersionError(f"binary has no version resource: {binary}") from error
+ return (
+ _quad(information["FileVersionMS"], information["FileVersionLS"]),
+ _quad(information["ProductVersionMS"], information["ProductVersionLS"]),
+ )
+
+
+def require_binary_version(binary: Path, version: str) -> None:
+ """Fail closed unless a binary carries exactly its declared release version."""
+
+ expected = (*parse_version(version), 0)
+ file_version, product_version = binary_version(binary)
+ if (file_version, product_version) != (expected, expected):
+ raise ModuleVersionError(
+ f"binary version resource is {file_version}/{product_version}, "
+ f"expected {expected}: {binary}"
+ )
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(required=True)
+ verify = commands.add_parser("verify")
+ verify.add_argument("version")
+ verify.add_argument("binary", type=Path)
+ args = parser.parse_args(argv)
+ require_binary_version(args.binary, args.version)
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/package.py b/build/core/package.py
new file mode 100644
index 0000000..fc97a43
--- /dev/null
+++ b/build/core/package.py
@@ -0,0 +1,291 @@
+"""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 re
+import shutil
+import subprocess
+from collections.abc import Sequence
+import zipfile
+
+from core.module_version import ModuleVersionError, parse_version
+from core.changelog import CHANGELOG_FILE, ChangelogError, latest_notes
+
+
+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)
+_IDENTITY = re.compile(r"[0-9a-f]{64}")
+
+
+class PackageError(RuntimeError):
+ pass
+
+
+def module_archive_name(module: str, version: str, architecture: str) -> str:
+ """Return the published archive name that carries the module's release version."""
+
+ return f"{module}-{version}-{architecture}.zip"
+
+
+def _require_version(version: str) -> str:
+ try:
+ parse_version(version)
+ except ModuleVersionError as error:
+ raise PackageError(f"invalid module version: {version}") from error
+ return version
+
+
+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, version: str = ""
+) -> dict[str, object]:
+ document: dict[str, object] = {
+ "architecture": architecture,
+ "entries": _entries(_payload_files(payload)),
+ "kind": kind,
+ "module": module,
+ }
+ if version:
+ document["version"] = version
+ return document
+
+
+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], version: str = ""
+) -> 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, version),
+ )
+
+
+def _stage_module(args: argparse.Namespace) -> None:
+ repository = args.repository
+ version = _require_version(args.version)
+ try:
+ latest_notes(repository, args.module, version)
+ except ChangelogError as error:
+ raise PackageError(
+ f"{args.module} ChangeLog does not describe {version}: {error}"
+ ) from error
+ files = {
+ "ChangeLog": repository / f"src/modules/{args.module}/{CHANGELOG_FILE}",
+ 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, version)
+
+
+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, version: str = ""
+) -> dict[str, Path]:
+ payload = stage / "payload"
+ expected = _document(kind, architecture, module, payload, version)
+ 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:
+ version = _require_version(args.version)
+ files = _stage_payload(args.stage, "module", args.architecture, args.module, version)
+ _zip(_output() / module_archive_name(args.module, version, args.architecture), 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:
+ version = _require_version(args.version)
+ _validate(
+ args.archive,
+ _stage_payload(args.stage, "module", args.architecture, args.module, version),
+ )
+
+
+def _validate_symbols(args: argparse.Namespace) -> None:
+ _validate(args.archive, _symbol_payload(args))
+
+
+def _module_records(values: Sequence[str]) -> list[dict[str, str]]:
+ records: dict[str, dict[str, str]] = {}
+ for value in values:
+ module, _, remainder = value.partition("=")
+ version, _, identity = remainder.partition("=")
+ if module not in MODULES or _IDENTITY.fullmatch(identity) is None:
+ raise PackageError(f"invalid module record: {value}")
+ _require_version(version)
+ if module in records:
+ raise PackageError(f"duplicate module record: {module}")
+ records[module] = {"content": identity, "module": module, "version": version}
+ if set(records) != set(MODULES):
+ raise PackageError("package manifest requires the expected module set")
+ return [records[module] for module in sorted(records)]
+
+
+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")
+ modules = _module_records(args.modules)
+ output = _output()
+ archives = sorted(args.archives, key=lambda path: path.name)
+ for archive in archives:
+ shutil.copyfile(archive, output / archive.name)
+ _write_json(
+ output / "packages.json",
+ {
+ "archives": [
+ {"name": archive.name, "sha256": _sha256(archive)} for archive in archives
+ ],
+ "modules": modules,
+ },
+ )
+
+
+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", "version", "binary", "repository"), _stage_module),
+ ("stage-symbol", ("architecture", "module", "symbol"), _stage_symbol),
+ ("archive-module", ("architecture", "module", "version", "stage"), _archive_module),
+ ("archive-symbols", ("architecture", "stages"), _archive_symbols),
+ ("validate-module", ("architecture", "module", "version", "archive", "stage"), _validate_module),
+ ("validate-symbols", ("architecture", "archive", "stages"), _validate_symbols),
+ ("aggregate", ("modules", "archives"), _aggregate),
+ ("smoke", ("architecture", "module", "archive", "tests"), _smoke),
+ )
+ for command, names, action in actions:
+ current = commands.add_parser(command)
+ for name in names:
+ if name == "modules":
+ current.add_argument("--module", dest="modules", action="append", required=True)
+ continue
+ 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 or name == "version" else Path,
+ )
+ current.set_defaults(run=action)
+ args = parser.parse_args(argv)
+ args.run(args)
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/python_coverage.py b/build/core/python_coverage.py
new file mode 100644
index 0000000..b070fa7
--- /dev/null
+++ b/build/core/python_coverage.py
@@ -0,0 +1,74 @@
+"""Run the project-local coverage.py gate and publish its native evidence.
+
+Coverage always runs through the *current* interpreter (``python -m coverage``)
+so the pinned project interpreter owns the run. The repository venv once held
+a ``coverage.exe`` launcher bound to a missing interpreter; that launcher is no
+longer trusted. ``main`` keeps accepting it only as a compatibility argument
+for the legacy graph entry point.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+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 run(build_root: Path | str) -> Path:
+ """Run the coverage gate for ``build_root`` and return the report path."""
+
+ build_root = Path(build_root).resolve(strict=True)
+ config = build_root / "pyproject.toml"
+ if not build_root.is_dir() or not config.is_file():
+ raise FileNotFoundError("coverage build root and config must exist")
+
+ work, output = _directory("OBSERVER_BUILD_DIR"), _directory("OBSERVER_OUT_DIR")
+ data = work / ".coverage"
+ command = [sys.executable, "-m", "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 output / "coverage.txt"
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ # ``coverage`` is the legacy venv launcher path, accepted for compatibility
+ # only; the actual run always uses ``sys.executable -m coverage``.
+ parser.add_argument("coverage", type=Path)
+ parser.add_argument("build_root", type=Path)
+ args = parser.parse_args(argv)
+ if not args.coverage.resolve(strict=True).is_file():
+ raise FileNotFoundError("coverage executable, build root, and config must exist")
+ run(args.build_root)
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/quality_tools.py b/build/core/quality_tools.py
new file mode 100644
index 0000000..96f05e9
--- /dev/null
+++ b/build/core/quality_tools.py
@@ -0,0 +1,255 @@
+"""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",
+}
+# clang resolves its runtime directory relative to its own binary, so every 32-bit compiler-rt
+# archive only ever exists in the 32-bit toolset that ships beside the x64 one. ARM64 coverage and
+# ARM64 fuzzing are rejected, so that toolset is deliberately absent here.
+_LLVM_TOOLSETS = {"x86": "VC/Tools/Llvm", "x64": "VC/Tools/Llvm/x64"}
+PROFILE_LIBRARIES = {
+ "x86": "clang_rt.profile-i386.lib",
+ "x64": "clang_rt.profile-x86_64.lib",
+}
+# ObserverFuzz.props links exactly these three archives per architecture.
+FUZZER_LIBRARIES = {
+ "x86": (
+ "clang_rt.fuzzer-i386.lib",
+ "clang_rt.asan-i386.lib",
+ "clang_rt.asan_cxx-i386.lib",
+ ),
+ "x64": (
+ "clang_rt.fuzzer-x86_64.lib",
+ "clang_rt.asan-x86_64.lib",
+ "clang_rt.asan_cxx-x86_64.lib",
+ ),
+}
+
+
+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 _runtime_directory(toolchain: MsvcToolchain, toolset: Path, name: str) -> Path:
+ llvm = _identity_value(toolchain, "clang_tidy_version", "LLVM clang_tidy_version")
+ root = toolset / "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 {name} runtime directory: {', '.join(map(str, candidates))}")
+ return directory
+
+
+def resolve_ubsan_runtime(toolchain: MsvcToolchain) -> ResolvedDirectory:
+ """Resolve only the LLVM x64 UBSan archive pair."""
+
+ directory = _runtime_directory(toolchain, toolchain.llvm_dir, "UBSan")
+ files = tuple(resolve_tool(directory / name, f"UBSan runtime {name}") for name in UBSAN_LIBRARIES)
+ return ResolvedDirectory(directory, files)
+
+
+def resolve_profile_runtime(toolchain: MsvcToolchain, architecture: str) -> ResolvedDirectory:
+ """Resolve the LLVM profile archive from the toolset that owns the requested architecture."""
+
+ if architecture not in PROFILE_LIBRARIES:
+ raise ValueError(f"coverage has no profile runtime for {architecture}")
+ directory = _runtime_directory(
+ toolchain,
+ toolchain.installation / _LLVM_TOOLSETS[architecture],
+ f"{architecture} profile",
+ )
+ name = PROFILE_LIBRARIES[architecture]
+ return ResolvedDirectory(
+ directory, (resolve_tool(directory / name, f"profile runtime {name}"),)
+ )
+
+
+def resolve_profile_runtimes(
+ toolchain: MsvcToolchain,
+) -> tuple[tuple[str, ResolvedDirectory], ...]:
+ """Resolve every profile runtime the Coverage configuration claims to support."""
+
+ return tuple(
+ (architecture, resolve_profile_runtime(toolchain, architecture))
+ for architecture in PROFILE_LIBRARIES
+ )
+
+
+def resolve_fuzzer_runtime(toolchain: MsvcToolchain, architecture: str) -> ResolvedDirectory:
+ """Resolve the libFuzzer and ASan archives from the toolset that owns the architecture."""
+
+ if architecture not in FUZZER_LIBRARIES:
+ raise ValueError(f"fuzzing has no libFuzzer runtime for {architecture}")
+ directory = _runtime_directory(
+ toolchain,
+ toolchain.installation / _LLVM_TOOLSETS[architecture],
+ f"{architecture} libFuzzer",
+ )
+ return ResolvedDirectory(directory, tuple(
+ resolve_tool(directory / name, f"libFuzzer runtime {name}")
+ for name in FUZZER_LIBRARIES[architecture]
+ ))
+
+
+def resolve_fuzzer_runtimes(
+ toolchain: MsvcToolchain,
+) -> tuple[tuple[str, ResolvedDirectory], ...]:
+ """Resolve every libFuzzer runtime the Fuzz configuration claims to support."""
+
+ return tuple(
+ (architecture, resolve_fuzzer_runtime(toolchain, architecture))
+ for architecture in FUZZER_LIBRARIES
+ )
+
+
+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/release.py b/build/core/release.py
new file mode 100644
index 0000000..23d097a
--- /dev/null
+++ b/build/core/release.py
@@ -0,0 +1,565 @@
+"""Fail-closed module release gate over published package manifests.
+
+A release is named by a module tag, ``/vX.Y.Z``: the tag selects exactly one module and
+its own SemVer lineage in ``src/modules//VERSION``. This command validates the tag,
+compares the module's content identity recorded in ``packages.json`` against the previously
+published release of the *same* module lineage, and refuses a release whose version or content
+disagree. It also verifies the bytes of the supplied archive assets before a release job
+publishes them. The default all-module gate remains available for a repository-wide release.
+"""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Iterable, Sequence
+import hashlib
+import json
+from pathlib import Path
+import re
+
+from core.module_version import (
+ MODULES,
+ ModuleVersionError,
+ parse_version,
+ read_version,
+)
+from core.changelog import ChangelogError, latest_notes
+
+
+# The architectures a module release must publish. Kept here (rather than imported from the
+# packaging code) so the gate is an independent oracle for the names it verifies.
+ARCHITECTURES = ("x86", "x64", "arm64")
+_TAG_ROOT = "v"
+_IDENTITY = re.compile(r"[0-9a-f]{64}")
+# A published asset is a plain file name, so any separator or traversal segment is rejected.
+_ARCHIVE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\.zip")
+_BOOTSTRAP_NOTE = (
+ "no previous release manifest was available, so the module version gate recorded a "
+ "bootstrap release instead of comparing content identities"
+)
+
+
+class ReleaseError(RuntimeError):
+ """A release tag, manifest, or asset set is malformed or violates the version gate."""
+
+
+def module_archive(module: str, version: str, architecture: str) -> str:
+ """Return the published archive name that carries one module's release version."""
+
+ return f"{module}-{version}-{architecture}.zip"
+
+
+def symbol_archive(module: str, version: str, architecture: str) -> str:
+ """Return the published PDB archive name that carries one module's release version."""
+
+ return f"{module}-{version}-{architecture}-pdb.zip"
+
+
+def expected_archives(
+ module: str, version: str, architectures: Sequence[str] = ARCHITECTURES
+) -> tuple[str, ...]:
+ """Return every archive a subdir-per-architecture module release must publish."""
+
+ return tuple(
+ name
+ for architecture in architectures
+ for name in (module_archive(module, version, architecture), symbol_archive(module, version, architecture))
+ )
+
+
+def parse_tag(tag: str) -> tuple[str, str]:
+ """Split one ``/vX.Y.Z`` release tag, rejecting anything malformed."""
+
+ module, separator, version = tag.partition(f"/{_TAG_ROOT}")
+ if not separator:
+ raise ReleaseError(f"release tag must be /v: {tag!r}")
+ if module not in MODULES:
+ raise ReleaseError(f"unknown release module: {module!r}")
+ try:
+ parse_version(version)
+ except ModuleVersionError as error:
+ raise ReleaseError(f"release tag version must be X.Y.Z: {tag!r}") from error
+ return module, version
+
+
+def validate_tag(repository: Path, tag: str) -> tuple[str, str]:
+ """Resolve one release tag and require it to match the module's declared version."""
+
+ module, version = parse_tag(tag)
+ try:
+ declared = read_version(repository, module)
+ except ModuleVersionError as error:
+ raise ReleaseError(f"module version file is unreadable for {module!r}") from error
+ if declared != version:
+ raise ReleaseError(
+ f"release tag {tag!r} does not match src/modules/{module}/VERSION ({declared})"
+ )
+ try:
+ latest_notes(repository, module, version)
+ except ChangelogError as error:
+ raise ReleaseError(
+ f"release tag {tag!r} does not match the {module!r} ChangeLog: {error}"
+ ) from error
+ return module, version
+
+
+def stable_release_tags(releases: Iterable[str]) -> list[str]:
+ """Extract the tag of every published, stable release from ``gh api`` JSON lines.
+
+ The workflow lists every page of the release history as one JSON object per line carrying the
+ ``tag``, ``draft`` and ``prerelease`` fields. Draft and prerelease entries, blank lines, and
+ records that are not JSON objects with a string tag are ignored, so an unpublished draft can
+ never disturb a published lineage.
+ """
+
+ tags: list[str] = []
+ for line in releases:
+ text = line.strip()
+ if not text:
+ continue
+ try:
+ record = json.loads(text)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(record, dict):
+ continue
+ if record.get("draft") or record.get("prerelease"):
+ continue
+ tag = record.get("tag")
+ if isinstance(tag, str):
+ tags.append(tag)
+ return tags
+
+
+def resolve_previous(
+ tag: str, releases: Iterable[str]
+) -> str | None:
+ """Return the release a *new* publication of ``tag`` must be compared against, if any.
+
+ ``releases`` is the published stable release history of every module; only tags of ``tag``'s
+ own module are candidates and the release under test is never mistaken for its own
+ predecessor. The latest published release of the lineage is returned whatever its version, so
+ publishing an older version than the current head resolves that head and the version gate
+ rejects the backwards release, instead of silently bootstrapping it.
+
+ A rerun of an already-published tag never reaches here: it reads the predecessor recorded in
+ the immutable release report instead, so history that moved on afterwards (or a hand-inserted
+ older release) can never change what the rerun compares against.
+ """
+
+ module, version = parse_tag(tag)
+ current = parse_version(version)
+ candidates: list[tuple[tuple[int, int, int], str]] = []
+ for candidate in releases:
+ text = candidate.strip()
+ try:
+ candidate_module, candidate_version = parse_tag(text)
+ except ReleaseError:
+ continue
+ if candidate_module != module:
+ continue
+ parsed = parse_version(candidate_version)
+ if parsed == current:
+ continue
+ candidates.append((parsed, text))
+ return max(candidates)[1] if candidates else None
+
+
+def recorded_previous_tag(tag: str, report: object) -> str | None:
+ """Return the predecessor an already-published release recorded for ``tag``.
+
+ A rerun must compare against the ``previous_version`` written into the immutable
+ ``release.json`` by the original publication, never against the mutable release history. The
+ report has to describe exactly the tag's own module at the tag's version, and record a
+ ``previous_version`` that is either ``null`` (a bootstrap release, which has no predecessor) or
+ a canonical version strictly older than the tag.
+ """
+
+ module, version = parse_tag(tag)
+ if not isinstance(report, dict):
+ raise ReleaseError("current release report must be a JSON object")
+ records = report.get("modules")
+ if not isinstance(records, list):
+ raise ReleaseError("current release report must describe its modules")
+ matching = [
+ record
+ for record in records
+ if isinstance(record, dict) and record.get("module") == module
+ ]
+ if len(matching) != 1:
+ raise ReleaseError(
+ f"current release report must describe exactly one {module!r} module"
+ )
+ record = matching[0]
+ if record.get("version") != version:
+ raise ReleaseError(
+ f"current release report version does not match the tag for {module!r}"
+ )
+ if "previous_version" not in record:
+ raise ReleaseError(
+ f"current release report records no previous_version for {module!r}"
+ )
+ previous = record["previous_version"]
+ if previous is None:
+ return None
+ try:
+ parse_version(previous if isinstance(previous, str) else "")
+ except ModuleVersionError as error:
+ raise ReleaseError(
+ f"current release report records an invalid previous_version for {module!r}"
+ ) from error
+ if parse_version(previous) >= parse_version(version):
+ raise ReleaseError(
+ f"current release report previous_version is not older than {module!r} {version}"
+ )
+ return f"{module}/{_TAG_ROOT}{previous}"
+
+
+def _plain_archive(name: str) -> str:
+ if _ARCHIVE_NAME.fullmatch(name) is None:
+ raise ReleaseError(f"release asset name must be a plain zip file name: {name!r}")
+ return name
+
+
+def _sha256(path: Path) -> str:
+ with path.open("rb") as stream:
+ return hashlib.file_digest(stream, "sha256").hexdigest()
+
+
+def _record(value: object) -> dict[str, str]:
+ if not isinstance(value, dict):
+ raise ReleaseError("release manifest module record must be an object")
+ module, version, content = (
+ value.get("module"), value.get("version"), value.get("content")
+ )
+ if not isinstance(content, str) or _IDENTITY.fullmatch(content) is None:
+ raise ReleaseError(f"invalid module content identity for {module!r}")
+ try:
+ parse_version(version if isinstance(version, str) else "")
+ except ModuleVersionError as error:
+ raise ReleaseError(f"module version must be X.Y.Z for {module!r}") from error
+ return {"content": content, "module": str(module), "version": version}
+
+
+def _modules(
+ document: dict[str, object], *, expected: frozenset[str] | None = frozenset(MODULES)
+) -> list[dict[str, str]]:
+ records = document.get("modules")
+ if not isinstance(records, list):
+ raise ReleaseError("release manifest must be a JSON object with modules and archives")
+ parsed = [_record(value) for value in records]
+ names = {record["module"] for record in parsed}
+ if len(names) != len(parsed) or (expected is not None and names != set(expected)):
+ raise ReleaseError("release manifest requires the expected module set")
+ # Published history is immutable: a previous release may predate a module or outlive a
+ # retired one, so only the release under test has to describe the current module set.
+ return sorted(
+ (record for record in parsed if record["module"] in MODULES),
+ key=lambda record: record["module"],
+ )
+
+
+def _archives(document: dict[str, object]) -> list[dict[str, str]]:
+ records = document.get("archives")
+ if not isinstance(records, list):
+ raise ReleaseError("release manifest must be a JSON object with modules and archives")
+ parsed = []
+ for value in records:
+ if not isinstance(value, dict) or not isinstance(value.get("name"), str) or (
+ not isinstance(value.get("sha256"), str)
+ ):
+ raise ReleaseError("invalid release archive record")
+ parsed.append({"name": _plain_archive(value["name"]), "sha256": value["sha256"]})
+ return parsed
+
+
+def _unique(archives: Iterable[dict[str, str]]) -> list[dict[str, str]]:
+ ordered: dict[str, dict[str, str]] = {}
+ for archive in archives:
+ if archive["name"] in ordered:
+ raise ReleaseError(f"duplicate release archive: {archive['name']}")
+ ordered[archive["name"]] = archive
+ return [ordered[name] for name in sorted(ordered)]
+
+
+def load_manifest(
+ path: Path, *, expected: frozenset[str] | None = frozenset(MODULES)
+) -> dict[str, object]:
+ """Read and validate one published ``packages.json`` document."""
+
+ try:
+ document = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise ReleaseError(f"unreadable release manifest: {path}") from error
+ if not isinstance(document, dict):
+ raise ReleaseError("release manifest must be a JSON object with modules and archives")
+ return {
+ "archives": _unique(_archives(document)),
+ "modules": _modules(document, expected=expected),
+ }
+
+
+def merge_manifests(
+ documents: Sequence[dict[str, object]],
+ *,
+ expected: frozenset[str] | None = frozenset(MODULES),
+) -> dict[str, object]:
+ """Union per-architecture manifests that must agree about every module.
+
+ ``expected`` is ``None`` for a module release whose packaging may already have selected the
+ module, so the manifests only have to agree with each other; the default all-module release
+ still requires every manifest to describe the repository's current module set.
+ """
+
+ if not documents:
+ raise ReleaseError("a release requires at least one release manifest")
+ first, *rest = documents
+ modules = _modules(first, expected=expected)
+ names = [record["module"] for record in modules]
+ for document in rest:
+ others = _modules(document, expected=expected)
+ if [record["module"] for record in others] != names:
+ raise ReleaseError("release manifests describe different module sets")
+ for left, right in zip(modules, others, strict=True):
+ if left != right:
+ raise ReleaseError(f"release manifests disagree about {left['module']}")
+ archives = _unique(
+ archive for document in documents for archive in _archives(document)
+ )
+ return {"archives": archives, "modules": modules}
+
+
+def select_module(document: dict[str, object], module: str) -> dict[str, object]:
+ """Restrict a merged all-module manifest to the one module a release publishes."""
+
+ modules = [record for record in document["modules"] if record["module"] == module]
+ if not modules:
+ raise ReleaseError(f"release manifest has no {module!r} record")
+ prefix = f"{module}-"
+ archives = [
+ archive
+ for archive in document["archives"]
+ if archive["name"].startswith(prefix)
+ ]
+ return {"archives": archives, "modules": modules}
+
+
+def verify_assets(directory: Path, archives: Sequence[dict[str, str]]) -> None:
+ """Fail closed unless the asset directory is exactly the manifest's archive bytes."""
+
+ if not directory.is_dir():
+ raise ReleaseError(f"release assets directory must exist: {directory}")
+ expected = {archive["name"]: archive["sha256"] for archive in archives}
+ found = {path.name for path in directory.iterdir() if path.is_file()}
+ if found != set(expected):
+ missing = sorted(set(expected) - found)
+ extra = sorted(found - set(expected))
+ raise ReleaseError(
+ f"release assets do not match the module archives; missing={missing} extra={extra}"
+ )
+ for name, digest in expected.items():
+ if _sha256(directory / name) != digest:
+ raise ReleaseError(f"release asset digest mismatch: {name}")
+
+
+def _decide(current: dict[str, str], previous: dict[str, dict[str, str]] | None) -> dict[str, object]:
+ module, version, content = current["module"], current["version"], current["content"]
+ decision = {**current, "previous_version": None, "status": "bootstrap"}
+ if previous is None:
+ return decision
+ earlier = previous.get(module)
+ if earlier is None:
+ return {**decision, "status": "new"}
+ decision["previous_version"] = earlier["version"]
+ order = (parse_version(version) > parse_version(earlier["version"])) - (
+ parse_version(version) < parse_version(earlier["version"])
+ )
+ changed = content != earlier["content"]
+ if order < 0:
+ raise ReleaseError(
+ f"{module} version {version} is older than {earlier['version']}"
+ )
+ if order == 0 and changed:
+ raise ReleaseError(f"{module} content changed without a version bump")
+ if order > 0 and not changed:
+ raise ReleaseError(f"{module} version bumped without a content change")
+ return {**decision, "status": "released" if changed else "unchanged"}
+
+
+def evaluate(
+ current: dict[str, object],
+ previous: dict[str, object] | None,
+ *,
+ expected: frozenset[str] = frozenset(MODULES),
+) -> dict[str, object]:
+ """Return the recorded per-module release decisions or fail the release."""
+
+ earlier = None if previous is None else {
+ record["module"]: record for record in _modules(previous, expected=None)
+ }
+ modules = [_decide(record, earlier) for record in _modules(current, expected=expected)]
+ return {
+ "bootstrap": previous is None,
+ "modules": modules,
+ "note": _BOOTSTRAP_NOTE if previous is None else "",
+ "title": ", ".join(
+ f"{record['module']} {record['version']}" for record in modules
+ ),
+ }
+
+
+def render_notes(report: dict[str, object], notes: str = "") -> str:
+ """Render the release body, appending the module's human ChangeLog entry when given."""
+
+ rows = "\n".join(
+ f"| {record['module']} | {record['version']} | {record['status']} |"
+ for record in report["modules"]
+ )
+ note = report["note"]
+ body = (
+ "Per-module release versions.\n\n"
+ "| Module | Version | Gate |\n|---|---|---|\n"
+ f"{rows}\n" + (f"\nThe module version gate reported: {note}.\n" if note else "")
+ )
+ if notes:
+ body += f"\n{notes}\n"
+ return body
+
+
+def _write(path: Path, document: object) -> None:
+ with path.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 _notes(output: Path, report: dict[str, object], notes: str = "") -> None:
+ with (output / "notes.md").open("x", encoding="utf-8", newline="\n") as stream:
+ stream.write(render_notes(report, notes))
+
+
+def _tag(args: argparse.Namespace) -> None:
+ module, version = validate_tag(args.repository, args.tag)
+ if args.github_output is not None:
+ with args.github_output.open("a", encoding="utf-8", newline="\n") as stream:
+ stream.write(f"module={module}\nversion={version}\n")
+
+
+def _previous(args: argparse.Namespace) -> None:
+ if not args.current_exists and args.current_report is not None:
+ raise ReleaseError("--current-report requires --current-exists")
+ if args.current_exists:
+ if args.current_report is None:
+ raise ReleaseError(
+ "a rerun of a published tag requires --current-report so its predecessor is "
+ "never reconstructed from history"
+ )
+ try:
+ report = json.loads(args.current_report.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise ReleaseError(
+ f"unreadable current release report: {args.current_report}"
+ ) from error
+ predecessor = recorded_previous_tag(args.tag, report)
+ elif args.releases is not None:
+ lines = args.releases.read_text(encoding="utf-8").splitlines()
+ predecessor = resolve_previous(args.tag, stable_release_tags(lines))
+ else:
+ raise ReleaseError("a new publication requires --releases")
+ with args.output.open("w", encoding="utf-8", newline="\n") as stream:
+ stream.write(f"{predecessor or ''}\n")
+
+
+def _gate(args: argparse.Namespace) -> None:
+ if not args.output.is_dir():
+ raise ReleaseError(f"release output directory must exist: {args.output}")
+ previous = (
+ None if args.previous is None else load_manifest(args.previous, expected=None)
+ )
+ human_notes = ""
+ if args.module is None:
+ current = merge_manifests([load_manifest(path) for path in args.manifests])
+ report = evaluate(current, previous)
+ else:
+ if args.tag is None or args.repository is None or args.assets is None:
+ raise ReleaseError("a module release requires --tag, --repository, and --assets")
+ tag_module, version = validate_tag(args.repository, args.tag)
+ if tag_module != args.module:
+ raise ReleaseError(
+ f"release tag {args.tag!r} selects {tag_module!r}, not {args.module!r}"
+ )
+ if previous is not None and all(
+ record["module"] != args.module for record in previous["modules"]
+ ):
+ raise ReleaseError(
+ f"previous release manifest does not describe {args.module!r}"
+ )
+ current = select_module(
+ merge_manifests(
+ [load_manifest(path, expected=None) for path in args.manifests],
+ expected=None,
+ ),
+ args.module,
+ )
+ record = current["modules"][0]
+ if record["version"] != version:
+ raise ReleaseError(
+ f"{args.module} tag version {version} does not match "
+ f"package version {record['version']}"
+ )
+ if {archive["name"] for archive in current["archives"]} != set(
+ expected_archives(args.module, version)
+ ):
+ raise ReleaseError(
+ f"{args.module} release archives must be exactly its module and symbol packages"
+ )
+ verify_assets(args.assets, current["archives"])
+ # ``validate_tag`` already refused a ChangeLog that disagrees with the tag, so this read
+ # only has to supply the human notes for the published body.
+ human_notes = latest_notes(args.repository, args.module, version)
+ report = evaluate(current, previous, expected=frozenset({args.module}))
+ _write(args.output / "packages.json", current)
+ _write(args.output / "release.json", report)
+ _notes(args.output, report, human_notes)
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(required=True, dest="command")
+ tag = commands.add_parser("tag")
+ tag.add_argument("--repository", required=True, type=Path)
+ tag.add_argument("--tag", required=True)
+ tag.add_argument("--github-output", type=Path)
+ previous = commands.add_parser("previous")
+ previous.add_argument("--tag", required=True)
+ previous.add_argument("--releases", type=Path)
+ previous.add_argument("--output", required=True, type=Path)
+ previous.add_argument(
+ "--current-exists",
+ action="store_true",
+ help="the exact tag is already published, so the rerun reuses its recorded predecessor",
+ )
+ previous.add_argument(
+ "--current-report",
+ type=Path,
+ help="the published release.json whose recorded previous_version a rerun compares against",
+ )
+ gate = commands.add_parser("gate")
+ gate.add_argument("--output", required=True, type=Path)
+ gate.add_argument("--previous", type=Path)
+ gate.add_argument("--module", choices=MODULES)
+ gate.add_argument("--tag")
+ gate.add_argument("--repository", type=Path)
+ gate.add_argument("--assets", type=Path)
+ gate.add_argument("manifests", nargs="+", type=Path)
+ args = parser.parse_args(argv)
+ if args.command == "tag":
+ _tag(args)
+ elif args.command == "previous":
+ _previous(args)
+ else:
+ _gate(args)
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/result_export.py b/build/core/result_export.py
new file mode 100644
index 0000000..b34ecad
--- /dev/null
+++ b/build/core/result_export.py
@@ -0,0 +1,248 @@
+"""Publish one command's current evidence into a plain, self-describing export tree.
+
+This is the console-first successor to the content-addressed result export. It
+copies the evidence the operations actually produced right now -- never a CAS
+directory and never a stale previous success -- into an application-owned
+``destination``:
+
+``manifest.json``
+ The passed/failed/deferred operations plus the relative evidence paths.
+``reports/``
+ The declared report files, nested below one directory per operation and the
+ operation's common evidence directory, so two reports that share a basename
+ (``Debug/tests.xml`` and ``Release/tests.xml``) keep distinct, meaningful paths.
+``logs/``
+ One log per operation that produced command output, success or failure.
+``packages/``
+ The flat, version-and-architecture named release ZIPs and ``packages.json``.
+
+The destination must be a fresh, non-reparse path below an existing directory:
+a second run never overwrites a previous export.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from collections.abc import Iterable, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+import re
+import shutil
+import stat
+import tempfile
+
+
+_COMMAND = re.compile(r"[a-z0-9][a-z0-9._-]*")
+_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
+PASSED = "passed"
+FAILED = "failed"
+DEFERRED = "deferred"
+_STATUSES = (PASSED, FAILED, DEFERRED)
+_REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
+
+
+class ResultExportError(RuntimeError):
+ """Declared evidence could not be published safely and completely."""
+
+
+@dataclass(frozen=True, slots=True)
+class Operation:
+ """One named operation, its verdict, and the evidence it produced."""
+
+ name: str
+ status: str
+ detail: str = ""
+ reports: tuple[Path, ...] = ()
+ log: str = ""
+
+
+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)
+ return stat.S_ISLNK(information.st_mode) or bool(attributes & _REPARSE)
+
+
+def _checked_file(path: Path) -> Path:
+ """Return ``path`` only when it is a real regular file, never behind a link."""
+
+ information = _lstat(path)
+ if information is None:
+ raise ResultExportError(f"claimed evidence is missing: {path}")
+ for candidate in (path, *path.parents):
+ if _is_reparse(candidate):
+ raise ResultExportError(f"reparse points are forbidden in evidence: {candidate}")
+ if not stat.S_ISREG(information.st_mode):
+ raise ResultExportError(f"claimed evidence is not a regular file: {path}")
+ return path
+
+
+def _report_entries(operation: Operation) -> tuple[tuple[Path, str], ...]:
+ """Name each report by its path below the operation's common evidence directory.
+
+ A single report keeps its basename; several reports that share a directory keep
+ only that directory as the ancestor so their subpaths stay unique and meaningful
+ (``Debug/tests.xml`` versus ``Release/tests.xml``).
+ """
+
+ reports = tuple(Path(report) for report in operation.reports)
+ if not reports:
+ return ()
+ common = Path(os.path.commonpath([os.fspath(report.parent) for report in reports]))
+ return tuple(
+ (report, f"{operation.name}/{report.relative_to(common).as_posix()}")
+ for report in reports
+ )
+
+
+def _digest(path: Path) -> tuple[int, str]:
+ with path.open("rb") as stream:
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
+ return path.stat().st_size, digest
+
+
+def _copy(
+ staging: Path, directory: str, sources: Iterable[tuple[Path, str]]
+) -> list[dict[str, object]]:
+ """Copy declared files flat below ``staging/`` and describe them."""
+
+ (staging / directory).mkdir(parents=True, exist_ok=True)
+ records: list[dict[str, object]] = []
+ seen: set[str] = set()
+ for source, name in sources:
+ if name in seen:
+ raise ResultExportError(f"duplicate evidence name: {name}")
+ seen.add(name)
+ _checked_file(source)
+ destination = staging / directory / name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source, destination, follow_symlinks=False)
+ size, digest = _digest(destination)
+ records.append({"path": f"{directory}/{name}", "sha256": digest, "size": size})
+ return records
+
+
+def _validate_command(command: str) -> None:
+ if not isinstance(command, str) or _COMMAND.fullmatch(command) is None:
+ raise ResultExportError("command must be a lowercase logical name")
+
+
+def _validate_operations(operations: Sequence[Operation]) -> None:
+ seen: set[str] = set()
+ for operation in operations:
+ if _NAME.fullmatch(operation.name) is None:
+ raise ResultExportError(f"invalid operation name: {operation.name!r}")
+ if operation.name in seen:
+ raise ResultExportError(f"duplicate operation: {operation.name}")
+ if operation.status not in _STATUSES:
+ raise ResultExportError(f"invalid operation status: {operation.status!r}")
+ seen.add(operation.name)
+
+
+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 _publish_logs(staging: Path, operations: Sequence[Operation]) -> list[dict[str, object]]:
+ (staging / "logs").mkdir(parents=True, exist_ok=True)
+ records: list[dict[str, object]] = []
+ for operation in operations:
+ if not operation.log:
+ continue
+ relative = f"logs/{operation.name}.log"
+ path = staging / relative
+ path.write_text(operation.log, encoding="utf-8", newline="\n")
+ size, digest = _digest(path)
+ records.append({"path": relative, "sha256": digest, "size": size})
+ return records
+
+
+def export_results(
+ destination: Path | str,
+ command: str,
+ operations: Iterable[Operation],
+ *,
+ packages: Iterable[Path] = (),
+) -> Path:
+ """Publish ``operations`` and ``packages`` into a fresh ``destination`` directory."""
+
+ _validate_command(command)
+ ordered = tuple(operations)
+ _validate_operations(ordered)
+ 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)
+ reports = _copy(
+ staging,
+ "reports",
+ (entry for operation in ordered for entry in _report_entries(operation)),
+ )
+ logs = _publish_logs(staging, ordered)
+ package_records = _copy(
+ staging, "packages", ((Path(path), Path(path).name) for path in packages)
+ )
+ _write_manifest(
+ staging,
+ {
+ "schema": 1,
+ "command": command,
+ "status": "failed" if any(
+ operation.status == FAILED for operation in ordered
+ ) else "success",
+ "passed": [
+ operation.name for operation in ordered if operation.status == PASSED
+ ],
+ "failed": [
+ operation.name for operation in ordered if operation.status == FAILED
+ ],
+ "deferred": [
+ {"name": operation.name, "detail": operation.detail}
+ for operation in ordered
+ if operation.status == DEFERRED
+ ],
+ "operations": [
+ {
+ "name": operation.name,
+ "status": operation.status,
+ "detail": operation.detail,
+ }
+ for operation in ordered
+ ],
+ "reports": reports,
+ "logs": logs,
+ "packages": package_records,
+ },
+ )
+ staging.rename(published)
+ except OSError as error:
+ raise ResultExportError(f"could not publish evidence export: {error}") from error
+ return published
diff --git a/build/core/sanitizer.py b/build/core/sanitizer.py
new file mode 100644
index 0000000..9faf92b
--- /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__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/core/sarif.py b/build/core/sarif.py
new file mode 100644
index 0000000..269c4f5
--- /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__": # pragma: no cover
+ raise SystemExit(main())
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/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..8bebde4
--- /dev/null
+++ b/build/core/windows_job.py
@@ -0,0 +1,48 @@
+"""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, *, priority_class: int | None = None) -> None:
+ handle = win32job.CreateJobObject(None, "")
+ self._handle = handle
+ try:
+ information = win32job.QueryInformationJobObject(
+ handle, win32job.JobObjectExtendedLimitInformation
+ )
+ limits = information["BasicLimitInformation"]
+ limits["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
+ if priority_class is not None:
+ # One limit on the job covers every descendant, including compilers the
+ # build tool spawns itself, without changing any command line.
+ limits["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_PRIORITY_CLASS
+ limits["PriorityClass"] = priority_class
+ 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..69d5794
--- /dev/null
+++ b/build/core/windows_process.py
@@ -0,0 +1,115 @@
+"""Small synchronous Windows process runner owned by a kill-on-close Job.
+
+The direct CLI shells out through the repository validators, but every child is
+still launched inside a :class:`core.windows_job.WindowsJob` so that closing the
+job terminates the whole process tree. A cancelled or failed run therefore
+leaves no compilers, MSBuild nodes or vcpkg workers behind, exactly as the
+retired graph engine did, without any async engine, graph or command protocol:
+the runner takes a literal ``argv`` and returns a standard
+:class:`subprocess.CompletedProcess`.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping, Sequence
+import os
+from pathlib import Path
+import subprocess
+from typing import IO
+
+import psutil
+
+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 | None,
+ assigned: bool,
+ errors: list[BaseException],
+) -> None:
+ """Close the job so its tree dies with it, falling back to a hard kill."""
+
+ 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)
+
+
+def _environment(env: Mapping[str, str] | None) -> dict[str, str]:
+ if env is None:
+ return dict(os.environ)
+ return {**os.environ, **env}
+
+
+class WindowsProcessRunner:
+ """Run one literal argv in a kill-on-close Job and capture its output."""
+
+ def __init__(self, *, priority_class: int | None = None) -> None:
+ self._priority_class = priority_class
+
+ def run(
+ self,
+ argv: Sequence[str],
+ cwd: Path | None = None,
+ env: Mapping[str, str] | None = None,
+ *,
+ stdout: IO[str] | None = None,
+ ) -> subprocess.CompletedProcess[str]:
+ command = [os.fspath(item) for item in argv]
+ job = WindowsJob(priority_class=self._priority_class)
+ process: psutil.Popen | None = None
+ assigned = False
+ try:
+ process = psutil.Popen(
+ command,
+ executable=command[0],
+ shell=False,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE if stdout is None else stdout,
+ # Capture mode merges stderr into the captured stream, exactly like the
+ # repository tool runner; a caller-supplied stream keeps stderr inherited so a
+ # parser reading that stream sees only the child's stdout.
+ stderr=subprocess.STDOUT if stdout is None else None,
+ cwd=None if cwd is None else os.fspath(cwd),
+ env=_environment(env),
+ close_fds=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ creationflags=_CREATE_SUSPENDED | _CREATE_UNICODE_ENVIRONMENT,
+ )
+ job.assign_process(int(process._handle))
+ assigned = True
+ process.resume()
+ captured, _ = process.communicate()
+ returncode = process.wait()
+ except BaseException as primary:
+ errors: list[BaseException] = []
+ _stop(job, process, assigned, errors)
+ for error in errors:
+ primary.add_note(f"cleanup failure: {error!r}")
+ raise
+ errors = []
+ _stop(job, process, assigned, errors)
+ if errors:
+ primary, *notes = errors
+ for error in notes:
+ primary.add_note(f"cleanup failure: {error!r}")
+ raise primary
+ return subprocess.CompletedProcess(command, returncode, captured)
diff --git a/build/diagnostics.py b/build/diagnostics.py
new file mode 100644
index 0000000..207b063
--- /dev/null
+++ b/build/diagnostics.py
@@ -0,0 +1,878 @@
+"""Direct coverage, sanitizer, fuzz and leak operations for ObserverModules.
+
+The content-addressed graph rendered one PowerShell command per node. This
+module is the smaller successor for the diagnostic family: it drives MSBuild,
+the Catch2 suites, LLVM and libFuzzer straight from Python over the stable
+``out/native`` layout defined by :mod:`native`, and reuses the validated
+``core.cpp_coverage``, ``core.sanitizer`` and ``core.leak`` module CLIs as
+fail-closed gates. Every external program is reached through the injected
+:data:`Runner` seam, so the command shapes are unit-testable in isolation.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import stat
+import subprocess
+import sys
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+
+import native
+from core.leak import MODES as LEAK_MODES
+from core.leak import SCENARIOS as LEAK_SCENARIOS
+
+
+PROJECTS: tuple[str, ...] = ("renpy", "rpgmaker", "zanzarah")
+LEAK_PROBE = "leak-probe.exe"
+COVERAGE_ARCHITECTURES: tuple[str, ...] = ("x86", "x64")
+FUZZ_ARCHITECTURES: tuple[str, ...] = ("x86", "x64")
+FUZZ_TARGETS: tuple[str, ...] = ("pickle", "renpy", "rpgmaker", "zanzarah")
+FUZZ_MAX_LENGTH: Mapping[str, int] = {
+ "pickle": 262144,
+ "renpy": 1048576,
+ "rpgmaker": 1048576,
+ "zanzarah": 1048576,
+}
+# Timed libFuzzer budgets stay x64-only; x86 exists as a replay-only gate.
+FUZZ_TIMED_ARCHITECTURE = "x64"
+SANITIZER_CONFIGURATIONS: Mapping[str, str] = {"asan": "ASan", "ubsan": "UBSan"}
+SANITIZER_ARCHITECTURES: Mapping[str, frozenset[str]] = {
+ "asan": frozenset({"x86", "x64"}),
+ "ubsan": frozenset({"x64"}),
+}
+_ASAN_OPTIONS = "halt_on_error=1:alloc_dealloc_mismatch=1"
+SANITIZER_ENVIRONMENT: Mapping[str, tuple[str, str]] = {
+ "asan": ("ASAN_OPTIONS", _ASAN_OPTIONS),
+ "ubsan": ("UBSAN_OPTIONS", "halt_on_error=1:print_stacktrace=1"),
+}
+ASAN_RUNTIMES: Mapping[str, str] = {
+ "x86": "clang_rt.asan_dynamic-i386.dll",
+ "x64": "clang_rt.asan_dynamic-x86_64.dll",
+}
+# First-party C++ only: the suites, the fuzz harnesses, vcpkg and the SDKs are external.
+COVERAGE_IGNORED_SOURCES = (
+ r"([\\/]src[\\/](tests|fuzz)[\\/])|([\\/]vcpkg_installed[\\/])|"
+ r"([\\/]Microsoft Visual Studio[\\/])|([\\/]Windows Kits[\\/])"
+)
+# ASan and Fuzz consume the sanitizer triplet from its own vcpkg install root.
+_ASAN_SUFFIX = "-asan"
+_WINDOWS_SEPARATOR = os.sep
+_BUILD_ROOT = Path(__file__).resolve().parent
+_PYTHON = Path(sys.executable)
+# Symlinks and junctions both carry this Windows attribute; POSIX has neither.
+_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
+# ``core.leak measure`` requires these two per-scope directories to be new.
+_MEASURE_DIRECTORIES = ("snapshots", "diffs")
+
+
+class DiagnosticsError(RuntimeError):
+ """A direct diagnostic operation could not complete its contract."""
+
+
+@dataclass(frozen=True, slots=True)
+class ToolResult:
+ returncode: int
+ stdout: str
+
+
+# The single seam every external program is reached through.
+Runner = Callable[[Sequence[str], Path | None, Mapping[str, str] | None], ToolResult]
+# The Release PE/BinSkim auditor: repository, architectures, output, and a keyword.
+Auditor = Callable[..., None]
+
+
+def run_tool(
+ argv: Sequence[str],
+ cwd: Path | None = None,
+ env: Mapping[str, str] | None = None,
+) -> ToolResult:
+ """Run one program, merging the environment, and keep its combined output."""
+
+ completed = subprocess.run(
+ [os.fspath(item) for item in argv],
+ cwd=None if cwd is None else os.fspath(cwd),
+ env={**os.environ, **(env or {})},
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ encoding="utf-8",
+ errors="replace",
+ )
+ return ToolResult(completed.returncode, completed.stdout)
+
+
+@dataclass(frozen=True, slots=True)
+class DiagnosticsTools:
+ """Explicit tool locations; discovery stays with the caller."""
+
+ msbuild: Path
+ vcpkg: Path
+ llvm_install: Path
+ llvm_cov: Path
+ llvm_profdata: Path
+ llvm_runtimes: Mapping[str, Path]
+ asan_runtimes: Mapping[str, Path]
+ umdh: Path
+
+ @property
+ def vcpkg_root(self) -> Path:
+ return self.vcpkg.parent
+
+
+def _job_count(value: int | None) -> int:
+ if value is None:
+ return os.cpu_count() or 1
+ if isinstance(value, bool) or value < 1:
+ raise ValueError("jobs must be a positive integer")
+ return value
+
+
+def _checked(
+ runner: Runner,
+ command: Sequence[str],
+ cwd: Path | None = None,
+ env: Mapping[str, str] | None = None,
+) -> ToolResult:
+ result = runner(command, cwd, env)
+ if result.returncode:
+ raise DiagnosticsError(
+ f"command failed with exit code {result.returncode}: {command[0]}"
+ )
+ return result
+
+
+def _checked_runner(runner: Runner) -> Runner:
+ """Adapt a result-returning runner so a nonzero exit raises.
+
+ :func:`native.restore` drives its runner with ``check=True`` semantics and
+ expects a nonzero vcpkg exit to raise. Our :data:`Runner` returns the code
+ instead, so every ``native.restore`` call is handed this adapter rather than
+ the raw runner, keeping a failed restore fail-closed.
+ """
+
+ def execute(
+ argv: Sequence[str], cwd: Path | None = None, env: Mapping[str, str] | None = None
+ ) -> ToolResult:
+ result = runner(argv, cwd, env)
+ if result.returncode:
+ raise DiagnosticsError(
+ f"command failed with exit code {result.returncode}: {argv[0]}"
+ )
+ return result
+
+ return execute
+
+
+def _require_selection(
+ values: Sequence[str], allowed: Sequence[str], label: str
+) -> None:
+ if not values:
+ raise ValueError("at least one selection is required")
+ seen: set[str] = set()
+ for value in values:
+ if value not in allowed:
+ raise ValueError(f"{label} does not support: {value}")
+ if value in seen:
+ raise ValueError(f"duplicate selection: {value}")
+ seen.add(value)
+
+
+def _require_sanitizer(sanitizer: str) -> None:
+ if sanitizer not in SANITIZER_CONFIGURATIONS:
+ raise ValueError(f"unsupported sanitizer: {sanitizer}")
+
+
+def _require_runnable(architectures: Sequence[str], host: str | None) -> None:
+ native.require_runnable(
+ tuple(architectures), native.host_architecture() if host is None else host
+ )
+
+
+def _validator(
+ runner: Runner, module: str, arguments: Sequence[str], output: Path
+) -> None:
+ """Invoke a core module CLI with the operation-scoped output environment."""
+
+ output.mkdir(parents=True, exist_ok=True)
+ directory = os.fspath(output)
+ _checked(
+ runner,
+ (os.fspath(_PYTHON), "-m", module, *arguments),
+ _BUILD_ROOT,
+ {"OBSERVER_OUT_DIR": directory, "OBSERVER_BUILD_DIR": directory},
+ )
+
+
+def _llvm_properties(
+ command: list[str], tools: DiagnosticsTools, runtime: Path | None
+) -> None:
+ if runtime is not None:
+ command.append(f"/p:LLVMInstallDir={os.fspath(tools.llvm_install)}")
+ command.append(f"/p:LLVMRuntimeDir={os.fspath(runtime)}")
+
+
+def _observer_command(
+ repository: Path,
+ architecture: str,
+ configuration: str,
+ jobs: int,
+ tools: DiagnosticsTools,
+ *,
+ runtime: Path | None = None,
+) -> tuple[str, ...]:
+ command = list(native.build_command(repository, architecture, configuration, jobs, tools))
+ _llvm_properties(command, tools, runtime)
+ return tuple(command)
+
+
+def _project_command(
+ repository: Path,
+ project: Path,
+ architecture: str,
+ configuration: str,
+ jobs: int,
+ tools: DiagnosticsTools,
+ *,
+ runtime: Path | None = None,
+) -> tuple[str, ...]:
+ command = [
+ str(tools.msbuild),
+ os.fspath(project),
+ "/nologo",
+ f"/m:{jobs}",
+ "/nr:false",
+ "/t:Build",
+ f"/p:Configuration={configuration}",
+ f"/p:Platform={native.PLATFORMS[architecture]}",
+ f"/p:ArtifactsRoot={native.artifacts_root(repository)}{_WINDOWS_SEPARATOR}",
+ f"/p:VcpkgRoot={tools.vcpkg_root}",
+ "/p:VcpkgManifestInstall=false",
+ ]
+ _llvm_properties(command, tools, runtime)
+ return tuple(command)
+
+
+def _restore_asan(
+ runner: Runner, repository: Path, architecture: str, tools: DiagnosticsTools
+) -> None:
+ root = native.artifacts_root(repository)
+ scratch = root / "vcpkg_scratch" / f"{architecture}{_ASAN_SUFFIX}"
+ command = (
+ str(tools.vcpkg),
+ "install",
+ f"--x-install-root={root / 'vcpkg_installed' / f'{architecture}{_ASAN_SUFFIX}'}",
+ f"--x-buildtrees-root={scratch / 'buildtrees'}",
+ f"--x-packages-root={scratch / 'packages'}",
+ "--triplet",
+ f"{native.triplet(architecture)}{_ASAN_SUFFIX}",
+ f"--x-manifest-root={repository}",
+ f"--overlay-triplets={repository / 'build' / 'vcpkg' / 'triplets'}",
+ )
+ _checked(runner, command, repository, None)
+
+
+def _clean_profiles(directory: Path) -> None:
+ directory.mkdir(parents=True, exist_ok=True)
+ for stale in directory.glob("*.profraw"):
+ stale.unlink()
+
+
+def _run_suite(
+ runner: Runner,
+ repository: Path,
+ architecture: str,
+ configuration: str,
+ report: Path,
+ environment: Mapping[str, str],
+ corpus: Path | None = None,
+) -> ToolResult:
+ report.parent.mkdir(parents=True, exist_ok=True)
+ return _checked(
+ runner,
+ native.test_command(repository, architecture, configuration, report, corpus),
+ native.bin_directory(repository, architecture, configuration),
+ environment,
+ )
+
+
+def _coverage_export(
+ repository: Path,
+ architecture: str,
+ profile: Path,
+ tools: DiagnosticsTools,
+ *,
+ lcov: bool = False,
+) -> tuple[str, ...]:
+ command = [
+ str(tools.llvm_cov),
+ "export",
+ os.fspath(native.test_executable(repository, architecture, "Coverage")),
+ "--instr-profile",
+ os.fspath(profile),
+ "--ignore-filename-regex",
+ COVERAGE_IGNORED_SOURCES,
+ ]
+ binary = native.bin_directory(repository, architecture, "Coverage")
+ for name in PROJECTS:
+ command += ["--object", os.fspath(binary / native.BINARIES[name])]
+ # The trimmed summary alone drives the 100% gate; the LCOV form keeps every
+ # file, line and branch so downstream reporting gets the full detail.
+ command.append("--format=lcov" if lcov else "--summary-only")
+ return tuple(command)
+
+
+def _install_runtime(tools: DiagnosticsTools, architecture: str, directory: Path) -> None:
+ directory.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(
+ tools.asan_runtimes[architecture], directory / ASAN_RUNTIMES[architecture]
+ )
+
+
+def _seed_corpus(repository: Path, target: str, directory: Path) -> tuple[Path, ...]:
+ """Materialise the checked-in seeds, decoding ``.hex`` fixtures to bytes."""
+
+ source = repository / "src" / "fuzz" / "corpus" / target
+ entries = (
+ sorted(path for path in source.iterdir() if path.is_file())
+ if source.is_dir()
+ else []
+ )
+ if not entries:
+ raise FileNotFoundError(f"no checked-in fuzzer seeds for {target}")
+ # The hermetic seed directory is rebuilt from scratch every run so nothing a
+ # previous run (or a caller-supplied prior corpus) left behind can linger.
+ shutil.rmtree(directory, ignore_errors=True)
+ directory.mkdir(parents=True, exist_ok=True)
+ seeded = []
+ for seed in entries:
+ if seed.suffix == ".hex":
+ destination = directory / seed.stem
+ destination.write_bytes(
+ bytes.fromhex("".join(seed.read_text(encoding="ascii").split()))
+ )
+ else:
+ destination = directory / seed.name
+ shutil.copyfile(seed, destination)
+ seeded.append(destination)
+ return tuple(seeded)
+
+
+def _merge_corpus(source: Path, directory: Path) -> tuple[Path, ...]:
+ """Copy every file from ``source`` into ``directory``, overwriting by name."""
+
+ directory.mkdir(parents=True, exist_ok=True)
+ merged = []
+ for item in sorted(source.iterdir()):
+ destination = directory / item.name
+ shutil.copyfile(item, destination)
+ merged.append(destination)
+ return tuple(merged)
+
+
+def _copy_corpus(source: Path, directory: Path) -> tuple[Path, ...]:
+ """Rebuild ``directory`` as an exact copy of the files in ``source``."""
+
+ shutil.rmtree(directory, ignore_errors=True)
+ return _merge_corpus(source, directory)
+
+
+def _prior_source(prior: Path, target: str) -> Path | None:
+ """Resolve one target's accumulated corpus, either published or bare."""
+
+ candidate = prior / target
+ published = candidate / "corpus"
+ if published.is_dir():
+ return published
+ if candidate.is_dir():
+ return candidate
+ return None
+
+
+def _publish_corpus(source: Path, directory: Path) -> None:
+ """Copy a run's accumulated corpus into the operation output, never deleting it."""
+
+ directory.mkdir(parents=True, exist_ok=True)
+ for item in sorted(source.iterdir()):
+ shutil.copyfile(item, directory / item.name)
+
+
+def _is_reparse_point(path: Path) -> bool:
+ try:
+ status = path.lstat()
+ except OSError:
+ return False
+ return bool(getattr(status, "st_file_attributes", 0) & _REPARSE_POINT)
+
+
+def _validate_owned(directory: Path, root: Path) -> None:
+ """Refuse a caller-owned path that escapes ``root`` or hides behind a link.
+
+ The containment check resolves the whole path, so a nested junction that
+ redirects outside the operation output is caught even when the leaf does not
+ exist yet; the ancestor walk additionally rejects any reparse point component
+ before anything is created or removed.
+ """
+
+ resolved_root = Path(root).resolve()
+ if not Path(directory).resolve().is_relative_to(resolved_root):
+ raise DiagnosticsError(
+ f"refusing to use outside the operation output: {directory}"
+ )
+ for item in (Path(directory), *Path(directory).parents):
+ if _is_reparse_point(item):
+ raise DiagnosticsError(f"refusing to use a reparse point: {item}")
+
+
+def _remove_owned(directory: Path, root: Path) -> None:
+ """Delete one caller-owned directory, refusing links and escapes out of ``root``."""
+
+ _validate_owned(directory, root)
+ if not directory.exists():
+ return
+ shutil.rmtree(directory)
+
+
+def _fresh_measure(directory: Path, root: Path) -> None:
+ """Recreate the per-scope measure output that ``core.leak`` expects to be new."""
+
+ _validate_owned(directory, root)
+ directory.mkdir(parents=True, exist_ok=True)
+ for name in _MEASURE_DIRECTORIES:
+ _remove_owned(directory / name, root)
+
+
+def _fuzz_command(
+ executable: Path, arguments: Sequence[str], artifacts: Path
+) -> tuple[str, ...]:
+ """Every libFuzzer run shares the bounded memory, timeout and artifact flags."""
+
+ return (
+ os.fspath(executable),
+ *arguments,
+ "-rss_limit_mb=1024",
+ "-timeout=10",
+ "-print_final_stats=1",
+ f"-artifact_prefix={os.fspath(artifacts)}{_WINDOWS_SEPARATOR}",
+ )
+
+
+def _replay(
+ runner: Runner,
+ executable: Path,
+ inputs: Sequence[Path],
+ max_length: int,
+ artifacts: Path,
+ log: Path,
+) -> int:
+ """Replay exact inputs, always writing the log, and return the exit status."""
+
+ result = runner(
+ _fuzz_command(
+ executable,
+ [*(os.fspath(item) for item in inputs), f"-max_len={max_length}"],
+ artifacts,
+ ),
+ None,
+ {"ASAN_OPTIONS": _ASAN_OPTIONS},
+ )
+ log.write_text(result.stdout, encoding="utf-8")
+ return result.returncode
+
+
+def _timed(
+ runner: Runner,
+ executable: Path,
+ corpus: Path,
+ seconds: int,
+ max_length: int,
+ artifacts: Path,
+ log: Path,
+) -> int:
+ """Run the bounded libFuzzer budget and return its exit status."""
+
+ result = runner(
+ _fuzz_command(
+ executable,
+ [os.fspath(corpus), f"-max_total_time={seconds}", f"-max_len={max_length}",
+ "-use_value_profile=1"],
+ artifacts,
+ ),
+ None,
+ {"ASAN_OPTIONS": _ASAN_OPTIONS},
+ )
+ log.write_text(result.stdout, encoding="utf-8")
+ return result.returncode
+
+
+def _default_auditor(
+ repository: Path, architectures: Sequence[str], output: Path, *, include_leak_probe: bool
+) -> None:
+ """Delegate the Release PE/BinSkim audit to the repository packaging operations.
+
+ ``packaging_ops`` is imported lazily so a staging workspace can inject its
+ own auditor without the repository module being importable. The exact
+ contract is ``audit_binaries(repository, architectures, output,
+ include_leak_probe=True)``; it raises on any finding.
+ """
+
+ import packaging_ops
+
+ # ``packaging_ops.audit_binaries`` requires an existing output root and runs its
+ # own containment and reparse-point validation below that root; materialise the
+ # operation's audit directory first so the default path never fails on a missing one.
+ # Because packaging only validates the root and below, reject the audit directory and
+ # every ancestor (including a linked parent) here, before anything is created.
+ output = Path(output)
+ _validate_owned(output, output.parent)
+ output.mkdir(parents=True, exist_ok=True)
+ packaging_ops.audit_binaries(
+ repository, tuple(architectures), output, include_leak_probe=include_leak_probe
+ )
+
+
+def test_coverage(
+ repository: Path,
+ architectures: Sequence[str],
+ output: Path,
+ *,
+ tools: DiagnosticsTools,
+ runner: Runner = run_tool,
+ jobs: int | None = None,
+ host: str | None = None,
+ corpus: Path | None = None,
+) -> tuple[Path, ...]:
+ """Build Coverage, run the ordinary suite and gate 100% LLVM coverage.
+
+ The ordinary suite always runs. When ``corpus`` names an existing
+ directory the compatibility suite runs as a separate pass with
+ ``OBSERVER_TEST_CORPUS`` scoped to it, and both suites' raw profiles are
+ merged before the single immutable 100% line/branch gate. Each architecture
+ yields the summary JSON that gate consumes and the full LCOV detail that is
+ written next to it under ``coverage/``.
+ """
+
+ repository = Path(repository)
+ output = Path(output)
+ architectures = tuple(architectures)
+ _require_selection(architectures, COVERAGE_ARCHITECTURES, "coverage")
+ _require_runnable(architectures, host)
+ corpus_path: Path | None = None
+ if corpus is not None:
+ corpus_path = Path(corpus).resolve(strict=True)
+ if not corpus_path.is_dir():
+ raise NotADirectoryError(corpus_path)
+ count = _job_count(jobs)
+ native.restore(repository, architectures, tools=tools, runner=_checked_runner(runner))
+ reports = []
+ for architecture in architectures:
+ _checked(
+ runner,
+ _observer_command(
+ repository, architecture, "Coverage", count, tools,
+ runtime=tools.llvm_runtimes[architecture],
+ ),
+ repository,
+ None,
+ )
+ directory = output / "coverage" / architecture
+ directory.mkdir(parents=True, exist_ok=True)
+ profiles = native.artifacts_root(repository) / "coverage" / architecture
+ suites: list[tuple[str, Path | None, Mapping[str, str]]] = [("unit", None, {})]
+ if corpus_path is not None:
+ suites.append(
+ ("corpus", corpus_path, {"OBSERVER_TEST_CORPUS": os.fspath(corpus_path)})
+ )
+ raw: list[Path] = []
+ for scope, suite_corpus, extra in suites:
+ # Each scope gets its own JUnit report and its own profraw directory
+ # so the compatibility pass can never contaminate the ordinary one.
+ scope_profiles = profiles / scope
+ _clean_profiles(scope_profiles)
+ environment = {
+ "LLVM_PROFILE_FILE": os.fspath(
+ scope_profiles / "coverage-%m-%p.profraw"
+ ),
+ **extra,
+ }
+ _run_suite(
+ runner, repository, architecture, "Coverage",
+ directory / scope / "tests.xml", environment, suite_corpus,
+ )
+ produced = sorted(scope_profiles.glob("*.profraw"))
+ if not produced:
+ raise DiagnosticsError(
+ f"coverage {scope} suite produced no LLVM raw profiles"
+ )
+ raw.extend(produced)
+ profile = directory / "coverage.profdata"
+ _checked(
+ runner,
+ (str(tools.llvm_profdata), "merge", "-sparse",
+ *(os.fspath(item) for item in raw), "-o", os.fspath(profile)),
+ None, None,
+ )
+ report = directory / "coverage.json"
+ report.write_text(
+ _checked(
+ runner, _coverage_export(repository, architecture, profile, tools), None, None
+ ).stdout,
+ encoding="utf-8",
+ )
+ lcov = directory / "coverage.lcov"
+ lcov.write_text(
+ _checked(
+ runner,
+ _coverage_export(repository, architecture, profile, tools, lcov=True),
+ None,
+ None,
+ ).stdout,
+ encoding="utf-8",
+ )
+ _validator(runner, "core.cpp_coverage", ("gate", os.fspath(report)), directory)
+ reports.append(report)
+ reports.append(lcov)
+ return tuple(reports)
+
+
+def test_sanitizer(
+ repository: Path,
+ sanitizer: str,
+ architectures: Sequence[str],
+ output: Path,
+ *,
+ tools: DiagnosticsTools,
+ runner: Runner = run_tool,
+ jobs: int | None = None,
+ host: str | None = None,
+) -> tuple[Path, ...]:
+ """Build ASan/UBSan, run the suite and gate the suite log for findings."""
+
+ repository = Path(repository)
+ output = Path(output)
+ architectures = tuple(architectures)
+ _require_sanitizer(sanitizer)
+ _require_selection(architectures, tuple(SANITIZER_ARCHITECTURES[sanitizer]), sanitizer)
+ _require_runnable(architectures, host)
+ count = _job_count(jobs)
+ configuration = SANITIZER_CONFIGURATIONS[sanitizer]
+ name, value = SANITIZER_ENVIRONMENT[sanitizer]
+ logs = []
+ for architecture in architectures:
+ if sanitizer == "asan":
+ _restore_asan(runner, repository, architecture, tools)
+ runtime = None
+ else:
+ native.restore(
+ repository, (architecture,), tools=tools, runner=_checked_runner(runner)
+ )
+ runtime = tools.llvm_runtimes[architecture]
+ _checked(
+ runner,
+ _observer_command(repository, architecture, configuration, count, tools,
+ runtime=runtime),
+ repository,
+ None,
+ )
+ directory = output / "sanitizer" / sanitizer / architecture
+ directory.mkdir(parents=True, exist_ok=True)
+ if sanitizer == "asan":
+ _install_runtime(
+ tools, architecture,
+ native.bin_directory(repository, architecture, configuration),
+ )
+ result = _run_suite(
+ runner, repository, architecture, configuration, directory / "tests.xml",
+ {name: value},
+ )
+ if not (directory / "tests.xml").is_file():
+ raise DiagnosticsError("sanitizer suite did not produce tests.xml")
+ log = directory / "tests.log"
+ log.write_text(result.stdout, encoding="utf-8")
+ _validator(runner, "core.sanitizer", ("gate", sanitizer, os.fspath(log)), directory)
+ logs.append(log)
+ return tuple(logs)
+
+
+def fuzz(
+ repository: Path,
+ architectures: Sequence[str],
+ targets: Sequence[str],
+ seconds: int,
+ output: Path,
+ *,
+ tools: DiagnosticsTools,
+ runner: Runner = run_tool,
+ jobs: int | None = None,
+ host: str | None = None,
+ prior_corpus: Path | None = None,
+) -> tuple[Path, ...]:
+ """Build every target; x64 runs the timed budget, x86 the replay-only gate.
+
+ Seeds live in their own directory and are replayed on their own, so a prior
+ corpus sharing a seed name can never weaken the mandatory hermetic x64
+ replay. A successful x64 run publishes its accumulated corpus and exit
+ status into the returned output tree, where the next invocation can reuse
+ it; a failing run leaves its log, status and crash artifacts in place.
+ """
+
+ repository = Path(repository)
+ output = Path(output)
+ architectures = tuple(architectures)
+ targets = tuple(targets)
+ _require_selection(architectures, FUZZ_ARCHITECTURES, "fuzz")
+ _require_selection(targets, FUZZ_TARGETS, "fuzz")
+ if isinstance(seconds, bool) or not isinstance(seconds, int) or seconds <= 0:
+ raise ValueError("fuzz seconds must be a positive integer")
+ _require_runnable(architectures, host)
+ count = _job_count(jobs)
+ prior = Path(prior_corpus) if prior_corpus is not None else None
+ if prior is not None and not prior.is_dir():
+ raise NotADirectoryError(prior)
+ outputs = []
+ for architecture in architectures:
+ _restore_asan(runner, repository, architecture, tools)
+ timed = architecture == FUZZ_TIMED_ARCHITECTURE
+ for target in targets:
+ _checked(
+ runner,
+ _project_command(
+ repository, repository / "build" / "projects" / f"fuzz-{target}.vcxproj",
+ architecture, "Fuzz", count, tools,
+ runtime=tools.llvm_runtimes[architecture],
+ ),
+ repository,
+ None,
+ )
+ executable = (
+ native.bin_directory(repository, architecture, "Fuzz") / f"fuzz-{target}.exe"
+ )
+ directory = output / "fuzz" / architecture / target
+ directory.mkdir(parents=True, exist_ok=True)
+ artifacts = directory / "artifacts"
+ artifacts.mkdir(parents=True, exist_ok=True)
+ work = native.artifacts_root(repository) / "fuzz" / architecture / target
+ length = FUZZ_MAX_LENGTH[target]
+ seeds = _seed_corpus(repository, target, work / "seeds")
+ prior_files: tuple[Path, ...] = ()
+ if prior is not None and (source := _prior_source(prior, target)) is not None:
+ prior_files = _copy_corpus(source, work / "prior")
+ status = directory / "status.txt"
+ replay_log = directory / "replay.log"
+ run_log = directory / "run.log"
+ # A new run owns the phase evidence: drop the previous run's status and
+ # logs so a stale success can never outlive a later failure. The
+ # accumulated corpus is deliberately preserved for reuse.
+ for stale in (status, replay_log, run_log):
+ stale.unlink(missing_ok=True)
+ inputs = seeds if timed else (*seeds, *prior_files)
+ replay = _replay(runner, executable, inputs, length, artifacts, replay_log)
+ if replay:
+ status.write_text(f"{replay}\n", encoding="utf-8")
+ raise DiagnosticsError(f"libFuzzer replay failed with exit code {replay}")
+ if timed:
+ run = work / "run"
+ _copy_corpus(work / "seeds", run)
+ if prior_files:
+ _merge_corpus(work / "prior", run)
+ code = _timed(
+ runner, executable, run, seconds, length, artifacts, run_log
+ )
+ status.write_text(f"{code}\n", encoding="utf-8")
+ if code:
+ raise DiagnosticsError(f"libFuzzer failed with exit code {code}")
+ _publish_corpus(run, directory / "corpus")
+ else:
+ _validator(
+ runner, "core.sanitizer", ("gate", "asan", os.fspath(replay_log)), directory
+ )
+ outputs.append(directory)
+ return tuple(outputs)
+
+
+def _require_leak_measurement(
+ warmup: int, iterations: int, windows: int, tolerance_bytes: int
+) -> None:
+ for name, value in (("warmup", warmup), ("iterations", iterations), ("windows", windows)):
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
+ raise ValueError(f"leak {name} must be a positive integer")
+ 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")
+
+
+def test_leaks(
+ repository: Path,
+ output: Path,
+ warmup: int = 8,
+ iterations: int = 100,
+ windows: int = 3,
+ tolerance_bytes: int = 0,
+ *,
+ tools: DiagnosticsTools,
+ runner: Runner = run_tool,
+ jobs: int | None = None,
+ host: str | None = None,
+ auditor: Auditor | None = None,
+) -> tuple[Path, ...]:
+ """Build the x64 Release probe and gate UMDH growth through ``core.leak``.
+
+ The built Release binaries, including the leak probe, are audited for PE
+ and BinSkim policy before any leak scenario runs. ``auditor`` defaults to
+ the repository ``packaging_ops.audit_binaries`` and is called with
+ ``(repository, ("x64",), output/"audit", include_leak_probe=True)``; it must
+ raise on a finding, and passing ``None`` never skips the audit.
+ """
+
+ repository = Path(repository)
+ output = Path(output)
+ _require_runnable(("x64",), host)
+ _require_leak_measurement(warmup, iterations, windows, tolerance_bytes)
+ count = _job_count(jobs)
+ native.restore(repository, ("x64",), tools=tools, runner=_checked_runner(runner))
+ _checked(
+ runner, _observer_command(repository, "x64", "Release", count, tools), repository, None
+ )
+ _checked(
+ runner,
+ _project_command(
+ repository, repository / "build" / "projects" / "leak-probe.vcxproj",
+ "x64", "Release", count, tools,
+ ),
+ repository,
+ None,
+ )
+ audit = _default_auditor if auditor is None else auditor
+ audit(repository, ("x64",), output / "audit", include_leak_probe=True)
+ binary = native.bin_directory(repository, "x64", "Release")
+ prepared = output / "leak" / "setup"
+ _validator(
+ runner,
+ "core.leak",
+ ("setup", os.fspath(binary / LEAK_PROBE),
+ *(os.fspath(binary / native.BINARIES[name]) for name in PROJECTS)),
+ prepared,
+ )
+ summaries = []
+ for mode in LEAK_MODES:
+ for scenario in LEAK_SCENARIOS:
+ scope = output / "leak" / mode / scenario
+ _validator(
+ runner, "core.leak",
+ ("preflight", os.fspath(prepared), mode, scenario), scope / "preflight",
+ )
+ measure = scope / "measure"
+ _fresh_measure(measure, output)
+ _validator(
+ runner, "core.leak",
+ ("measure", os.fspath(prepared), os.fspath(tools.umdh), mode, scenario,
+ str(warmup), str(iterations), str(windows), str(tolerance_bytes)),
+ measure,
+ )
+ summary = measure / "summary.json"
+ _validator(runner, "core.leak", ("gate", os.fspath(summary)), scope / "gate")
+ summaries.append(summary)
+ return tuple(summaries)
diff --git a/build/main.py b/build/main.py
new file mode 100644
index 0000000..8e48e37
--- /dev/null
+++ b/build/main.py
@@ -0,0 +1,882 @@
+"""Console entry point for the direct MSBuild build and verification route.
+
+This replaces the content-addressed graph driver with plain, coarse calls: the
+native route (:mod:`native`) lets MSBuild own project dependencies, scheduling
+and incremental state under a stable ``out/native`` tree, and every other
+family is a direct function that shells out to the retained ``core`` validators
+(:mod:`diagnostics`, :mod:`source_checks`, :mod:`packaging_ops`). The argument
+and command surface is preserved. Verification runs explicit, ordered gate
+calls with a small keep-going evidence collector: independent gates always run,
+only a dependent package is blocked by an earlier failure, and the current
+reports are exported before the run exits nonzero.
+"""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Callable, Sequence
+from contextlib import redirect_stdout
+from dataclasses import dataclass
+from io import StringIO
+import os
+from pathlib import Path
+import subprocess
+import sys
+import traceback
+from typing import TextIO
+
+import win32api
+import win32process
+
+import diagnostics
+import native
+import packaging_ops
+import source_checks
+from core.clean import CleanError, _is_reparse, clean as clean_output, prepare_fresh
+from core.doctor import main as doctor_main
+from core.host import detect_host_architecture, verify_route
+from core.package import MODULES
+from core.quality_tools import (
+ resolve_asan_runtimes,
+ resolve_fuzzer_runtime,
+ resolve_llvm,
+ resolve_profile_runtime,
+ resolve_ubsan_runtime,
+ resolve_umdh,
+)
+from core.toolchain import discover_msvc_toolchain
+from core.windows_process import WindowsProcessRunner
+from core import result_export
+
+
+_ARCHITECTURES = native.ARCHITECTURES
+_CONFIGURATIONS = native.CONFIGURATIONS
+_FUZZ_TARGETS = diagnostics.FUZZ_TARGETS
+_FUZZ_ARCHITECTURES = ("x86", "x64")
+
+# A job-wide priority class keeps an interactive desktop responsive while the build saturates
+# the machine. It is applied to this process and to the kill-on-close job every child lives in,
+# so the whole tree inherits it; a hosted runner has no desktop to protect and asks for the
+# normal class explicitly.
+_PRIORITIES: dict[str, int | None] = {
+ "below-normal": win32process.BELOW_NORMAL_PRIORITY_CLASS,
+ "normal": None,
+}
+
+_OPERATION_ERRORS: tuple[type[Exception], ...] = (
+ OSError,
+ ValueError,
+ CleanError,
+ packaging_ops.PackagingError,
+ diagnostics.DiagnosticsError,
+ result_export.ResultExportError,
+)
+
+
+class VerificationFailed(RuntimeError):
+ """One or more verification operations failed; the evidence was exported first."""
+
+ def __init__(self, outputs: tuple[Path, ...], failures: tuple[str, ...]) -> None:
+ super().__init__("verification failed: " + ", ".join(failures))
+ self.outputs = outputs
+ self.failures = failures
+
+
+@dataclass(frozen=True, slots=True)
+class _Processes:
+ """One job-owned runner adapted to each repository runner contract."""
+
+ native: Callable[..., None]
+ tools: Callable[..., diagnostics.ToolResult]
+ packaging: Callable[..., None]
+
+
+class _Tee:
+ """Write-through stream so an operation both shows its output and records it."""
+
+ def __init__(self, *streams: TextIO) -> None:
+ self._streams = streams
+
+ def write(self, text: str) -> int:
+ for stream in self._streams:
+ stream.write(text)
+ return len(text)
+
+ def flush(self) -> None:
+ for stream in self._streams:
+ stream.flush()
+
+
+def _selection(choices: tuple[str, ...]) -> Callable[[str], tuple[str, ...]]:
+ lookup = {item.casefold(): item for item in choices}
+
+ def parse(value: str) -> tuple[str, ...]:
+ parts = tuple(item.strip() for item in value.split(","))
+ if not all(parts):
+ raise argparse.ArgumentTypeError(
+ f"expected all or comma-separated: {','.join(choices)}"
+ )
+ if any(item.casefold() == "all" for item in parts):
+ if len(parts) != 1:
+ raise argparse.ArgumentTypeError("'all' must be selected on its own")
+ return choices
+ try:
+ return tuple(dict.fromkeys(lookup[item.casefold()] for item in parts))
+ except KeyError as error:
+ raise argparse.ArgumentTypeError(
+ f"expected all or comma-separated: {','.join(choices)}"
+ ) from error
+
+ 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",),
+ }),
+ "fuzz_seconds": (("-FuzzSeconds", "--fuzz-seconds"), {"type": _integer(1, 86400), "default": 60}),
+ "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}),
+ "module": (("-Module", "--module"), {"type": _selection(MODULES), "default": MODULES}),
+ "clean_mode": (("-CleanMode", "--clean-mode"), {"choices": ("all", "reports"), "default": "all"}),
+}
+
+_COMMAND_OPTIONS: dict[str, tuple[str, ...]] = {
+ "restore": ("arch", "restore"),
+ "build": ("arch", "config"),
+ "test": ("arch", "config", "corpus"),
+ "source-checks": ("arch",),
+ "compiler-analysis": ("arch",),
+ "test-coverage": ("arch", "corpus"),
+ "test-asan": ("arch",),
+ "test-ubsan": ("arch",),
+ "test-leaks": ("arch", "leak_warmup", "leak_iterations", "leak_windows", "leak_tolerance"),
+ "fuzz": ("arch", "fuzz_seconds", "fuzz_target"),
+ "audit-binaries": ("arch", "module"),
+ "package": ("arch", "module", "export_dir"),
+ "verify-source": ("export_dir",),
+ "verify-arch": (
+ "arch", "module", "corpus", "fuzz_seconds",
+ "leak_warmup", "leak_iterations", "leak_windows", "leak_tolerance", "export_dir",
+ ),
+ "verify": (
+ "arch", "module", "corpus", "fuzz_seconds",
+ "leak_warmup", "leak_iterations", "leak_windows", "leak_tolerance", "export_dir",
+ ),
+}
+
+_OPTIONS["fuzz_target"] = (
+ ("-FuzzTarget", "--fuzz-target"),
+ {"type": _selection(_FUZZ_TARGETS), "default": _FUZZ_TARGETS},
+)
+
+
+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 in _COMMAND_OPTIONS.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))
+ command.add_argument("-Priority", "--priority", choices=tuple(_PRIORITIES), default="below-normal")
+ 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 _concise(error: BaseException) -> str:
+ return " ".join(str(error).split()) or type(error).__name__
+
+
+def _jobs(value: int | None, default: int) -> int:
+ return default if value is None else value
+
+
+def _prioritize(priority_class: int | None) -> tuple[int, int] | None:
+ if priority_class is None:
+ return None
+ handle = win32api.GetCurrentProcess()
+ previous = win32process.GetPriorityClass(handle)
+ win32process.SetPriorityClass(handle, priority_class)
+ return handle, previous
+
+
+def _restore_priority(state: tuple[int, int] | None) -> None:
+ if state is not None:
+ handle, previous = state
+ win32process.SetPriorityClass(handle, previous)
+
+
+def _processes(priority_class: int | None) -> _Processes:
+ """Adapt one job-owned runner to the native, diagnostics and packaging contracts."""
+
+ runner = WindowsProcessRunner(priority_class=priority_class)
+
+ def echo(completed: subprocess.CompletedProcess[str]) -> None:
+ """Show a child's combined output, so direct commands are never silent."""
+
+ if completed.stdout:
+ print(completed.stdout, end="")
+
+ def native_runner(argv, cwd=None, env=None) -> None:
+ completed = runner.run(argv, cwd, env)
+ echo(completed)
+ if completed.returncode:
+ raise subprocess.CalledProcessError(
+ completed.returncode, [str(item) for item in argv], completed.stdout
+ )
+
+ def tool_runner(argv, cwd=None, env=None) -> diagnostics.ToolResult:
+ completed = runner.run(argv, cwd, env)
+ echo(completed)
+ return diagnostics.ToolResult(completed.returncode, completed.stdout or "")
+
+ def packaging_runner(argv, *, cwd=None, env=None, stdout=None) -> None:
+ completed = runner.run(argv, cwd, env, stdout=stdout)
+ if stdout is None:
+ echo(completed)
+ if completed.returncode:
+ raise subprocess.CalledProcessError(
+ completed.returncode, [str(item) for item in argv], completed.stdout
+ )
+
+ return _Processes(native_runner, tool_runner, packaging_runner)
+
+
+def _reports_root(repository: Path) -> Path:
+ return Path(repository).resolve(strict=True) / "out" / "reports"
+
+
+def _output_dir(repository: Path, name: str) -> Path:
+ output = _reports_root(repository) / name
+ output.mkdir(parents=True, exist_ok=True)
+ return output
+
+
+def _reset_output(repository: Path, name: str) -> Path:
+ root = Path(repository).resolve(strict=True)
+ return prepare_fresh(root / "out" / "reports" / name, root)
+
+
+def _diagnostics_tools(
+ architectures: Sequence[str], *, profile: bool = False, asan: bool = False, ubsan: bool = False,
+ fuzzer: bool = False, umdh: bool = False,
+) -> diagnostics.DiagnosticsTools:
+ """Resolve only the tools and runtimes the requested architectures actually consume."""
+
+ native_tools = native.locate_tools()
+ toolchain = discover_msvc_toolchain()
+ asan_runtimes: dict[str, Path] = {}
+ if asan:
+ asan_runtimes = {
+ architecture: tool.path
+ for architecture, tool in resolve_asan_runtimes(toolchain, tuple(architectures))
+ }
+ llvm_runtimes: dict[str, Path] = {}
+ if profile:
+ llvm_runtimes = {
+ architecture: resolve_profile_runtime(toolchain, architecture).path
+ for architecture in architectures
+ }
+ elif ubsan:
+ # UBSan ships one x64 archive pair; only an x64 request consumes it.
+ llvm_runtimes = (
+ {"x64": resolve_ubsan_runtime(toolchain).path} if "x64" in architectures else {}
+ )
+ elif fuzzer:
+ llvm_runtimes = {
+ architecture: resolve_fuzzer_runtime(toolchain, architecture).path
+ for architecture in architectures
+ }
+ return diagnostics.DiagnosticsTools(
+ msbuild=native_tools.msbuild,
+ vcpkg=native_tools.vcpkg,
+ llvm_install=toolchain.llvm_dir,
+ llvm_cov=resolve_llvm(toolchain, "llvm-cov").path if profile else Path(),
+ llvm_profdata=resolve_llvm(toolchain, "llvm-profdata").path if profile else Path(),
+ llvm_runtimes=llvm_runtimes,
+ asan_runtimes=asan_runtimes,
+ umdh=resolve_umdh().path if umdh else Path(),
+ )
+
+
+def _files_under(directory: Path) -> tuple[Path, ...]:
+ """Every real file below ``directory``, never descending through a reparse point or link."""
+
+ files: list[Path] = []
+ pending = [Path(directory)]
+ while pending:
+ current = pending.pop()
+ if _is_reparse(current):
+ continue
+ try:
+ entries = list(os.scandir(current))
+ except OSError:
+ continue
+ for entry in entries:
+ child = Path(entry.path)
+ if _is_reparse(child):
+ continue
+ if entry.is_dir(follow_symlinks=False):
+ pending.append(child)
+ else:
+ files.append(child)
+ return tuple(sorted(files, key=str))
+
+
+def _evidence_reports(paths: Sequence[Path]) -> tuple[Path, ...]:
+ """Expand any declared directory report into the real files it currently holds.
+
+ A diagnostic operation such as :func:`diagnostics.fuzz` returns its per-target evidence
+ directory, which the exporter can only publish file by file. A plain file path is left
+ untouched, so the exporter still validates it (and still rejects a missing claim).
+ """
+
+ reports: list[Path] = []
+ for path in paths:
+ candidate = Path(path)
+ if candidate.is_dir():
+ reports.extend(_files_under(candidate))
+ else:
+ reports.append(candidate)
+ return tuple(reports)
+
+
+def _owned_reports(
+ declared: Sequence[Path], evidence: Path | None, exclude: Path | None,
+) -> tuple[Path, ...]:
+ """Every current evidence file of one operation, deduplicated in declaration order.
+
+ ``declared`` is what the operation returned; ``evidence`` is its operation-specific output
+ root. A gate may write artifacts it does not name -- a nested JUnit tree, a SARIF, sanitizer
+ logs, a coverage data file -- so the declared reports are combined with a scan of the current
+ evidence root and the two are deduplicated. ``exclude`` drops one owned sub-tree (the release
+ ``packages`` directory) from both the success and failure paths, so a release payload is never
+ mistaken for a report.
+ """
+
+ candidates = list(declared)
+ if evidence is not None:
+ candidates.extend(_files_under(evidence))
+ unique: list[Path] = []
+ seen: set[str] = set()
+ for path in candidates:
+ if exclude is not None and exclude in path.parents:
+ continue
+ key = str(path)
+ if key in seen:
+ continue
+ seen.add(key)
+ unique.append(path)
+ return tuple(unique)
+
+
+def _export(destination: Path, command: str, operations: Sequence[result_export.Operation],
+ *, packages: Sequence[Path] = ()) -> Path:
+ return result_export.export_results(Path(destination), command, operations, packages=packages)
+
+
+def _attempt(
+ records: list[result_export.Operation],
+ name: str,
+ action: Callable[[], Sequence[Path]],
+ *,
+ evidence: Path | None = None,
+ exclude: Path | None = None,
+) -> bool:
+ """Run one gate, record its verdict and captured output, and keep going on any failure."""
+
+ captured = StringIO()
+ try:
+ with redirect_stdout(_Tee(sys.stdout, captured)):
+ declared = _evidence_reports(tuple(action()))
+ except Exception as error:
+ records.append(result_export.Operation(
+ name, result_export.FAILED, detail=_concise(error),
+ reports=_owned_reports((), evidence, exclude),
+ log=captured.getvalue() + traceback.format_exc(),
+ ))
+ return False
+ records.append(result_export.Operation(
+ name, result_export.PASSED, reports=_owned_reports(declared, evidence, exclude),
+ log=captured.getvalue(),
+ ))
+ return True
+
+
+def _blocked(records: list[result_export.Operation], name: str, reason: str) -> None:
+ records.append(result_export.Operation(name, result_export.DEFERRED, detail=reason))
+
+
+def _finish(
+ command: str,
+ args: argparse.Namespace,
+ records: Sequence[result_export.Operation],
+ *,
+ packages: Sequence[Path],
+) -> tuple[Path, ...]:
+ failed = any(item.status == result_export.FAILED for item in records)
+ if args.export_dir is not None:
+ _export(args.export_dir, command, records, packages=packages)
+ for item in records:
+ if item.status == result_export.DEFERRED:
+ print(f"[DEFERRED] {item.name}: {item.detail}")
+ outputs = tuple(report for item in records for report in item.reports)
+ if failed:
+ raise VerificationFailed(
+ outputs, tuple(item.name for item in records if item.status == result_export.FAILED)
+ )
+ return outputs
+
+
+def _restore_asan(
+ repository: Path, architectures: tuple[str, ...], tools: native.NativeTools,
+ processes: _Processes,
+) -> tuple[Path, ...]:
+ roots: list[Path] = []
+ for architecture in architectures:
+ root = native.artifacts_root(repository) / "vcpkg_installed" / f"{architecture}-asan"
+ scratch = native.artifacts_root(repository) / "vcpkg_scratch" / f"{architecture}-asan"
+ processes.native(
+ (
+ str(tools.vcpkg),
+ "install",
+ f"--x-install-root={root}",
+ f"--x-buildtrees-root={scratch / 'buildtrees'}",
+ f"--x-packages-root={scratch / 'packages'}",
+ "--triplet",
+ f"{native.triplet(architecture)}-asan",
+ f"--x-manifest-root={repository}",
+ f"--overlay-triplets={repository / 'build' / 'vcpkg' / 'triplets'}",
+ ),
+ repository,
+ None,
+ )
+ roots.append(root)
+ return tuple(roots)
+
+
+def _restore(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ tools = native.locate_tools()
+ outputs: tuple[Path, ...] = ()
+ if "default" in args.restore:
+ outputs += native.restore(repository, args.arch, tools=tools, runner=processes.native)
+ if "asan" in args.restore:
+ architectures = tuple(item for item in args.arch if item != "arm64")
+ if architectures:
+ outputs += _restore_asan(repository, architectures, tools, processes)
+ return outputs
+
+
+def _build(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ return native.build(
+ repository, args.arch, args.config, jobs=args.jobs,
+ tools=native.locate_tools(), runner=processes.native,
+ )
+
+
+def _test(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ return native.test(
+ repository, args.arch, args.config, corpus=args.corpus, jobs=args.jobs,
+ tools=native.locate_tools(), runner=processes.native,
+ )
+
+
+def _source_checks(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ output = _output_dir(repository, "source-checks")
+ return source_checks.source_checks(
+ repository, args.arch, output, jobs=_jobs(args.jobs, 4),
+ tools=source_checks.locate_tools(), runner=processes.native,
+ )
+
+
+def _compiler_analysis(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ output = _output_dir(repository, "compiler-analysis")
+ return source_checks.compiler_analysis(
+ repository, args.arch, output, jobs=_jobs(args.jobs, 2),
+ tools=source_checks.locate_analysis_tools(), runner=processes.native,
+ )
+
+
+def _test_coverage(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ output = _output_dir(repository, "test-coverage")
+ return diagnostics.test_coverage(
+ repository, args.arch, output, tools=_diagnostics_tools(args.arch, profile=True),
+ runner=processes.tools, jobs=args.jobs, corpus=args.corpus,
+ )
+
+
+def _sanitizer(sanitizer: str) -> Callable[[Path, argparse.Namespace, _Processes], tuple[Path, ...]]:
+ def handler(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ output = _output_dir(repository, f"test-{sanitizer}")
+ return diagnostics.test_sanitizer(
+ repository, sanitizer, args.arch, output,
+ tools=_diagnostics_tools(args.arch, **{sanitizer: True}),
+ runner=processes.tools, jobs=args.jobs,
+ )
+
+ return handler
+
+
+def _test_leaks(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ output = _output_dir(repository, "test-leaks")
+ return diagnostics.test_leaks(
+ repository, output, args.leak_warmup, args.leak_iterations, args.leak_windows,
+ args.leak_tolerance, tools=_diagnostics_tools(("x64",), umdh=True),
+ runner=processes.tools, jobs=args.jobs,
+ )
+
+
+def _fuzz(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ output = _output_dir(repository, "fuzz")
+ return diagnostics.fuzz(
+ repository, args.arch, args.fuzz_target, args.fuzz_seconds, output,
+ tools=_diagnostics_tools(args.arch, fuzzer=True), runner=processes.tools, jobs=args.jobs,
+ )
+
+
+def _audit(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ output = _output_dir(repository, "audit-binaries")
+ # The public audit gates the stable Release binaries, so it builds Release first (MSBuild is
+ # incremental on a warm tree) exactly as the retired driver's audited-release did.
+ native.build(
+ repository, args.arch, ("Release",), jobs=args.jobs,
+ tools=native.locate_tools(), runner=processes.native,
+ )
+ return packaging_ops.audit_binaries(
+ repository, args.arch, output, modules=args.module,
+ tools=packaging_ops.locate_packaging_tools(), runner=processes.packaging,
+ )
+
+
+def _package_command(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ host = detect_host_architecture()
+ route = verify_route(args.arch, host)
+ # A direct package run owns a fresh operation output root instead of the shared ``out`` tree,
+ # so a rerun can never republish the stale archives left under ``out/packages`` and a failure
+ # keeps only the audit evidence this run actually produced.
+ output = _reset_output(repository, "package")
+ packages_root = output / "packages"
+ captured = StringIO()
+ try:
+ # Packaging freezes and audits the stable Release binaries, so build Release first.
+ with redirect_stdout(_Tee(sys.stdout, captured)):
+ native.build(
+ repository, args.arch, ("Release",), jobs=args.jobs,
+ tools=native.locate_tools(), runner=processes.native,
+ )
+ result = packaging_ops.package(
+ repository, args.arch, output, modules=args.module,
+ smoke_architectures=route.runnable,
+ tools=packaging_ops.locate_packaging_tools(), runner=processes.packaging, host=host,
+ )
+ except Exception as error:
+ if args.export_dir is not None:
+ _export(args.export_dir, "package", (
+ result_export.Operation(
+ "package", result_export.FAILED, detail=_concise(error),
+ reports=_owned_reports((), output, packages_root),
+ log=captured.getvalue() + traceback.format_exc(),
+ ),
+ ))
+ raise
+ if args.export_dir is not None:
+ _export(
+ args.export_dir, "package",
+ (result_export.Operation(
+ "package", result_export.PASSED,
+ # The just-produced audit and smoke evidence is the operation's report; the
+ # release payload below the ``packages`` root is exported only as packages.
+ reports=_owned_reports((), output, packages_root),
+ log=captured.getvalue(),
+ ),),
+ packages=(*result.archives, packages_root / "packages.json"),
+ )
+ return result.archives
+
+
+def _tests_action(
+ repository: Path, args: argparse.Namespace, architecture: str,
+ runnable: tuple[str, ...], processes: _Processes, host: str,
+) -> Callable[[], tuple[Path, ...]]:
+ def action() -> tuple[Path, ...]:
+ # MSBuild keeps its JUnit reports in a persistent per-architecture tree, so a fresh run
+ # must clear the selected architecture first; otherwise an early build failure would
+ # publish a stale previous success from this same architecture.
+ root = Path(repository).resolve(strict=True)
+ prepare_fresh(native.artifacts_root(root) / "reports" / "tests" / architecture, root)
+ tools = native.locate_tools()
+ if architecture in runnable:
+ return native.test(
+ repository, (architecture,), ("Debug", "Release"), corpus=args.corpus,
+ jobs=args.jobs, tools=tools, runner=processes.native, host=host,
+ )
+ return native.build(
+ repository, (architecture,), ("Debug", "Release"), jobs=args.jobs,
+ tools=tools, runner=processes.native,
+ )
+
+ return action
+
+
+def _python_coverage(repository: Path, output: Path, processes: _Processes) -> tuple[Path, ...]:
+ build_root = Path(repository).resolve(strict=True) / "build"
+ output.mkdir(parents=True, exist_ok=True)
+ # The retained gate stores its raw ``.coverage`` data file in the build directory and then
+ # copies it into the output directory, so the two must be different directories or its
+ # ``shutil.copyfile`` raises SameFileError before publishing any evidence.
+ data = output / "work"
+ data.mkdir(parents=True, exist_ok=True)
+ processes.native(
+ (sys.executable, "-m", "core.python_coverage", sys.executable, str(build_root)),
+ build_root,
+ {"OBSERVER_OUT_DIR": str(output), "OBSERVER_BUILD_DIR": str(data)},
+ )
+ # The retained gate copies its raw data file into the output directory, so the published
+ # evidence is the five native reports it writes; the ``work`` copy is scratch, not a report.
+ return (
+ output / ".coverage",
+ output / "coverage.json",
+ output / "coverage.xml",
+ output / "coverage.toml",
+ output / "coverage.txt",
+ )
+
+
+def _verify_source(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ work = _reset_output(repository, "verify-source")
+ records: list[result_export.Operation] = []
+ _attempt(records, "source", lambda: source_checks.source_checks(
+ repository, ("x64",), work / "source", jobs=_jobs(args.jobs, 4),
+ tools=source_checks.locate_tools(), runner=processes.native,
+ ), evidence=work / "source")
+ _attempt(records, "python-coverage", lambda: _python_coverage(
+ repository, work / "python-coverage", processes,
+ ), evidence=work / "python-coverage")
+ return _finish("verify-source", args, records, packages=())
+
+
+def _verification(
+ command: str, repository: Path, args: argparse.Namespace, processes: _Processes,
+) -> tuple[Path, ...]:
+ host = detect_host_architecture()
+ route = verify_route(args.arch, host)
+ work = _reset_output(repository, command)
+ records: list[result_export.Operation] = []
+
+ _attempt(records, "source", lambda: source_checks.source_checks(
+ repository, args.arch, work / "source", jobs=_jobs(args.jobs, 4),
+ tools=source_checks.locate_tools(), runner=processes.native,
+ ), evidence=work / "source")
+ _attempt(records, "compiler-analysis", lambda: source_checks.compiler_analysis(
+ repository, args.arch, work / "compiler-analysis", jobs=_jobs(args.jobs, 2),
+ tools=source_checks.locate_analysis_tools(), runner=processes.native,
+ ), evidence=work / "compiler-analysis")
+ if command == "verify":
+ _attempt(records, "python-coverage", lambda: _python_coverage(
+ repository, work / "python-coverage", processes,
+ ), evidence=work / "python-coverage")
+
+ tests_root = native.artifacts_root(Path(repository).resolve(strict=True)) / "reports" / "tests"
+ for architecture in args.arch:
+ # A non-runnable target is only cross-built here, so its record is a ``build-``
+ # gate; the deferred ``tests-`` verdict is added separately below. Naming the
+ # cross-build ``tests-`` would collide with that deferral and the exporter would
+ # reject the duplicate operation.
+ name = (
+ f"tests-{architecture}" if architecture in route.runnable
+ else f"build-{architecture}"
+ )
+ _attempt(
+ records, name,
+ _tests_action(repository, args, architecture, route.runnable, processes, host),
+ evidence=tests_root / architecture,
+ )
+
+ if route.coverage:
+ _attempt(records, "coverage", lambda: diagnostics.test_coverage(
+ repository, route.coverage, work / "coverage",
+ tools=_diagnostics_tools(route.coverage, profile=True), runner=processes.tools,
+ jobs=args.jobs, host=host, corpus=args.corpus,
+ ), evidence=work / "coverage")
+ if route.asan:
+ _attempt(records, "asan", lambda: diagnostics.test_sanitizer(
+ repository, "asan", route.asan, work / "asan",
+ tools=_diagnostics_tools(route.asan, asan=True),
+ runner=processes.tools, jobs=args.jobs, host=host,
+ ), evidence=work / "asan")
+ if route.ubsan:
+ _attempt(records, "ubsan", lambda: diagnostics.test_sanitizer(
+ repository, "ubsan", route.ubsan, work / "ubsan",
+ tools=_diagnostics_tools(route.ubsan, ubsan=True),
+ runner=processes.tools, jobs=args.jobs, host=host,
+ ), evidence=work / "ubsan")
+ if route.fuzz:
+ _attempt(records, "fuzz", lambda: diagnostics.fuzz(
+ repository, route.fuzz, _FUZZ_TARGETS, args.fuzz_seconds, work / "fuzz",
+ tools=_diagnostics_tools(route.fuzz, fuzzer=True), runner=processes.tools,
+ jobs=args.jobs, host=host,
+ ), evidence=work / "fuzz")
+ if route.run_x64_specialists:
+ _attempt(records, "leaks", lambda: diagnostics.test_leaks(
+ repository, work / "leaks", args.leak_warmup, args.leak_iterations,
+ args.leak_windows, args.leak_tolerance,
+ tools=_diagnostics_tools(("x64",), umdh=True), runner=processes.tools,
+ jobs=args.jobs, host=host,
+ ), evidence=work / "leaks")
+
+ # The shared ``work`` tree is reset fresh per run and ``audit_binaries`` fails closed unless
+ # its output root already exists, so the gate creates its root; keeping the mkdir inside the
+ # action records a creation failure as failed audit evidence and suppresses packaging.
+ audit_root = work / "audit"
+
+ def audit_gate() -> tuple[Path, ...]:
+ audit_root.mkdir(parents=True, exist_ok=True)
+ return packaging_ops.audit_binaries(
+ repository, args.arch, audit_root, modules=args.module,
+ include_leak_probe=route.run_x64_specialists,
+ tools=packaging_ops.locate_packaging_tools(), runner=processes.packaging,
+ )
+
+ _attempt(records, "audit", audit_gate, evidence=audit_root)
+
+ packages: tuple[Path, ...] = ()
+ # Packaging is the only dependent operation: it publishes release payloads, so it stays
+ # blocked until every independent gate above has passed. Any failure (source, coverage,
+ # sanitizer, fuzz, audit or a suite) suppresses the archives instead of exporting them.
+ if any(item.status == result_export.FAILED for item in records):
+ _blocked(records, "package", "blocked by failed gates")
+ else:
+ # Packaging owns a dedicated operation root, so a failure keeps the audit evidence it
+ # has already produced instead of scanning only the never-published ``packages`` tree.
+ package_root = work / "package"
+ package_root.mkdir(parents=True, exist_ok=True)
+
+ def package_gate() -> tuple[Path, ...]:
+ return packaging_ops.package(
+ repository, args.arch, package_root, modules=args.module,
+ smoke_architectures=route.runnable,
+ tools=packaging_ops.locate_packaging_tools(), runner=processes.packaging, host=host,
+ ).archives
+
+ if _attempt(
+ records, "package", package_gate,
+ evidence=package_root, exclude=package_root / "packages",
+ ):
+ packages = _files_under(package_root / "packages")
+
+ for item in route.deferred:
+ _blocked(records, f"{item.gate}-{item.architecture}", item.reason)
+
+ return _finish(command, args, records, packages=packages)
+
+
+def _verify_arch(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ return _verification("verify-arch", repository, args, processes)
+
+
+def _verify(repository: Path, args: argparse.Namespace, processes: _Processes) -> tuple[Path, ...]:
+ return _verification("verify", repository, args, processes)
+
+
+_HANDLERS: dict[str, Callable[[Path, argparse.Namespace, _Processes], tuple[Path, ...]]] = {
+ "restore": _restore,
+ "build": _build,
+ "test": _test,
+ "source-checks": _source_checks,
+ "compiler-analysis": _compiler_analysis,
+ "test-coverage": _test_coverage,
+ "test-asan": _sanitizer("asan"),
+ "test-ubsan": _sanitizer("ubsan"),
+ "test-leaks": _test_leaks,
+ "fuzz": _fuzz,
+ "audit-binaries": _audit,
+ "package": _package_command,
+ "verify-source": _verify_source,
+ "verify-arch": _verify_arch,
+ "verify": _verify,
+}
+
+
+def _validate_architecture_rules(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
+ if args.command == "test-leaks" and args.arch != ("x64",):
+ parser.error("test-leaks requires -Arch x64")
+ if args.command == "fuzz" and any(item not in _FUZZ_ARCHITECTURES for item in args.arch):
+ parser.error("fuzz requires -Arch x86, x64, or x86,x64")
+ if args.command == "verify-arch" and len(args.arch) != 1:
+ parser.error("verify-arch requires exactly one architecture")
+
+
+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":
+ _validate_architecture_rules(parser, args)
+ priority = _PRIORITIES[getattr(args, "priority", "below-normal")]
+ previous = _prioritize(priority)
+ try:
+ if args.command == "clean":
+ outputs = clean_output(args.repository, args.clean_mode)
+ else:
+ outputs = _HANDLERS[args.command](args.repository, args, _processes(priority))
+ except VerificationFailed as error:
+ outputs, failures = error.outputs, error.failures
+ except subprocess.CalledProcessError as error:
+ print(
+ f"observer-build: command failed with exit code {error.returncode}: {error.cmd}",
+ file=sys.stderr,
+ )
+ return error.returncode or 1
+ except _OPERATION_ERRORS as error:
+ print(f"observer-build: {_concise(error)}", file=sys.stderr)
+ return 2
+ else:
+ failures = ()
+ finally:
+ _restore_priority(previous)
+ for output in outputs:
+ print(output)
+ if failures:
+ print(f"observer-build: failed gates: {', '.join(failures)}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/native.py b/build/native.py
new file mode 100644
index 0000000..9efb917
--- /dev/null
+++ b/build/native.py
@@ -0,0 +1,445 @@
+"""Direct native MSBuild and Catch2 route for ObserverModules.
+
+The content-addressed graph drove MSBuild project by project through rendered
+PowerShell. This module is the smaller successor for the native family: it
+invokes vcpkg and MSBuild straight from Python with stable directories under
+``out/native`` and lets MSBuild own project dependencies, scheduling and
+incremental object state. It needs only MSVC and vcpkg (not LLVM, UMDH or
+BinSkim) and is self-contained: the standard library only, no graph and no CAS.
+"""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass
+import os
+from pathlib import Path
+import subprocess
+import sys
+
+from core import host as host_policy
+
+
+ARCHITECTURES: tuple[str, ...] = ("x86", "x64", "arm64")
+PLATFORMS: Mapping[str, str] = {"x86": "Win32", "x64": "x64", "arm64": "ARM64"}
+# The direct route builds only what MSVC alone provides. Coverage, sanitizer,
+# fuzz and UBSan configurations select ClangCL and LLVM, which stay out of scope.
+CONFIGURATIONS: tuple[str, ...] = ("Debug", "Release")
+BINARIES: Mapping[str, str] = {
+ "renpy": "renpy.so",
+ "rpgmaker": "rpgmaker.so",
+ "zanzarah": "zanzarah.so",
+ "tests": "tests.exe",
+}
+PROJECTS: tuple[str, ...] = tuple(BINARIES)
+
+REPOSITORY = Path(__file__).resolve().parents[1]
+
+
+Runner = Callable[[Sequence[str], Path | None, Mapping[str, str] | None], None]
+
+
+@dataclass(frozen=True, slots=True)
+class NativeTools:
+ msbuild: Path
+ vcpkg: Path
+
+ @property
+ def vcpkg_root(self) -> Path:
+ return self.vcpkg.parent
+
+
+def run_tool(argv: Sequence[str], cwd: Path | None = None, env: Mapping[str, str] | None = None) -> None:
+ """Run one native tool, letting a nonzero exit raise ``CalledProcessError``."""
+
+ subprocess.run(
+ [os.fspath(item) for item in argv],
+ cwd=None if cwd is None else os.fspath(cwd),
+ env={**os.environ, **(env or {})},
+ check=True,
+ )
+
+
+def _required_file(path: Path, description: str) -> Path:
+ resolved = Path(path).resolve()
+ if not resolved.is_file():
+ raise FileNotFoundError(f"{description} not found: {path}")
+ return resolved
+
+
+def vswhere_path(environ: Mapping[str, str]) -> Path:
+ base = environ.get("ProgramFiles(x86)")
+ if not base:
+ raise FileNotFoundError("ProgramFiles(x86) is not set; cannot locate vswhere.exe")
+ return Path(base) / "Microsoft Visual Studio" / "Installer" / "vswhere.exe"
+
+
+def find_msbuild(vswhere: Path, *, run: Callable[..., object] = subprocess.run) -> Path:
+ """Ask vswhere for the newest MSVC installation and pick its MSBuild binary.
+
+ A bare ``Microsoft.Component.MSBuild`` requirement is satisfied by .NET-only
+ installations, so the query also requires the VS2022/v143 C++ build tools.
+ Diagnostics tooling such as LLVM/Clang is deliberately not required.
+ """
+
+ argv = [
+ str(vswhere),
+ "-latest",
+ "-products",
+ "*",
+ "-requires",
+ "Microsoft.Component.MSBuild",
+ "-requires",
+ "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
+ "-property",
+ "installationPath",
+ ]
+ result = run(argv, capture_output=True, text=True, check=True)
+ installation = next(
+ (line.strip() for line in str(result.stdout).splitlines() if line.strip()), None
+ )
+ if installation is None:
+ raise RuntimeError("vswhere did not report a Visual Studio installation")
+ directory = Path(installation)
+ amd64 = directory / "MSBuild" / "Current" / "Bin" / "amd64" / "MSBuild.exe"
+ plain = directory / "MSBuild" / "Current" / "Bin" / "MSBuild.exe"
+ return _required_file(amd64 if amd64.is_file() else plain, "MSBuild.exe")
+
+
+def find_vcpkg(environ: Mapping[str, str]) -> Path:
+ """Resolve vcpkg from ``VCPKG_ROOT``, refusing a bare PATH shim."""
+
+ root = environ.get("VCPKG_ROOT")
+ if not root:
+ raise FileNotFoundError("VCPKG_ROOT is not set; refusing to trust a PATH shim")
+ integration = Path(root) / "scripts" / "buildsystems" / "msbuild" / "vcpkg.props"
+ if not integration.is_file():
+ raise FileNotFoundError(f"VCPKG_ROOT is not a vcpkg checkout: {root}")
+ return _required_file(Path(root) / "vcpkg.exe", "vcpkg.exe")
+
+
+def locate_tools(
+ *, environ: Mapping[str, str] | None = None, run: Callable[..., object] = subprocess.run
+) -> NativeTools:
+ values = os.environ if environ is None else environ
+ msbuild = find_msbuild(_required_file(vswhere_path(values), "vswhere.exe"), run=run)
+ return NativeTools(msbuild=msbuild, vcpkg=find_vcpkg(values))
+
+
+def artifacts_root(repository: Path) -> Path:
+ return Path(repository) / "out" / "native"
+
+
+def restore_root(repository: Path, architecture: str) -> Path:
+ return artifacts_root(repository) / "vcpkg_installed" / architecture
+
+
+def bin_directory(repository: Path, architecture: str, configuration: str) -> Path:
+ return artifacts_root(repository) / "bin" / architecture / configuration
+
+
+def artifacts(repository: Path, architecture: str, configuration: str) -> tuple[Path, ...]:
+ directory = bin_directory(repository, architecture, configuration)
+ return tuple(directory / BINARIES[project] for project in PROJECTS)
+
+
+def test_executable(repository: Path, architecture: str, configuration: str) -> Path:
+ return bin_directory(repository, architecture, configuration) / BINARIES["tests"]
+
+
+def triplet(architecture: str) -> str:
+ return f"observer-{architecture}-windows-static"
+
+
+def restore_command(repository: Path, architecture: str, tools: NativeTools) -> tuple[str, ...]:
+ root = artifacts_root(repository)
+ scratch = root / "vcpkg_scratch" / architecture
+ return (
+ str(tools.vcpkg),
+ "install",
+ f"--x-install-root={restore_root(repository, architecture)}",
+ f"--x-buildtrees-root={scratch / 'buildtrees'}",
+ f"--x-packages-root={scratch / 'packages'}",
+ "--triplet",
+ triplet(architecture),
+ f"--x-manifest-root={repository}",
+ f"--overlay-triplets={repository / 'build' / 'vcpkg' / 'triplets'}",
+ )
+
+
+def build_command(
+ repository: Path, architecture: str, configuration: str, jobs: int, tools: NativeTools
+) -> tuple[str, ...]:
+ # The trailing separator is required: the props concatenate the root with a
+ # relative segment, so ``out\native`` without it would build ``out\nativebin``.
+ root = f"{artifacts_root(repository)}{os.sep}"
+ return (
+ str(tools.msbuild),
+ str(repository / "build" / "Observer.proj"),
+ "/nologo",
+ f"/m:{jobs}",
+ "/nr:false",
+ "/t:Build",
+ f"/p:Configuration={configuration}",
+ f"/p:Platform={PLATFORMS[architecture]}",
+ f"/p:ArtifactsRoot={root}",
+ f"/p:VcpkgRoot={tools.vcpkg.parent}",
+ "/p:VcpkgManifestInstall=false",
+ )
+
+
+def test_command(
+ repository: Path, architecture: str, configuration: str, report: Path, corpus: Path | None
+) -> tuple[str, ...]:
+ command = [str(test_executable(repository, architecture, configuration))]
+ if corpus is not None:
+ command.append("[compatibility]")
+ command += [
+ "--reporter", "compact",
+ "--reporter", f"JUnit::out={report}",
+ "--durations", "yes",
+ "--order", "lex",
+ ]
+ return tuple(command)
+
+
+def host_architecture(machine: str | None = None) -> str:
+ """Canonicalise a machine name through the shared ``core.host`` policy."""
+
+ try:
+ return host_policy.detect_host_architecture(machine)
+ except RuntimeError as error:
+ raise ValueError(str(error)) from error
+
+
+def require_runnable(architectures: Sequence[str], host: str) -> None:
+ """Refuse targets the host cannot execute, using the shared runnable matrix."""
+
+ try:
+ host_policy.require_runnable(tuple(architectures), host)
+ except RuntimeError as error:
+ raise ValueError(str(error)) from error
+
+
+def _validate_architectures(architectures: Sequence[str]) -> None:
+ if not architectures:
+ raise ValueError("at least one architecture is required")
+ for architecture in architectures:
+ if architecture not in PLATFORMS:
+ raise ValueError(f"unsupported architecture: {architecture}")
+
+
+def _validate_configurations(configurations: Sequence[str]) -> None:
+ if not configurations:
+ raise ValueError("at least one configuration is required")
+ for configuration in configurations:
+ if configuration not in CONFIGURATIONS:
+ raise ValueError(f"unsupported configuration: {configuration}")
+
+
+def _unique(values: Sequence[str]) -> tuple[str, ...]:
+ return tuple(dict.fromkeys(values))
+
+
+def restore(
+ repository: Path,
+ architectures: Sequence[str] = ("x64",),
+ *,
+ tools: NativeTools | None = None,
+ runner: Runner | None = None,
+) -> tuple[Path, ...]:
+ repository = Path(repository).resolve()
+ architectures = _unique(architectures)
+ _validate_architectures(architectures)
+ tools = tools or locate_tools()
+ execute = runner or run_tool
+ roots = []
+ for architecture in architectures:
+ execute(restore_command(repository, architecture, tools), repository, None)
+ roots.append(restore_root(repository, architecture))
+ return tuple(roots)
+
+
+def build(
+ repository: Path,
+ architectures: Sequence[str] = ("x64",),
+ configurations: Sequence[str] = ("Debug",),
+ *,
+ jobs: int | None = None,
+ tools: NativeTools | None = None,
+ runner: Runner | None = None,
+) -> tuple[Path, ...]:
+ repository = Path(repository).resolve()
+ architectures = _unique(architectures)
+ configurations = _unique(configurations)
+ _validate_architectures(architectures)
+ _validate_configurations(configurations)
+ if jobs is None:
+ jobs = os.cpu_count() or 1
+ elif jobs < 1:
+ raise ValueError("jobs must be a positive integer")
+ tools = tools or locate_tools()
+ execute = runner or run_tool
+ restore(repository, architectures, tools=tools, runner=execute)
+ outputs: list[Path] = []
+ for architecture in architectures:
+ for configuration in configurations:
+ execute(
+ build_command(repository, architecture, configuration, jobs, tools),
+ repository, None,
+ )
+ outputs.extend(artifacts(repository, architecture, configuration))
+ return tuple(outputs)
+
+
+def test(
+ repository: Path,
+ architectures: Sequence[str] = ("x64",),
+ configurations: Sequence[str] = ("Debug",),
+ *,
+ corpus: Path | None = None,
+ host: str | None = None,
+ jobs: int | None = None,
+ tools: NativeTools | None = None,
+ runner: Runner | None = None,
+) -> tuple[Path, ...]:
+ """Build and run Catch2 for every variant, returning each JUnit report.
+
+ The ordinary hermetic suite always runs for every runnable variant. A
+ compatibility corpus is optional and, when supplied, is appended as a
+ second suite per variant (with ``OBSERVER_TEST_CORPUS`` scoped to it only).
+ Both report sets are returned together, unit reports first, so a caller
+ never has to duplicate the ordinary run.
+ """
+
+ repository = Path(repository).resolve()
+ architectures = _unique(architectures)
+ configurations = _unique(configurations)
+ _validate_architectures(architectures)
+ _validate_configurations(configurations)
+ require_runnable(architectures, host or host_architecture())
+ corpus_path: Path | None = None
+ if corpus is not None:
+ corpus_path = Path(corpus).resolve(strict=True)
+ if not corpus_path.is_dir():
+ raise NotADirectoryError(corpus_path)
+ execute = runner or run_tool
+ build(repository, architectures, configurations, jobs=jobs, tools=tools, runner=execute)
+ reports: list[Path] = []
+ for architecture in architectures:
+ for configuration in configurations:
+ # The ordinary hermetic suite always runs; an optional compatibility
+ # corpus is appended as a separate suite rather than replacing it.
+ suites: list[tuple[Path | None, Mapping[str, str] | None]] = [(None, None)]
+ if corpus_path is not None:
+ suites.append(
+ (corpus_path, {"OBSERVER_TEST_CORPUS": str(corpus_path)})
+ )
+ for suite_corpus, environment in suites:
+ scope = "corpus" if suite_corpus is not None else "unit"
+ report = (
+ artifacts_root(repository) / "reports" / "tests"
+ / architecture / configuration / scope / "tests.xml"
+ )
+ report.parent.mkdir(parents=True, exist_ok=True)
+ execute(
+ test_command(
+ repository, architecture, configuration, report, suite_corpus
+ ),
+ bin_directory(repository, architecture, configuration),
+ environment,
+ )
+ reports.append(report)
+ return tuple(reports)
+
+
+def _selection(choices: tuple[str, ...]) -> Callable[[str], tuple[str, ...]]:
+ lookup = {item.casefold(): item for item in choices}
+
+ def parse(value: str) -> tuple[str, ...]:
+ parts = tuple(item.strip() for item in value.split(","))
+ if not all(parts):
+ raise argparse.ArgumentTypeError(
+ f"expected all or comma-separated: {','.join(choices)}"
+ )
+ if any(item.casefold() == "all" for item in parts):
+ if len(parts) != 1:
+ raise argparse.ArgumentTypeError("'all' must be selected on its own")
+ return choices
+ try:
+ return tuple(dict.fromkeys(lookup[item.casefold()] for item in parts))
+ except KeyError as error:
+ raise argparse.ArgumentTypeError(
+ f"expected all or comma-separated: {','.join(choices)}"
+ ) from error
+
+ return parse
+
+
+def _positive_integer(value: str) -> int:
+ try:
+ number = int(value)
+ except ValueError as error:
+ raise argparse.ArgumentTypeError("expected a positive integer") from error
+ if number < 1:
+ raise argparse.ArgumentTypeError("expected a positive integer")
+ return number
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="observer-native")
+ commands = parser.add_subparsers(dest="command", required=True)
+ restore_parser = commands.add_parser("restore")
+ build_parser = commands.add_parser("build")
+ test_parser = commands.add_parser("test")
+ for command in (restore_parser, build_parser, test_parser):
+ command.add_argument("-Repository", "--repository", type=Path, default=REPOSITORY)
+ command.add_argument("-Arch", "--arch", type=_selection(ARCHITECTURES), default=("x64",))
+ for command in (build_parser, test_parser):
+ command.add_argument("-Config", "--config", type=_selection(CONFIGURATIONS), default=("Debug",))
+ command.add_argument("-Jobs", "--jobs", type=_positive_integer, default=None)
+ test_parser.add_argument("-Corpus", "--corpus", type=Path, default=None)
+ return parser
+
+
+def main(
+ argv: Sequence[str] | None = None,
+ *,
+ environ: Mapping[str, str] | None = None,
+ tools: NativeTools | None = None,
+ runner: Runner | None = None,
+) -> int:
+ arguments = list(sys.argv[1:] if argv is None else argv)
+ args = _parser().parse_args(arguments)
+ execute = runner or run_tool
+ try:
+ resolved = tools or locate_tools(environ=environ)
+ if args.command == "restore":
+ outputs = restore(args.repository, args.arch, tools=resolved, runner=execute)
+ elif args.command == "build":
+ outputs = build(
+ args.repository, args.arch, args.config, jobs=args.jobs,
+ tools=resolved, runner=execute,
+ )
+ else:
+ outputs = test(
+ args.repository, args.arch, args.config, corpus=args.corpus,
+ tools=resolved, runner=execute,
+ )
+ except subprocess.CalledProcessError as error:
+ print(
+ f"observer-native: command failed with exit code {error.returncode}: {error.cmd}",
+ file=sys.stderr,
+ )
+ return error.returncode or 1
+ except (ValueError, OSError) as error:
+ print(f"observer-native: {error}", file=sys.stderr)
+ return 2
+ for output in outputs:
+ print(output)
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/build/packaging_ops.py b/build/packaging_ops.py
new file mode 100644
index 0000000..2d07a4a
--- /dev/null
+++ b/build/packaging_ops.py
@@ -0,0 +1,675 @@
+"""Direct audit and packaging over the stable ``out/native`` Release artifacts.
+
+This is the console-first, standard-library successor to the content-addressed
+``graphs/audit.py`` and ``graphs/package.py`` families. It expresses the same
+release gates as plain Python that shells out to the already-validated ``core``
+command line modules -- ``core.binary_audit``, ``core.module_version`` and
+``core.package`` -- each with an operation-scoped ``OBSERVER_OUT_DIR``. It owns no
+graph, cache or scheduler: MSBuild keeps the incremental state and ``core`` keeps
+the checks, so no gate is restated or weakened here.
+
+Independent per-module releases (``renpy/vX.Y.Z`` and friends) ship only the
+selected module's assets, and every PDB archive is scoped to one module, its
+version and one architecture. ``core.package``'s ``archive-symbols`` and
+``aggregate`` commands hard-code the full module set, so this route assembles the
+per-module symbols from the explicitly staged payloads and reports a manifest that
+``core.release`` accepts once its complete-module-set requirement is relaxed to
+the selected set (see ``package``).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Iterable, Mapping
+from dataclasses import dataclass
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import stat
+import subprocess
+import sys
+import zipfile
+
+from core.host import DeferredGate, runnable_architectures
+from core.module_version import VERSION_FILE, content_identity, read_version, shared_content_inputs
+# ``archive-symbols``/``validate-symbols`` hard-code the full module set, so a per-module symbol
+# archive reuses the deterministic staging payload and ZIP writer rather than restating them.
+from core.package import LICENSES, MODULES, _stage_payload, _zip, module_archive_name
+from core.quality_tools import resolve_binskim, resolve_dumpbin
+from core.toolchain import discover_msvc_toolchain
+from native import ARCHITECTURES, bin_directory, locate_tools
+
+
+BUILD_ROOT = Path(__file__).resolve().parent
+LEAK_PROBE = "leak-probe"
+ALL = "all"
+RELEASE = "Release"
+REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
+# Undeclared first-party headers reachable beside a production source must not be lost, so the
+# conservative inventory keeps the shared trees and the API headers at the source root.
+_CONSERVATIVE_HEADER_TREES = ("src/common", "src/core")
+# The MSBuild configuration every selected project imports, mirroring the graph's
+# ``COMMON_PROJECT_INPUTS``. A change here recompiles a module without touching its sources, so
+# the release identity must sign it; the module ``VERSION`` file stays excluded instead.
+_SHARED_BUILD_INPUTS = (
+ "build/ObserverProjectConfigurations.props",
+ "build/ObserverConfiguration.props",
+ "build/ObserverProject.props",
+ "build/ObserverModuleVersion.props",
+)
+Runner = Callable[..., None]
+
+
+class PackagingError(RuntimeError):
+ """A release artifact, resource, or output path refused the release contract."""
+
+
+@dataclass(frozen=True, slots=True)
+class PackagingTools:
+ """The external tools the direct route resolves once per operation."""
+
+ msbuild: Path
+ vcpkg_root: Path
+ dumpbin: Path
+ binskim: Path
+
+
+@dataclass(frozen=True, slots=True)
+class PackageResult:
+ """The archives written and the gates deferred to a host that can run them."""
+
+ archives: tuple[Path, ...]
+ deferred: tuple[DeferredGate, ...]
+
+
+def locate_packaging_tools(*, environ: Mapping[str, str] | None = None) -> PackagingTools:
+ """Resolve MSBuild, vcpkg, dumpbin and BinSkim once from the installed toolchain."""
+
+ native = locate_tools(environ=environ)
+ toolchain = discover_msvc_toolchain()
+ return PackagingTools(
+ msbuild=native.msbuild,
+ vcpkg_root=native.vcpkg_root,
+ dumpbin=resolve_dumpbin(toolchain).path,
+ binskim=resolve_binskim().path,
+ )
+
+
+def _default_runner(argv, *, cwd=None, env=None, stdout=None) -> None:
+ """Run one tool, letting a nonzero exit raise ``CalledProcessError``."""
+
+ subprocess.run(
+ [os.fspath(item) for item in argv],
+ cwd=None if cwd is None else os.fspath(cwd),
+ env=None if env is None else {**os.environ, **env},
+ check=True,
+ stdout=stdout,
+ )
+
+
+def _modules(selection: str | Iterable[str] | None) -> tuple[str, ...]:
+ if selection is None or selection == ALL:
+ return MODULES
+ values = (selection,) if isinstance(selection, str) else tuple(selection)
+ if not values:
+ raise ValueError("module selection must not be empty")
+ unknown = next((module for module in values if module not in MODULES), None)
+ if unknown is not None:
+ raise ValueError(f"unknown module: {unknown}")
+ if len(set(values)) != len(values):
+ raise ValueError("module selection must not repeat a module")
+ return tuple(sorted(values, key=MODULES.index))
+
+
+def _architectures(architectures: Iterable[str]) -> tuple[str, ...]:
+ values = tuple(architectures)
+ if not values:
+ raise ValueError("at least one architecture is required")
+ unknown = next((item for item in values if item not in ARCHITECTURES), None)
+ if unknown is not None:
+ raise ValueError(f"unsupported architecture: {unknown}")
+ if len(set(values)) != len(values):
+ raise ValueError("architecture list must not repeat an architecture")
+ return tuple(sorted(values, key=ARCHITECTURES.index))
+
+
+def _smoke(architectures: tuple[str, ...], smoke_architectures: Iterable[str] | None) -> tuple[str, ...]:
+ if smoke_architectures is None:
+ return architectures
+ values = tuple(smoke_architectures)
+ unknown = next((item for item in values if item not in architectures), None)
+ if unknown is not None:
+ raise ValueError(f"smoke architecture is not selected: {unknown}")
+ if len(set(values)) != len(values):
+ raise ValueError("smoke architecture list must not repeat an architecture")
+ return tuple(sorted(values, key=architectures.index))
+
+
+def _is_reparse(path: Path) -> bool:
+ try:
+ information = os.lstat(path)
+ except OSError:
+ return False
+ attributes = getattr(information, "st_file_attributes", 0)
+ return stat.S_ISLNK(information.st_mode) or bool(attributes & REPARSE)
+
+
+def _confined(candidate: Path, root: Path) -> Path:
+ """Return ``candidate`` only when it stays below ``root`` without any reparse hop."""
+
+ path = Path(os.path.abspath(os.fspath(candidate)))
+ base = Path(os.path.abspath(os.fspath(root)))
+ if path != base and base not in path.parents:
+ raise PackagingError(f"path escapes the output tree: {path}")
+ current = path
+ while True:
+ if _is_reparse(current):
+ raise PackagingError(f"reparse point is forbidden below the output tree: {current}")
+ if current == base:
+ return path
+ current = current.parent
+
+
+def _output_root(output: Path) -> Path:
+ root = Path(output)
+ if not root.is_dir():
+ raise PackagingError(f"output directory must be an existing directory: {root}")
+ return _confined(root, root)
+
+
+def _directory(directory: Path, root: Path) -> Path:
+ target = _confined(directory, root)
+ target.mkdir(parents=True, exist_ok=True)
+ return target
+
+
+def _reset_directory(directory: Path, root: Path) -> Path:
+ """Remove one stale staging subdirectory and recreate it empty."""
+
+ target = _confined(directory, root)
+ if target.exists():
+ if not target.is_dir():
+ raise PackagingError(f"staging path is not a directory: {target}")
+ _reject_reparse_children(target, root)
+ shutil.rmtree(target)
+ target.mkdir(parents=True)
+ return target
+
+
+def _reject_reparse_children(directory: Path, root: Path) -> None:
+ """Refuse to remove a tree that hides a reparse hop anywhere below it."""
+
+ pending = [_confined(directory, root)]
+ while pending:
+ current = pending.pop()
+ with os.scandir(current) as entries:
+ for entry in entries:
+ child = Path(entry.path)
+ if _is_reparse(child):
+ raise PackagingError(f"reparse point is forbidden below the output tree: {child}")
+ if entry.is_dir(follow_symlinks=False):
+ pending.append(child)
+
+
+def _leaf(path: Path, root: Path) -> Path:
+ """Return one writable output leaf, never following an existing link or directory."""
+
+ target = _confined(path, root)
+ if target.is_dir():
+ raise PackagingError(f"output leaf is a directory: {target}")
+ return target
+
+
+def _publish(pending: Path, publish: Path, root: Path) -> Path:
+ """Swap a freshly staged tree into place, keeping the previous one if the move fails.
+
+ A published release is moved aside to ``.previous`` first, so a failed ``os.replace``
+ (for example a sharing violation on the manifest) can roll the old bytes back instead of
+ leaving the release destroyed. No transaction framework is involved, only this one rename.
+ """
+
+ target = _confined(publish, root)
+ backup: Path | None = None
+ if target.exists():
+ if not target.is_dir():
+ raise PackagingError(f"publish path is not a directory: {target}")
+ _reject_reparse_children(target, root)
+ backup = _confined(target.parent / f"{target.name}.previous", root)
+ if backup.exists():
+ _reject_reparse_children(backup, root)
+ shutil.rmtree(backup)
+ os.replace(target, backup)
+ try:
+ os.replace(pending, target)
+ except OSError:
+ if backup is not None:
+ os.replace(backup, target)
+ raise
+ if backup is not None:
+ shutil.rmtree(backup)
+ return target
+
+
+def _environment(out: Path) -> dict[str, str]:
+ return {"OBSERVER_OUT_DIR": os.fspath(out)}
+
+
+def _python(module: str) -> tuple[str, ...]:
+ return (sys.executable, "-m", module)
+
+
+def _sha256(path: Path) -> str:
+ with path.open("rb") as stream:
+ return hashlib.file_digest(stream, "sha256").hexdigest()
+
+
+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 _binaries(repository: Path, architecture: str) -> Path:
+ """Return the stable Release directory one architecture's binaries live in."""
+
+ return bin_directory(repository, architecture, RELEASE)
+
+
+def _artifact(binaries: Path, module: str) -> Path:
+ name = "leak-probe.exe" if module == LEAK_PROBE else f"{module}.so"
+ path = binaries / name
+ if not path.is_file():
+ raise PackagingError(f"release artifact is missing: {path}")
+ return path
+
+
+def _symbol(binaries: Path, module: str) -> Path:
+ path = binaries / f"{module}.pdb"
+ if not path.is_file():
+ raise PackagingError(f"release symbols are missing: {path}")
+ return path
+
+
+def _test_runner(binaries: Path) -> Path:
+ path = binaries / "tests.exe"
+ if not path.is_file():
+ raise PackagingError(f"test runner is missing: {path}")
+ return path
+
+
+def _item_paths(text: str, repository: Path) -> tuple[str, ...]:
+ """Return the repository-relative items from an MSBuild ``-getItem`` document."""
+
+ start = text.find("{")
+ if start < 0:
+ raise PackagingError("MSBuild returned no item inventory")
+ try:
+ document = json.loads(text[start:])
+ except json.JSONDecodeError as error:
+ raise PackagingError("MSBuild item inventory is not JSON") from error
+ if not isinstance(document, dict) or not isinstance(document.get("Items"), dict):
+ raise PackagingError("MSBuild item inventory has no Items")
+ paths: set[str] = set()
+ for name, values in document["Items"].items():
+ if not isinstance(values, list):
+ raise PackagingError(f"MSBuild item inventory for {name} is not a list")
+ for value in values:
+ identity = value.get("Identity") if isinstance(value, dict) else value
+ if not isinstance(identity, str):
+ raise PackagingError(f"MSBuild item inventory for {name} has a non-string identity")
+ candidate = Path(identity)
+ resolved = candidate if candidate.is_absolute() else repository / candidate
+ try:
+ relative = resolved.relative_to(repository)
+ except ValueError:
+ continue
+ if not resolved.is_file():
+ raise PackagingError(f"project item is missing: {resolved}")
+ paths.add(relative.as_posix())
+ if not paths:
+ raise PackagingError("MSBuild reported no project items")
+ return tuple(sorted(paths))
+
+
+def _project_inventory(
+ repository: Path, module: str, tools: PackagingTools, runner: Runner, scratch: Path
+) -> tuple[str, ...]:
+ """Ask MSBuild for one project's authoritative source and header inventory."""
+
+ project = repository / "build" / "projects" / f"{module}.vcxproj"
+ if not project.is_file():
+ raise PackagingError(f"module project is missing: {project}")
+ report = scratch / f"inventory-{module}.json"
+ argv = (
+ str(tools.msbuild), str(project), "-nologo",
+ "-getItem:ClCompile", "-getItem:ClInclude",
+ "-p:Configuration=Release", "-p:Platform=x64",
+ "-p:VcpkgManifestInstall=false", f"-p:VcpkgRoot={tools.vcpkg_root}",
+ )
+ with report.open("wb") as stream:
+ runner(argv, cwd=repository, env=None, stdout=stream)
+ return _item_paths(report.read_text(encoding="utf-8-sig"), repository)
+
+
+def module_content_inputs(
+ repository: Path, module: str, inventory: Iterable[str]
+) -> tuple[str, ...]:
+ """Return the repository files whose bytes decide whether one module changed."""
+
+ inputs = set(inventory)
+ inputs.update(shared_content_inputs(repository))
+ inputs.update(_SHARED_BUILD_INPUTS)
+ inputs.add(f"build/projects/{module}.vcxproj")
+ inputs.update({
+ f"src/modules/{module}/observer_user.ini",
+ "LICENSE.txt",
+ *(f"licenses/{name}" for name in LICENSES[module]),
+ })
+ trees = (
+ repository / "src" / "modules" / module,
+ *(repository / name for name in _CONSERVATIVE_HEADER_TREES),
+ )
+ for tree in trees:
+ if tree.is_dir():
+ inputs.update(
+ path.relative_to(repository).as_posix()
+ for path in tree.rglob("*")
+ if path.is_file()
+ )
+ inputs.update(
+ path.relative_to(repository).as_posix()
+ for path in (repository / "src").glob("*.h")
+ if path.is_file()
+ )
+ return tuple(sorted(
+ name for name in inputs if name.rsplit("/", 1)[-1] != VERSION_FILE
+ ))
+
+
+def _audit_modules(modules: tuple[str, ...], include_leak_probe: bool, architecture: str) -> tuple[str, ...]:
+ """Audit the requested modules plus the leak probe, which only exists for x64."""
+
+ probe = (LEAK_PROBE,) if include_leak_probe and architecture == "x64" else ()
+ return (*probe, *modules)
+
+
+def _audit(
+ repository: Path,
+ architectures: Iterable[str],
+ output: Path,
+ *,
+ modules: str | Iterable[str] | None,
+ include_leak_probe: bool,
+ tools: PackagingTools,
+ execute: Runner,
+ binaries_for: Callable[[str], Path],
+) -> tuple[Path, ...]:
+ """Gate the stable Release binaries ``binaries_for`` resolves for each architecture."""
+
+ selected_architectures = _architectures(architectures)
+ selected_modules = _modules(modules)
+ root = _output_root(output)
+ evidence: list[Path] = []
+ for architecture in selected_architectures:
+ binaries = binaries_for(architecture)
+ for module in _audit_modules(selected_modules, include_leak_probe, architecture):
+ binary = _artifact(binaries, module)
+ directory = _directory(root / "audit" / architecture / module, root)
+ logs = {}
+ for mode in ("headers", "dependents", "exports"):
+ log = _leaf(directory / f"{mode}.txt", root)
+ with log.open("wb") as stream:
+ execute(
+ (str(tools.dumpbin), f"/{mode}", str(binary)),
+ cwd=repository, env=None, stdout=stream,
+ )
+ logs[mode] = log
+ evidence.append(log)
+ # The leak probe is a self-contained EXE without module exports; every shipped
+ # module still exports exactly LoadSubModule/UnloadSubModule.
+ kind = ("--executable",) if module == LEAK_PROBE else ()
+ execute(
+ (*_python("core.binary_audit"), "pe", *kind, architecture,
+ str(logs["headers"]), str(logs["dependents"]), str(logs["exports"])),
+ cwd=BUILD_ROOT, env=None,
+ )
+ report = _leaf(directory / "binskim.sarif", root)
+ if report.exists():
+ report.unlink()
+ execute(
+ (*_python("core.binary_audit"), "run-binskim", str(tools.binskim), str(binary)),
+ cwd=BUILD_ROOT, env=_environment(directory),
+ )
+ execute(
+ (*_python("core.binary_audit"), "binskim", str(report)),
+ cwd=BUILD_ROOT, env=None,
+ )
+ evidence.append(report)
+ if module != LEAK_PROBE:
+ execute(
+ (*_python("core.module_version"), "verify", read_version(repository, module),
+ str(binary)),
+ cwd=BUILD_ROOT, env=None,
+ )
+ return tuple(evidence)
+
+
+def audit_binaries(
+ repository: Path,
+ architectures: Iterable[str],
+ output: Path,
+ *,
+ modules: str | Iterable[str] | None = None,
+ include_leak_probe: bool = False,
+ tools: PackagingTools | None = None,
+ runner: Runner | None = None,
+) -> tuple[Path, ...]:
+ """Gate every selected stable Release binary with dumpbin, BinSkim and its version."""
+
+ repository = Path(repository).resolve(strict=True)
+ return _audit(
+ repository,
+ architectures,
+ output,
+ modules=modules,
+ include_leak_probe=include_leak_probe,
+ tools=tools or locate_packaging_tools(),
+ execute=runner or _default_runner,
+ binaries_for=lambda architecture: _binaries(repository, architecture),
+ )
+
+
+def _validate_zip(archive: Path, files: Mapping[str, Path]) -> None:
+ """Fail closed unless one deterministic archive carries exactly the staged bytes."""
+
+ with zipfile.ZipFile(archive) as bundle:
+ members = bundle.infolist()
+ if len(members) != len({member.filename for member in members}):
+ raise PackagingError("symbols archive has duplicate entries")
+ if sorted(member.filename for member in members) != sorted(files):
+ raise PackagingError("symbols archive does not match the staged payload")
+ for member in members:
+ digest = hashlib.file_digest(bundle.open(member), "sha256").hexdigest()
+ if digest != _sha256(files[member.filename]):
+ raise PackagingError("symbols archive does not match the staged payload")
+
+
+def _manifest(
+ repository: Path,
+ modules: tuple[str, ...],
+ inventory: Mapping[str, tuple[str, ...]],
+ archives: Iterable[Path],
+) -> dict[str, object]:
+ records = [
+ {
+ "content": content_identity(
+ repository, module_content_inputs(repository, module, inventory[module])
+ ),
+ "module": module,
+ "version": read_version(repository, module),
+ }
+ for module in modules
+ ]
+ return {
+ "archives": [
+ {"name": archive.name, "sha256": _sha256(archive)}
+ for archive in sorted(archives, key=lambda item: item.name)
+ ],
+ "modules": records,
+ }
+
+
+def package(
+ repository: Path,
+ architectures: Iterable[str],
+ output: Path,
+ *,
+ modules: str | Iterable[str] | None = None,
+ smoke_architectures: Iterable[str] | None = None,
+ tools: PackagingTools | None = None,
+ runner: Runner | None = None,
+ host: str | None = None,
+) -> PackageResult:
+ """Audit the stable Release binaries, then package and smoke exactly that build.
+
+ Each selected Release binary and its PDB are frozen into one operation staging tree first, so
+ the audit, the archive and the smoke all read the same bytes even if the build tree mutates
+ underneath. The selected archives are assembled in a fresh staging tree and only published
+ once every gate passes, so a failed rerun can never leave a manifest describing overwritten or
+ stale archives.
+
+ A selected-module release writes only that module's archives and manifest records. The
+ manifest stays in the ``core.release`` shape, but its module list is the selected set, so the
+ release gate must relax its complete-module-set requirement to the selected modules for an
+ independent ``renpy/vX.Y.Z`` style release.
+ """
+
+ repository = Path(repository).resolve(strict=True)
+ selected_architectures = _architectures(architectures)
+ selected_modules = _modules(modules)
+ selected_smoke = _smoke(selected_architectures, smoke_architectures)
+ root = _output_root(output)
+ tools = tools or locate_packaging_tools()
+ execute = runner or _default_runner
+
+ staging = _reset_directory(root / "staging", root)
+ try:
+ # Freeze the audited binaries once, then audit, archive and smoke only the frozen copies.
+ inputs = _directory(staging / "inputs", root)
+ snapshots: dict[str, Path] = {}
+ for architecture in selected_architectures:
+ binaries = _binaries(repository, architecture)
+ directory = _directory(inputs / architecture, root)
+ for module in selected_modules:
+ for source in (_artifact(binaries, module), _symbol(binaries, module)):
+ shutil.copyfile(source, _leaf(directory / source.name, root))
+ snapshots[architecture] = directory
+
+ _audit(repository, selected_architectures, root, modules=selected_modules,
+ include_leak_probe=False, tools=tools, execute=execute,
+ binaries_for=lambda architecture: snapshots[architecture])
+
+ pending = _reset_directory(staging / "publish", root)
+ archives: list[Path] = []
+ for architecture in selected_architectures:
+ target = _directory(pending / architecture, root)
+ snapshot = snapshots[architecture]
+ for module in selected_modules:
+ version = read_version(repository, module)
+ binary = snapshot / f"{module}.so"
+ symbol = snapshot / f"{module}.pdb"
+ stage = _reset_directory(staging / "stage" / architecture / module / "stage", root)
+ execute(
+ (*_python("core.package"), "stage-module", architecture, module, version,
+ str(binary), str(repository)),
+ cwd=BUILD_ROOT, env=_environment(stage),
+ )
+ archive = target / module_archive_name(module, version, architecture)
+ _leaf(archive, root)
+ execute(
+ (*_python("core.package"), "archive-module", architecture, module, version,
+ str(stage)),
+ cwd=BUILD_ROOT, env=_environment(target),
+ )
+ validation = _reset_directory(
+ staging / "stage" / architecture / module / "validation", root
+ )
+ execute(
+ (*_python("core.package"), "validate-module", architecture, module, version,
+ str(archive), str(stage)),
+ cwd=BUILD_ROOT, env=_environment(validation),
+ )
+ archives.append(archive)
+
+ symbol_stage = _reset_directory(
+ staging / "stage" / architecture / module / "symbol", root
+ )
+ execute(
+ (*_python("core.package"), "stage-symbol", architecture, module, str(symbol)),
+ cwd=BUILD_ROOT, env=_environment(symbol_stage),
+ )
+ symbol_archive = target / f"{module}-{version}-{architecture}-pdb.zip"
+ payload = _stage_payload(symbol_stage, "symbols", architecture, module)
+ _leaf(symbol_archive, root)
+ _zip(symbol_archive, payload)
+ _validate_zip(symbol_archive, payload)
+ archives.append(symbol_archive)
+ if len(selected_modules) == len(MODULES):
+ stages = tuple(
+ staging / "stage" / architecture / module / "symbol"
+ for module in selected_modules
+ )
+ combined = target / f"observer-modules-{architecture}-pdb.zip"
+ _leaf(combined, root)
+ execute(
+ (*_python("core.package"), "archive-symbols", architecture,
+ *(str(stage) for stage in stages)),
+ cwd=BUILD_ROOT, env=_environment(target),
+ )
+ symbols_validation = _reset_directory(staging / "symbols" / architecture, root)
+ execute(
+ (*_python("core.package"), "validate-symbols", architecture, str(combined),
+ *(str(stage) for stage in stages)),
+ cwd=BUILD_ROOT, env=_environment(symbols_validation),
+ )
+ archives.append(combined)
+
+ deferred: list[DeferredGate] = []
+ runnable = runnable_architectures(selected_smoke, host)
+ for architecture in selected_smoke:
+ if architecture not in runnable:
+ deferred.append(DeferredGate(
+ "package-runtime", architecture,
+ f"host cannot execute {architecture} package-runtime",
+ ))
+ continue
+ tests = _test_runner(_binaries(repository, architecture))
+ for module in selected_modules:
+ version = read_version(repository, module)
+ archive = pending / architecture / module_archive_name(module, version, architecture)
+ directory = _reset_directory(staging / "smoke" / architecture / module, root)
+ _leaf(directory / f"{module}.so", root)
+ execute(
+ (*_python("core.package"), "smoke", architecture, module, str(archive),
+ str(tests)),
+ cwd=BUILD_ROOT, env=_environment(directory),
+ )
+
+ inventory = {
+ module: _project_inventory(repository, module, tools, execute, staging)
+ for module in selected_modules
+ }
+ document = _manifest(repository, selected_modules, inventory, archives)
+ _leaf(pending / "packages.json", root)
+ _write_json(pending / "packages.json", document)
+
+ publish = _publish(pending, root / "packages", root)
+ published = tuple(sorted(
+ (publish / archive.relative_to(pending) for archive in archives),
+ key=lambda item: item.name,
+ ))
+ finally:
+ shutil.rmtree(staging, ignore_errors=True)
+ return PackageResult(published, tuple(deferred))
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..e4ee2cd
--- /dev/null
+++ b/build/projects/fuzz-renpy.vcxproj
@@ -0,0 +1,47 @@
+
+
+
+
+ 17.0
+ {086A7516-3492-4D13-8BD5-11123490C810}
+ Win32Proj
+ fuzz_renpy
+ 10.0
+ Application
+ renpy
+
+
+
+
+
+
+
+
+
+
+ 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..6dee938
--- /dev/null
+++ b/build/projects/fuzz-rpgmaker.vcxproj
@@ -0,0 +1,39 @@
+
+
+
+
+ 17.0
+ {DF60A28B-D6D4-4EF0-811B-4CC53ED93070}
+ Win32Proj
+ fuzz_rpgmaker
+ 10.0
+ Application
+ rpgmaker
+
+
+
+
+
+
+
+
+
+
+ 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..c644d9c
--- /dev/null
+++ b/build/projects/fuzz-zanzarah.vcxproj
@@ -0,0 +1,39 @@
+
+
+
+
+ 17.0
+ {F56AF8F0-E552-41DB-85E9-8131548828E6}
+ Win32Proj
+ fuzz_zanzarah
+ 10.0
+ Application
+ zanzarah
+
+
+
+
+
+
+
+
+
+
+ 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..8c20825
--- /dev/null
+++ b/build/projects/renpy.vcxproj
@@ -0,0 +1,50 @@
+
+
+
+
+ 17.0
+ {4E8AE1A2-12B2-4A87-9B28-62E3E7CDB510}
+ Win32Proj
+ renpy
+ 10.0
+ DynamicLibrary
+ renpy
+
+
+
+
+
+
+
+
+
+ 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..8343c5e
--- /dev/null
+++ b/build/projects/rpgmaker.vcxproj
@@ -0,0 +1,45 @@
+
+
+
+
+ 17.0
+ {7DF16D34-0324-4AC4-B99F-637D2A7E53E8}
+ Win32Proj
+ rpgmaker
+ 10.0
+ DynamicLibrary
+ rpgmaker
+
+
+
+
+
+
+
+
+
+ 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..4ec06b7
--- /dev/null
+++ b/build/projects/tests.vcxproj
@@ -0,0 +1,89 @@
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {4E8AE1A2-12B2-4A87-9B28-62E3E7CDB510}
+ false
+ false
+
+
+ {7DF16D34-0324-4AC4-B99F-637D2A7E53E8}
+ false
+ false
+
+
+ {B313D87B-B15E-4E0E-92F9-DA04231CC41A}
+ false
+ false
+
+
+
+
+
+
diff --git a/build/projects/zanzarah.vcxproj b/build/projects/zanzarah.vcxproj
new file mode 100644
index 0000000..e4fdf7d
--- /dev/null
+++ b/build/projects/zanzarah.vcxproj
@@ -0,0 +1,45 @@
+
+
+
+
+ 17.0
+ {B313D87B-B15E-4E0E-92F9-DA04231CC41A}
+ Win32Proj
+ zanzarah
+ 10.0
+ DynamicLibrary
+ zanzarah
+
+
+
+
+
+
+
+
+
+ zanzarah
+ .so
+
+
+
+ $(RepositoryRoot)src\modules\zanzarah\zanzarah.def
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/pyproject.toml b/build/pyproject.toml
new file mode 100644
index 0000000..713a945
--- /dev/null
+++ b/build/pyproject.toml
@@ -0,0 +1,27 @@
+[project]
+name = "observer-build"
+version = "0.1.0"
+description = "Direct MSBuild and validator build tooling for ObserverModules"
+requires-python = "==3.14.7"
+dependencies = [
+ "coverage==7.15.2",
+ "filelock==3.32.2",
+ "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 = ["pragma: no cover"]
+fail_under = 100
+include = ["core/*", "main.py", "native.py", "source_checks.py", "diagnostics.py",
+ "packaging_ops.py"]
+show_missing = true
+skip_covered = false
diff --git a/build/source_checks.py b/build/source_checks.py
new file mode 100644
index 0000000..a99709e
--- /dev/null
+++ b/build/source_checks.py
@@ -0,0 +1,682 @@
+"""Direct source-format/analyzer checks and compiler analysis for ObserverModules.
+
+These functions are the plain-command successors to ``graphs/source.py`` and
+``graphs/analysis.py``. They compose the repository's existing validators --
+clang-format, Cppcheck, PSScriptAnalyzer, the ``*.Tests.ps1`` contracts, MSVC
+``/analyze`` and clang-tidy -- and reuse the ``core.sarif`` command line for
+normalization and the warning/error gate. There is no graph, cache, scheduler
+or discovery compile: ``build/native.py`` restores the pinned vcpkg tree and
+MSBuild owns project evaluation and incremental state, while every report lands
+under one explicit output directory that the caller owns.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Sequence
+from dataclasses import dataclass, replace
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+
+import native
+from core.toolchain import discover_msvc_toolchain
+
+
+Runner = native.Runner
+
+_CPP_SUFFIXES = frozenset({".cpp", ".h", ".hpp"})
+_POWERSHELL_SUFFIXES = frozenset({".ps1", ".psm1"})
+# Cppcheck needs the platform word and the target architecture macro, exactly as the former
+# per-architecture graph nodes supplied them.
+_CPPCHECK = {
+ "x86": ("win32W", "_M_IX86=600"),
+ "x64": ("win64", "_M_X64=100"),
+ "arm64": ("win64", "_M_ARM64=1"),
+}
+# A fuzz project links the LLVM ASan/libFuzzer archives and has no ordinary entry point, so the
+# analyzer compiles its translation units with the compile-only target instead of rebuilding.
+_FUZZ_PREFIX = "fuzz-"
+_COMPILE_TARGET = "/t:ClCompile"
+_REBUILD_TARGET = "/t:Rebuild"
+# Transient build state, VCS metadata and the local virtualenv are never sources.
+_IGNORED_DIRECTORIES = frozenset({".git", ".venv", "__pycache__", "out"})
+# Raw Jinja templates are not PowerShell; the rendered scripts are the ``checks`` scripts.
+_PS_SCRIPT = Path(__file__).resolve().parent / "checks" / "psscriptanalyzer.ps1"
+
+# PSScriptAnalyzer severities map onto SARIF levels; any other severity is informational.
+_SARIF_LEVELS = {"Error": "error", "Warning": "warning"}
+
+_PSSA_QUERY = """
+$module = Get-Module -ListAvailable PSScriptAnalyzer |
+ Sort-Object Version -Descending |
+ Select-Object -First 1
+if (-not $module) { throw 'PSScriptAnalyzer was not found' }
+$module.Path
+"""
+
+
+@dataclass(frozen=True, slots=True)
+class CheckTools:
+ """The external tools the direct source checks invoke.
+
+ ``llvm_dir`` is the compiler-analysis LLVM installation and stays ``None`` for the
+ format/policy checks, which only need a standalone formatter and the native MSBuild locator.
+ """
+
+ pwsh: Path
+ clang_format: Path
+ cppcheck: Path
+ psscriptanalyzer: Path
+ native: native.NativeTools
+ llvm_dir: Path | None = None
+
+
+def _on_path(name: str) -> Path:
+ candidate = shutil.which(name)
+ if candidate is None:
+ raise FileNotFoundError(f"{name} was not found on PATH")
+ return Path(candidate).resolve(strict=True)
+
+
+# The Visual Studio C++ tools ship clang-format under ``VC\Tools\Llvm``; the x64 copy is the
+# host formatter and the sibling ``bin`` directory is the architecture-neutral one.
+_LLVM_FORMATTER_SUBDIRECTORIES = (Path("bin"), Path("x64") / "bin")
+
+
+def _visual_studio_clang_format(msbuild: Path) -> Path:
+ """Fall back to the clang-format that ships inside the Visual Studio C++ tools.
+
+ Only the formatter is required: clang-tidy and the wider LLVM/Clang toolchain stay optional
+ compiler-analysis prerequisites, so a compiler install without them still formats.
+ """
+
+ for parent in Path(msbuild).resolve().parents:
+ if parent.name.casefold() != "msbuild":
+ continue
+ llvm = parent.parent / "VC" / "Tools" / "Llvm"
+ for relative in _LLVM_FORMATTER_SUBDIRECTORIES:
+ formatter = llvm / relative / "clang-format.exe"
+ if formatter.is_file():
+ return formatter.resolve()
+ raise FileNotFoundError(
+ f"clang-format was not found on PATH or beside the MSBuild installation: {msbuild}"
+ )
+
+
+def _source_clang_format(native_tools: native.NativeTools) -> Path:
+ """Resolve the standalone formatter, preferring PATH but accepting the VS install."""
+
+ try:
+ return _on_path("clang-format")
+ except FileNotFoundError:
+ return _visual_studio_clang_format(native_tools.msbuild)
+
+
+def _psscriptanalyzer_path(pwsh: Path) -> Path:
+ completed = subprocess.run(
+ [str(pwsh), "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", _PSSA_QUERY],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ candidate = completed.stdout.strip()
+ if not candidate:
+ raise FileNotFoundError("PSScriptAnalyzer was not found")
+ return Path(candidate).resolve(strict=True)
+
+
+def locate_tools() -> CheckTools:
+ """Resolve the source-check tools without the compiler-analysis LLVM toolchain.
+
+ The format, PSScriptAnalyzer and contract checks only need a standalone formatter, Cppcheck
+ and the native MSBuild locator used to restore the Cppcheck third-party headers; the LLVM
+ ``clang-format``/``clang-tidy`` installation is a compiler-analysis prerequisite, not a
+ source one.
+ """
+
+ pwsh = _on_path("pwsh")
+ native_tools = native.locate_tools()
+ return CheckTools(
+ pwsh=pwsh,
+ clang_format=_source_clang_format(native_tools),
+ cppcheck=_on_path("cppcheck"),
+ psscriptanalyzer=_psscriptanalyzer_path(pwsh),
+ native=native_tools,
+ )
+
+
+def locate_analysis_tools() -> CheckTools:
+ """Add the compiler-analysis LLVM toolchain the MSVC/clang-tidy route requires."""
+
+ return replace(locate_tools(), llvm_dir=discover_msvc_toolchain().llvm_dir)
+
+
+def source_checks(
+ repository: Path,
+ architectures: Sequence[str],
+ output: Path,
+ *,
+ jobs: int = 4,
+ runner: Runner | None = None,
+ tools: CheckTools | None = None,
+) -> tuple[Path, ...]:
+ """Run clang-format, Cppcheck, PSScriptAnalyzer and the repository contracts.
+
+ Every check that reads the third-party headers runs after one vcpkg restore per architecture,
+ and the Cppcheck and PSScriptAnalyzer findings are normalized, merged and gated through the
+ shared ``core.sarif`` command line. The gated SARIF report under ``output`` is returned.
+
+ ``jobs`` is validated for interface symmetry with the build operations; the direct checks run
+ sequentially because this operation owns no scheduler.
+ """
+
+ repository = Path(repository).resolve()
+ selected = _architectures(architectures)
+ _positive_jobs(jobs)
+ tools = tools or locate_tools()
+ execute = runner or native.run_tool
+ output = Path(output).resolve()
+ output.mkdir(parents=True, exist_ok=True)
+
+ native.restore(repository, selected, tools=tools.native, runner=execute)
+
+ cpp_sources = _files(repository / "src", _CPP_SUFFIXES)
+ if cpp_sources:
+ execute(
+ [
+ str(tools.clang_format),
+ "--dry-run",
+ "--Werror",
+ *(str(path) for path in cpp_sources),
+ ],
+ repository,
+ None,
+ )
+
+ findings: list[Path] = []
+ for architecture in selected:
+ findings.append(_cppcheck(repository, tools, architecture, output, execute))
+
+ powershell_sources = _powershell_sources(repository)
+ if powershell_sources:
+ findings.append(
+ _psscriptanalyzer(repository, tools, powershell_sources, output, execute)
+ )
+
+ _contracts(repository, tools, execute)
+
+ merged = output / "analysis.sarif"
+ _sarif(
+ repository,
+ output,
+ execute,
+ ["merge", *(str(report) for report in findings), "--output-name", "analysis.sarif"],
+ )
+ _sarif(repository, output, execute, ["gate", str(merged)])
+ return (merged,)
+
+
+def compiler_analysis(
+ repository: Path,
+ architectures: Sequence[str],
+ output: Path,
+ *,
+ jobs: int = 2,
+ runner: Runner | None = None,
+ tools: CheckTools | None = None,
+) -> tuple[Path, ...]:
+ """Compile every production translation unit under MSVC ``/analyze`` and clang-tidy.
+
+ Each project is rebuilt into an isolated output variant so the analyzers never pollute the
+ ordinary incremental build. The raw MSVC SARIF and clang-tidy logs are normalized, merged
+ and gated per architecture through the shared ``core.sarif`` command line, and the gated
+ report of every architecture is returned.
+ """
+
+ repository = Path(repository).resolve()
+ selected = _architectures(architectures)
+ _positive_jobs(jobs)
+ tools = tools or locate_analysis_tools()
+ execute = runner or native.run_tool
+ output = Path(output).resolve()
+ output.mkdir(parents=True, exist_ok=True)
+
+ native.restore(repository, selected, tools=tools.native, runner=execute)
+
+ projects = _projects(repository)
+ reports: list[Path] = []
+ for architecture in selected:
+ normalized: list[Path] = []
+ for name, project in projects:
+ if name == "leak-probe" and architecture != "x64":
+ continue
+ configuration = "Release" if name == "leak-probe" else "Debug"
+ normalized.append(
+ _analyze_msvc(
+ repository, tools, architecture, configuration, name, project,
+ output, jobs, execute,
+ )
+ )
+ normalized.append(
+ _analyze_tidy(
+ repository, tools, architecture, configuration, name, project,
+ output, jobs, execute,
+ )
+ )
+ merged = output / architecture / "analysis.sarif"
+ _sarif(
+ repository,
+ output,
+ execute,
+ [
+ "merge",
+ *(str(report) for report in normalized),
+ "--output-name",
+ f"{architecture}/analysis.sarif",
+ ],
+ )
+ _sarif(repository, output, execute, ["gate", str(merged)])
+ reports.append(merged)
+ return tuple(reports)
+
+
+def _architectures(architectures: Iterable[str]) -> tuple[str, ...]:
+ selected = tuple(architectures)
+ if not selected:
+ raise ValueError("at least one architecture is required")
+ if len(set(selected)) != len(selected):
+ raise ValueError("source architectures must be unique")
+ for architecture in selected:
+ if architecture not in native.PLATFORMS:
+ raise ValueError(f"unsupported architecture: {architecture}")
+ return selected
+
+
+def _positive_jobs(jobs: object) -> None:
+ if isinstance(jobs, bool) or not isinstance(jobs, int) or jobs < 1:
+ raise ValueError("jobs must be a positive integer")
+
+
+def _files(
+ root: Path, suffixes: frozenset[str], *, excluded: tuple[Path, ...] = ()
+) -> tuple[Path, ...]:
+ if not root.is_dir():
+ return ()
+ found = []
+ for directory, directories, names in root.walk():
+ directories[:] = sorted(set(directories) - _IGNORED_DIRECTORIES)
+ for name in names:
+ path = directory / name
+ if path.suffix in suffixes and not any(
+ path.is_relative_to(item) for item in excluded
+ ):
+ found.append(path)
+ return tuple(sorted(found, key=str))
+
+
+def _powershell_sources(repository: Path) -> tuple[Path, ...]:
+ return _files(
+ repository,
+ _POWERSHELL_SUFFIXES,
+ excluded=(repository / "build" / "templates",),
+ )
+
+
+def _projects(repository: Path) -> tuple[tuple[str, Path], ...]:
+ return tuple(
+ (project.stem, project)
+ for project in sorted((repository / "build" / "projects").glob("*.vcxproj"))
+ )
+
+
+def _cppcheck(
+ repository: Path, tools: CheckTools, architecture: str, output: Path, execute: Runner
+) -> Path:
+ platform, define = _CPPCHECK[architecture]
+ triplet = native.triplet(architecture)
+ include_dir = native.restore_root(repository, architecture) / triplet / "include"
+ report = output / f"cppcheck-{architecture}.sarif"
+ # The installed Cppcheck CLI refuses to run unless its build directory already exists.
+ build_directory = output / f"cppcheck-{architecture}-build"
+ build_directory.mkdir(parents=True, exist_ok=True)
+ execute(
+ [
+ str(tools.cppcheck),
+ str(repository / "src"),
+ "--std=c++23",
+ f"--platform={platform}",
+ "-DWIN32=1",
+ "-D_WIN32=1",
+ "-DUNICODE=1",
+ "-D_UNICODE=1",
+ f"-D{define}",
+ f"-I{repository / 'src'}",
+ f"-I{include_dir}",
+ "--enable=warning,style,performance,portability",
+ "--check-level=exhaustive",
+ "--inconclusive",
+ "--inline-suppr",
+ "--suppress=missingIncludeSystem",
+ "--suppress=uninitMemberVarNoCtor:src/api.h",
+ f"--suppress=*:*\\{triplet}\\include\\*",
+ "--suppress=functionStatic",
+ f"--relative-paths={repository}",
+ "--output-format=sarif",
+ f"--output-file={report}",
+ f"--cppcheck-build-dir={build_directory}",
+ ],
+ repository,
+ None,
+ )
+ if not report.is_file():
+ raise FileNotFoundError(f"Cppcheck did not produce {report}")
+ _stamp_cppcheck(report, f"cppcheck/{architecture}/")
+ return report
+
+
+def _stamp_cppcheck(report: Path, automation_id: str) -> None:
+ """Give every Cppcheck SARIF run the automation identity the merge requires."""
+
+ document = json.loads(report.read_text(encoding="utf-8-sig"))
+ runs = document.get("runs")
+ if not isinstance(runs, list):
+ raise ValueError(f"Cppcheck SARIF report has no runs: {report}")
+ for index, run in enumerate(runs):
+ run.setdefault("automationDetails", {})["id"] = f"{automation_id}{index}/"
+ report.write_text(
+ json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+
+
+def _psscriptanalyzer(
+ repository: Path,
+ tools: CheckTools,
+ sources: tuple[Path, ...],
+ output: Path,
+ execute: Runner,
+) -> Path:
+ """Run the branchless PowerShell collector, then convert its findings to SARIF in Python.
+
+ The script only gathers raw ``Invoke-ScriptAnalyzer`` records so the severity mapping and the
+ SARIF assembly stay in covered, independently testable Python.
+ """
+
+ raw = output / "psscriptanalyzer-findings.json"
+ execute(
+ [
+ str(tools.pwsh),
+ "-NoLogo",
+ "-NoProfile",
+ "-NonInteractive",
+ "-File",
+ str(_PS_SCRIPT),
+ "-Module",
+ str(tools.psscriptanalyzer),
+ "-Settings",
+ str(repository / "build" / "PSScriptAnalyzerSettings.psd1"),
+ "-Output",
+ str(raw),
+ *(str(path) for path in sources),
+ ],
+ repository,
+ None,
+ )
+ report = output / "psscriptanalyzer.sarif"
+ _write_sarif(report, _pssa_runs(repository, sources, _read_findings(raw)))
+ return report
+
+
+def _read_findings(path: Path) -> list[dict[str, object]]:
+ document = json.loads(path.read_text(encoding="utf-8-sig"))
+ if not isinstance(document, list):
+ raise ValueError(f"PSScriptAnalyzer findings must be a list: {path}")
+ return document
+
+
+def _uri(repository: Path, path: Path) -> str:
+ try:
+ return path.resolve().relative_to(repository).as_posix()
+ except ValueError as error:
+ raise ValueError(f"PSScriptAnalyzer path is outside the repository: {path}") from error
+
+
+def _severity(severity: str) -> str:
+ return _SARIF_LEVELS.get(severity, "note")
+
+
+def _pssa_result(finding: dict[str, object], uri: str) -> dict[str, object]:
+ return {
+ "ruleId": str(finding["RuleName"]),
+ "level": _severity(str(finding["Severity"])),
+ "message": {"text": str(finding["Message"])},
+ "locations": [
+ {
+ "physicalLocation": {
+ "artifactLocation": {"uri": uri},
+ "region": {
+ "startLine": int(finding["Line"]),
+ "startColumn": int(finding["Column"]),
+ },
+ }
+ }
+ ],
+ }
+
+
+def _pssa_runs(
+ repository: Path, sources: tuple[Path, ...], findings: Iterable[dict[str, object]]
+) -> list[dict[str, object]]:
+ """Group findings per source, preserving the scanned order and keeping empty runs."""
+
+ grouped: dict[str, list[dict[str, object]]] = {
+ _uri(repository, source): [] for source in sources
+ }
+ for finding in findings:
+ uri = _uri(repository, Path(str(finding["ScriptPath"])))
+ grouped.setdefault(uri, []).append(_pssa_result(finding, uri))
+ return [
+ {
+ "automationDetails": {"id": f"psscriptanalyzer/{uri}/"},
+ "tool": {"driver": {"name": "PSScriptAnalyzer"}},
+ "results": results,
+ }
+ for uri, results in grouped.items()
+ ]
+
+
+def _write_sarif(path: Path, runs: list[dict[str, object]]) -> None:
+ document = {
+ "version": "2.1.0",
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+ "runs": runs,
+ }
+ path.write_text(
+ json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+
+
+def _contracts(repository: Path, tools: CheckTools, execute: Runner) -> None:
+ for test in sorted((repository / "build" / "tests").glob("*.Tests.ps1")):
+ execute(
+ [
+ str(tools.pwsh),
+ "-NoLogo",
+ "-NoProfile",
+ "-NonInteractive",
+ "-File",
+ str(test),
+ ],
+ repository,
+ None,
+ )
+
+
+def _analyze_msvc(
+ repository: Path,
+ tools: CheckTools,
+ architecture: str,
+ configuration: str,
+ name: str,
+ project: Path,
+ output: Path,
+ jobs: int,
+ execute: Runner,
+) -> Path:
+ variant = output / architecture / "raw" / "msvc" / name
+ report = variant / f"{name}.sarif"
+ execute(
+ _analysis_command(
+ repository, tools, architecture, configuration, project, variant, jobs
+ )
+ + [
+ "/p:EnableMicrosoftCodeAnalysis=true",
+ "/p:ObserverEnableClangTidy=false",
+ f"/p:ObserverAnalysisReportName={name}",
+ f"/p:ObserverAnalysisReportPath={report}",
+ ],
+ repository,
+ None,
+ )
+ if not report.is_file():
+ raise FileNotFoundError(f"MSVC analysis did not produce {report}")
+ return _normalize_msvc(
+ repository, report, f"msvc-analyze/{architecture}/{name}/",
+ f"{architecture}/msvc/{name}.sarif", output, execute,
+ )
+
+
+def _analyze_tidy(
+ repository: Path,
+ tools: CheckTools,
+ architecture: str,
+ configuration: str,
+ name: str,
+ project: Path,
+ output: Path,
+ jobs: int,
+ execute: Runner,
+) -> Path:
+ variant = output / architecture / "raw" / "tidy" / name
+ log = variant / "obj" / f"{name}.ClangTidy.log"
+ execute(
+ _analysis_command(
+ repository, tools, architecture, configuration, project, variant, jobs
+ )
+ + [
+ "/p:EnableMicrosoftCodeAnalysis=false",
+ "/p:ObserverEnableClangTidy=true",
+ f"/p:LLVMInstallDir={tools.llvm_dir}",
+ f"/p:ClangTidyLogFile={name}.ClangTidy.log",
+ ],
+ repository,
+ None,
+ )
+ if not log.is_file():
+ raise FileNotFoundError(f"clang-tidy did not produce {log}")
+ return _convert_tidy(
+ repository, log.parent, f"clang-tidy/{architecture}/{name}/",
+ f"{architecture}/tidy/{name}.sarif", output, execute,
+ )
+
+
+def _analysis_command(
+ repository: Path,
+ tools: CheckTools,
+ architecture: str,
+ configuration: str,
+ project: Path,
+ variant: Path,
+ jobs: int,
+) -> list[str]:
+ """One project compiled for the analyzer into its own isolated output variant.
+
+ Ordinary projects are rebuilt so the analysis never pollutes the incremental build. A fuzz
+ project is not linked because its harness has no ordinary entry point and links the LLVM ASan
+ runtime, so every translation unit is compiled with the compile-only target instead.
+ """
+
+ fuzz_project = project.stem.startswith(_FUZZ_PREFIX)
+ command = [
+ str(tools.native.msbuild),
+ str(project),
+ "/nologo",
+ f"/m:{jobs}",
+ "/nr:false",
+ _COMPILE_TARGET if fuzz_project else _REBUILD_TARGET,
+ "/p:BuildProjectReferences=false",
+ f"/p:Configuration={configuration}",
+ f"/p:Platform={native.PLATFORMS[architecture]}",
+ f"/p:OutDir={variant}{os.sep}",
+ f"/p:IntDir={variant / 'obj'}{os.sep}",
+ "/p:ObserverCompileAnalysis=true",
+ f"/p:VcpkgRoot={tools.native.vcpkg_root}",
+ f"/p:VcpkgInstalledDir={native.restore_root(repository, architecture)}{os.sep}",
+ "/p:ObserverRunCodeAnalysis=true",
+ "/p:RunCodeAnalysis=true",
+ ]
+ if fuzz_project:
+ # A compile-only target still honours incremental tracking, so a stable output directory
+ # could silently reuse an earlier analysis. ForceRebuild recompiles every translation
+ # unit for the fuzz harnesses; ordinary projects already rebuild explicitly.
+ command.append("/p:ForceRebuild=true")
+ return command
+
+
+def _normalize_msvc(
+ repository: Path,
+ report: Path,
+ automation_id: str,
+ output_name: str,
+ output: Path,
+ execute: Runner,
+) -> Path:
+ _sarif(
+ repository,
+ output,
+ execute,
+ ["normalize-msvc", str(report), automation_id, "--output-name", output_name],
+ )
+ return output / output_name
+
+
+def _convert_tidy(
+ repository: Path,
+ log_root: Path,
+ automation_id: str,
+ output_name: str,
+ output: Path,
+ execute: Runner,
+) -> Path:
+ _sarif(
+ repository,
+ output,
+ execute,
+ [
+ "convert-tidy",
+ str(repository),
+ str(log_root),
+ automation_id,
+ "--output-name",
+ output_name,
+ ],
+ )
+ return output / output_name
+
+
+def _sarif(
+ repository: Path, output: Path, execute: Runner, arguments: Sequence[str]
+) -> None:
+ environment = {
+ "OBSERVER_OUT_DIR": str(output),
+ "OBSERVER_BUILD_DIR": str(output / "work"),
+ }
+ execute(
+ [sys.executable, "-m", "core.sarif", *arguments],
+ repository / "build",
+ environment,
+ )
diff --git a/build/tests/build-entry-point-contract.Tests.ps1 b/build/tests/build-entry-point-contract.Tests.ps1
new file mode 100644
index 0000000..0c2de32
--- /dev/null
+++ b/build/tests/build-entry-point-contract.Tests.ps1
@@ -0,0 +1,131 @@
+#requires -Version 7.4
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+# PowerShell parses an unquoted `x86,x64,arm64` in argument mode as an array literal, so an entry
+# point that forwards `@args` verbatim splats it into three separate driver arguments and every
+# documented multi-value example fails with `unrecognized arguments`. These cases pin the forwarding
+# contract of the root launcher: the driver must receive exactly one argument per written argument.
+
+$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
+$entryPoint = Join-Path $repositoryRoot 'build.ps1'
+$driverScript = Join-Path (Join-Path $repositoryRoot 'build') 'main.py'
+$powershell = [System.Environment]::ProcessPath
+$invocation = $entryPoint.Replace("'", "''")
+
+# The seam refuses to report a successful build, so a leaked variable cannot pass as one.
+$argvSeamExitCode = 97
+
+function Get-ForwardedArgv {
+ param(
+ [Parameter(Mandatory)][string[]] $ProcessArgument,
+ [Parameter(Mandatory)][string] $Description
+ )
+
+ $previous = $env:OBSERVER_BUILD_PRINT_ARGV
+ try {
+ $env:OBSERVER_BUILD_PRINT_ARGV = '1'
+ $output = & $powershell -NoLogo -NoProfile -NonInteractive @ProcessArgument
+ }
+ finally {
+ $env:OBSERVER_BUILD_PRINT_ARGV = $previous
+ }
+
+ if ($LASTEXITCODE -ne $argvSeamExitCode) {
+ throw ("The entry point must refuse to build and exit ${argvSeamExitCode} while reporting the driver " +
+ "argv, but exited ${LASTEXITCODE} for: $Description")
+ }
+ $reported = (@($output) -join "`n").Trim()
+ if (-not $reported.StartsWith('[', [StringComparison]::Ordinal)) {
+ throw ("build.ps1 must report the driver argv as JSON when OBSERVER_BUILD_PRINT_ARGV is '1' " +
+ "for '$Description'; reported: $reported")
+ }
+ $argv = @($reported | ConvertFrom-Json)
+ if ($argv.Count -eq 0 -or $argv[0] -ne $driverScript) {
+ throw "The entry point must forward the pinned driver script for: $Description"
+ }
+ return @($argv | Select-Object -Skip 1)
+}
+
+function Assert-ForwardedArgv {
+ param(
+ [Parameter(Mandatory)][string[]] $ProcessArgument,
+ [Parameter(Mandatory)][string] $Description,
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Expected
+ )
+
+ $actual = @(Get-ForwardedArgv -ProcessArgument $ProcessArgument -Description $Description)
+ $separator = [char]31
+ if ([string]::Join($separator, $actual) -ne [string]::Join($separator, $Expected)) {
+ throw ("'$Description' must reach the driver as [$([string]::Join('] [', $Expected))] " +
+ "but reached it as [$([string]::Join('] [', $actual))].")
+ }
+}
+
+function Assert-CommandLine {
+ param(
+ [Parameter(Mandatory)][string] $CommandLine,
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Expected
+ )
+
+ # -Command reproduces the parsing a console or CI step performs; -File would flatten every
+ # argument to a string and hide the array-literal defect this contract exists for. -Command
+ # reports only success or failure by itself, so the launcher's own code is republished.
+ Assert-ForwardedArgv -ProcessArgument @('-Command', "& '$invocation' $CommandLine; exit `$LASTEXITCODE") `
+ -Description $CommandLine -Expected $Expected
+}
+
+# An empty command line reaches the driver as no arguments at all, which is how the launcher
+# forwards the documented help path. Assert-CommandLine cannot express it: its mandatory string
+# parameters reject the empty string.
+Assert-ForwardedArgv -ProcessArgument @('-Command', "& '$invocation'; exit `$LASTEXITCODE") `
+ -Description 'no arguments' -Expected @()
+
+Assert-CommandLine -CommandLine 'help' -Expected @('help')
+
+Assert-CommandLine -CommandLine 'doctor' -Expected @('doctor')
+
+Assert-CommandLine -CommandLine 'build -Arch x86,x64,arm64 -Config Release' `
+ -Expected @('build', '-Arch', 'x86,x64,arm64', '-Config', 'Release')
+
+Assert-CommandLine -CommandLine "build -Arch 'x86,x64,arm64' -Config Release" `
+ -Expected @('build', '-Arch', 'x86,x64,arm64', '-Config', 'Release')
+
+Assert-CommandLine -CommandLine 'test -Arch x86,x64 -Config Debug' `
+ -Expected @('test', '-Arch', 'x86,x64', '-Config', 'Debug')
+
+Assert-CommandLine -CommandLine 'test -Arch x64 -Config Debug' `
+ -Expected @('test', '-Arch', 'x64', '-Config', 'Debug')
+
+# Generality: every multi-value option, not only -Arch, survives the unquoted comma form.
+Assert-CommandLine -CommandLine 'restore -Arch x86,x64 -RestoreFlavor default,asan' `
+ -Expected @('restore', '-Arch', 'x86,x64', '-RestoreFlavor', 'default,asan')
+
+Assert-CommandLine -CommandLine 'build -Arch all -Config Debug,Release' `
+ -Expected @('build', '-Arch', 'all', '-Config', 'Debug,Release')
+
+Assert-CommandLine -CommandLine 'fuzz -Arch x64 -FuzzTarget pickle,renpy,rpgmaker,zanzarah -FuzzSeconds 60' `
+ -Expected @('fuzz', '-Arch', 'x64', '-FuzzTarget', 'pickle,renpy,rpgmaker,zanzarah', '-FuzzSeconds', '60')
+
+Assert-CommandLine -CommandLine 'fuzz -Arch x86,x64 -FuzzTarget all -FuzzSeconds 60' `
+ -Expected @('fuzz', '-Arch', 'x86,x64', '-FuzzTarget', 'all', '-FuzzSeconds', '60')
+
+Assert-CommandLine -CommandLine 'clean -CleanMode stale-work' -Expected @('clean', '-CleanMode', 'stale-work')
+
+# The complete CI command line, including a switch, integers, and a quoted path.
+Assert-CommandLine -CommandLine ("verify-arch -Arch x64 -ExportDir 'C:\evidence dir\x64' " +
+ '-FuzzSeconds 5 -LeakWarmup 1 -LeakIterations 1 -LeakWindows 3 -LeakToleranceBytes 0 ' +
+ '-PruneCas -Priority normal -Jobs 8 -Corpus C:\corpus') `
+ -Expected @('verify-arch', '-Arch', 'x64', '-ExportDir', 'C:\evidence dir\x64',
+ '-FuzzSeconds', '5', '-LeakWarmup', '1', '-LeakIterations', '1', '-LeakWindows', '3',
+ '-LeakToleranceBytes', '0', '-PruneCas', '-Priority', 'normal', '-Jobs', '8',
+ '-Corpus', 'C:\corpus')
+
+# build.cmd routes through `pwsh -File`, where every argument already arrives as one string.
+Assert-ForwardedArgv -ProcessArgument (@('-File', $entryPoint) +
+ @('build', '-Arch', 'x86,x64,arm64', '-Config', 'Release')) `
+ -Description 'build.cmd shim' `
+ -Expected @('build', '-Arch', 'x86,x64,arm64', '-Config', 'Release')
+
+Write-Host '[OK] The root entry point forwards every documented argument form unchanged.'
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_binary_audit.py b/build/tests/test_binary_audit.py
new file mode 100644
index 0000000..95b7ffd
--- /dev/null
+++ b/build/tests/test_binary_audit.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import sys
+import tempfile
+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 ( # noqa: E402
+ AuditError,
+ main as binary_audit_main,
+ require_clean_binskim,
+ require_release_pe,
+)
+
+
+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)
+
+ with self.assertRaisesRegex(AuditError, "unsupported.*riscv64") as raised:
+ require_release_pe("riscv64", "", "", "")
+ self.assertIsInstance(raised.exception.__cause__, KeyError)
+
+ def test_release_pe_executable_accepts_a_self_contained_exe_without_exports(self) -> None:
+ # The actual leak-probe.exe is an x64 /MT EXE whose dumpbin export table is empty.
+ require_release_pe(
+ "x64",
+ " 8664 machine (x64)\n",
+ " KERNEL32.dll\n",
+ " Summary\n\n 4A000 .text\n",
+ executable=True,
+ )
+
+ def test_release_pe_executable_still_rejects_any_export(self) -> None:
+ for exports in (
+ " 1 0 0001 LoadSubModule\n 2 1 0002 UnloadSubModule\n",
+ " 1 0 0001 Renpy_ExtraExport\n",
+ ):
+ with self.subTest(exports=exports), self.assertRaisesRegex(AuditError, "unexpected exports"):
+ require_release_pe(
+ "x64", " 8664 machine (x64)\n", " KERNEL32.dll\n", exports,
+ executable=True,
+ )
+
+ def test_release_pe_default_module_contract_still_requires_exports(self) -> None:
+ with self.assertRaisesRegex(AuditError, "unexpected exports"):
+ require_release_pe("x64", " 8664 machine (x64)\n", " KERNEL32.dll\n", "")
+
+ def test_cli_pe_executable_flag_selects_the_executable_kind(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ headers = root / "headers.txt"
+ dependents = root / "dependents.txt"
+ exports = 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(" Summary\n\n 4A000 .text\n", encoding="utf-8")
+ self.assertEqual(
+ 0,
+ binary_audit_main((
+ "pe", "--executable", "x64",
+ str(headers), str(dependents), str(exports),
+ )),
+ )
+ with self.assertRaisesRegex(AuditError, "unexpected exports"):
+ binary_audit_main(("pe", "x64", str(headers), str(dependents), str(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_binary_audit_cli.py b/build/tests/test_binary_audit_cli.py
new file mode 100644
index 0000000..a14948d
--- /dev/null
+++ b/build/tests/test_binary_audit_cli.py
@@ -0,0 +1,72 @@
+"""Standalone worker tests for the ``core.binary_audit`` CLI dispatch.
+
+These assertions were preserved from ``test_audit_graph.py`` when the retired
+``graphs.audit`` composition was dropped. They drive the real ``pe`` and
+``binskim`` CLI paths, so the literal and dispatched-run behaviour stays covered.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from core.binary_audit import AuditError, main as binary_audit_main # noqa: E402
+
+class BinaryAuditCliTests(unittest.TestCase):
+ 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)))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_changelog.py b/build/tests/test_changelog.py
new file mode 100644
index 0000000..1ca3eb9
--- /dev/null
+++ b/build/tests/test_changelog.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+REPOSITORY = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.changelog import ChangelogError, changelog_path, latest_notes # noqa: E402
+from core.module_version import MODULES # noqa: E402
+
+
+def write_bytes(root: Path, module: str, data: bytes) -> Path:
+ path = root / "src" / "modules" / module / "ChangeLog"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(data)
+ return path
+
+
+def write_text(root: Path, module: str, text: str) -> Path:
+ return write_bytes(root, module, text.encode("utf-8"))
+
+
+def entry(version: str, *bullets: str) -> str:
+ lines = [f"Version {version}", "-" * (len(version) + len("Version ") + 3)]
+ lines.extend(f" * {bullet}" for bullet in bullets)
+ return "\n".join(lines) + "\n"
+
+
+class LatestNotesTests(unittest.TestCase):
+ def test_the_latest_entry_body_becomes_markdown_bullets(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ write_text(
+ root,
+ "renpy",
+ "Version 3.1.0\n"
+ "-------------\n"
+ " * Support the RPA-2.0 and RPA-3.0 archive versions.\n"
+ " + Unpack entries with extra headers without junk bytes at the end of the file.\n",
+ )
+
+ self.assertEqual(
+ latest_notes(root, "renpy", "3.1.0"),
+ "- Support the RPA-2.0 and RPA-3.0 archive versions.\n"
+ "- Unpack entries with extra headers without junk bytes at the end of the file.",
+ )
+
+ def test_only_the_top_entry_is_rendered_and_older_sections_never_leak(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ write_text(
+ root,
+ "renpy",
+ "Version 3.1.0\n"
+ "-------------\n"
+ " * new behaviour\n"
+ "\n"
+ "Version 3.0.0\n"
+ "-------------\n"
+ " * old behaviour\n",
+ )
+
+ self.assertEqual(latest_notes(root, "renpy", "3.1.0"), "- new behaviour")
+
+ def test_blank_lines_inside_the_body_are_ignored(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ write_text(
+ root,
+ "zanzarah",
+ "Version 2.1.0\n"
+ "-------------\n"
+ "\n"
+ " * one\n"
+ "\n"
+ " + two\n"
+ "\n",
+ )
+
+ self.assertEqual(latest_notes(root, "zanzarah", "2.1.0"), "- one\n- two")
+
+ def test_a_bom_and_crlf_line_endings_are_tolerated(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ write_bytes(
+ root,
+ "rpgmaker",
+ "\ufeffVersion 1.1.0\r\n-----\r\n * first\r\n + second\r\n".encode("utf-8"),
+ )
+
+ self.assertEqual(latest_notes(root, "rpgmaker", "1.1.0"), "- first\n- second")
+
+ def test_the_path_helper_resolves_known_modules_only(self) -> None:
+ self.assertEqual(changelog_path("renpy"), "src/modules/renpy/ChangeLog")
+ for module in MODULES:
+ self.assertEqual(changelog_path(module), f"src/modules/{module}/ChangeLog")
+ with self.assertRaisesRegex(ChangelogError, "unknown module"):
+ changelog_path("pickle")
+
+ def test_invalid_inputs_fail_with_a_change_log_error(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ write_text(root, "renpy", entry("3.1.0", "note"))
+
+ cases = (
+ (lambda: latest_notes(root, "pickle", "1.0.0"), "unknown module"),
+ (lambda: latest_notes(root, "renpy", "3.1"), "canonical"),
+ (lambda: latest_notes(root, "rpgmaker", "1.1.0"), "missing or unreadable"),
+ (lambda: latest_notes(root, "renpy", "3.0.0"), "does not match"),
+ )
+ for index, (call, message) in enumerate(cases):
+ with self.subTest(index=index), self.assertRaisesRegex(ChangelogError, message):
+ call()
+
+ def test_malformed_headers_underlines_and_bodies_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ documents = (
+ ("", "3.1.0", "header"),
+ ("ChangeLog for renpy\n * note\n", "3.1.0", "header"),
+ ("Version 3.1\n-----\n * note\n", "3.1.0", "canonical"),
+ ("Version 3.1.0", "3.1.0", "underlined"),
+ ("Version 3.1.0\n * note\n", "3.1.0", "underlined"),
+ ("Version 3.1.0\n-----\nplain prose\n", "3.1.0", "not a bullet"),
+ ("Version 3.1.0\n-----\n", "3.1.0", "no notes"),
+ (
+ "Version 3.1.0\n-----\nVersion 3.0.0\n-----\n * old\n",
+ "3.1.0",
+ "no notes",
+ ),
+ )
+ for index, (text, version, message) in enumerate(documents):
+ write_text(root, "renpy", text)
+ with self.subTest(index=index), self.assertRaisesRegex(ChangelogError, message):
+ latest_notes(root, "renpy", version)
+
+ def test_non_utf8_bytes_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ write_bytes(root, "renpy", "Version 3.1.0\n-----\n * caf\xe9\n".encode("latin-1"))
+
+ with self.assertRaisesRegex(ChangelogError, "UTF-8"):
+ latest_notes(root, "renpy", "3.1.0")
+
+
+class RepositoryChangeLogTests(unittest.TestCase):
+ def test_every_shipped_module_change_log_matches_its_version(self) -> None:
+ for module in MODULES:
+ version = (
+ REPOSITORY / "src" / "modules" / module / "VERSION"
+ ).read_text(encoding="utf-8").strip()
+ with self.subTest(module=module):
+ notes = latest_notes(REPOSITORY, module, version)
+ lines = notes.splitlines()
+ self.assertTrue(lines)
+ self.assertTrue(all(line.startswith("- ") for line in lines))
+
+
+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..58093b3
--- /dev/null
+++ b/build/tests/test_clean.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+from contextlib import redirect_stdout
+from io import StringIO
+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))
+
+import core.clean as core_clean # noqa: E402
+from core.clean import CleanError, clean, main, prepare_fresh # noqa: E402
+
+
+class CleanTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._temporary = tempfile.TemporaryDirectory()
+ self.repository = Path(self._temporary.name)
+ self.addCleanup(self._temporary.cleanup)
+
+ def output(self, *names: str) -> dict[str, Path]:
+ out = self.repository / "out"
+ out.mkdir(exist_ok=True)
+ created = {}
+ for name in names:
+ target = out / name
+ target.mkdir()
+ (target / "artifact.txt").write_text(name, encoding="utf-8")
+ created[name] = target
+ return created
+
+ def test_all_removes_every_owned_directory_and_preserves_unrelated_output(self) -> None:
+ created = self.output("native", "reports", "packages", "sol-team")
+ (self.repository / "out" / "notes.txt").write_text("keep", encoding="utf-8")
+
+ removed = clean(self.repository, "all")
+
+ self.assertEqual(removed, (created["native"], created["reports"], created["packages"]))
+ self.assertTrue((self.repository / "out" / "sol-team" / "artifact.txt").is_file())
+ self.assertTrue((self.repository / "out" / "notes.txt").is_file())
+
+ def test_reports_mode_keeps_the_native_tree(self) -> None:
+ created = self.output("native", "reports", "packages")
+
+ removed = clean(self.repository, "reports")
+
+ self.assertEqual(removed, (created["reports"], created["packages"]))
+ self.assertTrue((created["native"] / "artifact.txt").is_file())
+
+ def test_an_unsupported_mode_and_missing_repository_are_refused(self) -> None:
+ with self.assertRaisesRegex(ValueError, "unsupported clean mode"):
+ clean(self.repository, "everything")
+ with self.assertRaises(FileNotFoundError):
+ clean(self.repository / "absent", "all")
+
+ def test_a_repository_without_output_is_a_no_op(self) -> None:
+ self.assertEqual(clean(self.repository, "all"), ())
+ (self.repository / "out").write_text("not a directory", encoding="utf-8")
+ self.assertEqual(clean(self.repository, "all"), ())
+ self.assertFalse(core_clean._is_reparse(self.repository / "out" / "absent"))
+
+ def test_a_reparse_output_root_is_refused(self) -> None:
+ out = self.repository / "out"
+ out.mkdir()
+ with (
+ mock.patch.object(core_clean, "_is_reparse", return_value=True),
+ self.assertRaisesRegex(CleanError, "reparse point is forbidden"),
+ ):
+ clean(self.repository, "all")
+
+ def test_a_reparse_anywhere_below_an_owned_tree_is_refused(self) -> None:
+ created = self.output("native")
+ nested = created["native"] / "artifact.txt"
+ with (
+ mock.patch.object(core_clean, "_is_reparse", side_effect=lambda path: Path(path) == nested),
+ self.assertRaisesRegex(CleanError, "reparse point is forbidden"),
+ ):
+ clean(self.repository, "all")
+ self.assertTrue(nested.is_file())
+
+ def test_a_reparse_owned_directory_itself_is_refused(self) -> None:
+ created = self.output("native")
+ with (
+ mock.patch.object(
+ core_clean, "_is_reparse", side_effect=lambda path: Path(path) == created["native"]
+ ),
+ self.assertRaisesRegex(CleanError, "reparse point is forbidden"),
+ ):
+ clean(self.repository, "all")
+ self.assertTrue((created["native"] / "artifact.txt").is_file())
+
+ def test_a_non_directory_owned_entry_is_unlinked(self) -> None:
+ out = self.repository / "out"
+ out.mkdir()
+ entry = out / "native"
+ entry.write_text("stale", encoding="utf-8")
+
+ removed = clean(self.repository, "all")
+
+ self.assertEqual(removed, (entry,))
+ self.assertFalse(os.path.lexists(entry))
+
+ def test_main_prints_every_removed_directory(self) -> None:
+ created = self.output("reports")
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ self.assertEqual(main((str(self.repository), "--mode", "reports")), 0)
+ self.assertEqual(stdout.getvalue().splitlines(), [str(created["reports"])])
+
+ def test_prepare_fresh_rebuilds_an_empty_tree_under_the_boundary(self) -> None:
+ stale = self.repository / "out" / "reports" / "verify" / "old.txt"
+ stale.parent.mkdir(parents=True)
+ stale.write_text("stale", encoding="utf-8")
+ target = prepare_fresh(self.repository / "out" / "reports" / "verify", self.repository)
+ self.assertEqual(list(target.iterdir()), [])
+ self.assertTrue(target.is_dir())
+
+ def test_prepare_fresh_refuses_an_escape_from_the_boundary(self) -> None:
+ outside = self.repository.parent / "outside-output"
+ with self.assertRaisesRegex(CleanError, "escapes its owned root"):
+ prepare_fresh(outside, self.repository)
+
+ def test_prepare_fresh_refuses_a_reparse_target(self) -> None:
+ target = self.repository / "out" / "reports" / "verify"
+ with (
+ mock.patch.object(core_clean, "_is_reparse", side_effect=lambda path: Path(path) == target),
+ self.assertRaisesRegex(CleanError, "reparse point is forbidden"),
+ ):
+ prepare_fresh(target, self.repository)
+
+ def test_prepare_fresh_refuses_a_reparse_ancestor(self) -> None:
+ ancestor = self.repository / "out"
+ with (
+ mock.patch.object(core_clean, "_is_reparse", side_effect=lambda path: Path(path) == ancestor),
+ self.assertRaisesRegex(CleanError, "reparse point is forbidden"),
+ ):
+ prepare_fresh(self.repository / "out" / "reports" / "verify", self.repository)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_coverage_config.py b/build/tests/test_coverage_config.py
new file mode 100644
index 0000000..eb1d6cd
--- /dev/null
+++ b/build/tests/test_coverage_config.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import re
+import unittest
+from pathlib import Path
+
+from coverage import Coverage
+from coverage.parser import PythonParser
+
+
+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:include"),
+ ["core/*", "main.py", "native.py", "source_checks.py", "diagnostics.py",
+ "packaging_ops.py"],
+ )
+ self.assertTrue(coverage.get_option("report:show_missing"))
+ self.assertFalse(coverage.get_option("report:skip_covered"))
+
+ def test_the_only_exclusion_is_an_explicit_per_line_pragma(self) -> None:
+ coverage = Coverage(config_file=str(BUILD_ROOT / "pyproject.toml"))
+
+ self.assertEqual(
+ coverage.get_option("report:exclude_lines"), ["pragma: no cover"]
+ )
+ self.assertEqual(coverage.get_option("report:exclude_also"), [])
+
+ def test_every_excluded_line_is_annotated_or_a_main_guard_body(self) -> None:
+ # coverage.py silently widens a pragma on a compound-statement header over the whole
+ # body, so reading the source for annotations is not enough: ask the parser which lines
+ # the exclusion actually removed, and allow that widening only for a __main__ guard.
+ annotation = re.compile(r"#\s*pragma:\s*no cover\s*(?:#.*)?$")
+ guard = re.compile(r'^\s*if __name__ == "__main__":')
+ sources = [
+ path
+ for pattern in (
+ "core/*.py", "main.py", "native.py", "source_checks.py",
+ "diagnostics.py", "packaging_ops.py",
+ )
+ for path in sorted(BUILD_ROOT.glob(pattern))
+ ]
+
+ self.assertTrue(sources)
+ for path in sources:
+ parser = PythonParser(filename=str(path), exclude="pragma: no cover")
+ parser.parse_source()
+ lines = path.read_text(encoding="utf-8").splitlines()
+
+ for number in sorted(parser.raw_excluded):
+ with self.subTest(path=path.name, line=number):
+ self.assertRegex(lines[number - 1], annotation)
+ for number in sorted(parser.excluded - parser.raw_excluded):
+ with self.subTest(path=path.name, widened=number):
+ self.assertRegex(lines[number - 2], guard)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_cpp_coverage_gate.py b/build/tests/test_cpp_coverage_gate.py
new file mode 100644
index 0000000..98da30b
--- /dev/null
+++ b/build/tests/test_cpp_coverage_gate.py
@@ -0,0 +1,71 @@
+"""Standalone worker tests for the ``core.cpp_coverage`` coverage gate.
+
+These assertions were preserved from ``test_cpp_coverage_graph.py`` when the
+retired ``graphs.coverage`` composition was dropped. They pin the exact
+first-party line/branch completeness gate and its CLI, so the threshold and
+malformed-report handling stay covered.
+"""
+
+from __future__ import annotations
+
+from contextlib import redirect_stderr
+import io
+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 core.cpp_coverage import CoverageError, main as coverage_main, require_full_coverage # noqa: E402
+
+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"))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_diagnostics.py b/build/tests/test_diagnostics.py
new file mode 100644
index 0000000..99fecc7
--- /dev/null
+++ b/build/tests/test_diagnostics.py
@@ -0,0 +1,1203 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import types
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+import diagnostics # noqa: E402
+import native # noqa: E402
+
+
+def _touch(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(b"")
+ return path
+
+
+@dataclass(frozen=True)
+class RecordedCall:
+ argv: tuple[str, ...]
+ cwd: Path | None
+ env: dict[str, str] | None
+
+ @property
+ def name(self) -> str:
+ return Path(self.argv[0]).name.casefold()
+
+
+class FakeRunner:
+ """Record every invocation and answer it with a scripted result."""
+
+ def __init__(self, handler=None) -> None:
+ self.calls: list[RecordedCall] = []
+ self.handler = handler
+
+ def __call__(self, argv, cwd=None, env=None) -> diagnostics.ToolResult:
+ call = RecordedCall(
+ tuple(str(item) for item in argv),
+ cwd,
+ None if env is None else dict(env),
+ )
+ self.calls.append(call)
+ return (
+ diagnostics.ToolResult(0, "")
+ if self.handler is None
+ else self.handler(call)
+ )
+
+ def commands(self, name: str) -> list[RecordedCall]:
+ return [call for call in self.calls if call.name == name]
+
+
+def _report(call: RecordedCall) -> None:
+ for argument in call.argv:
+ if argument.startswith("JUnit::out="):
+ path = Path(argument.split("=", 1)[1])
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("", encoding="utf-8")
+
+
+# A distinct payload for the full-detail LCOV export so a test can prove the
+# two ``llvm-cov export`` shapes are not accidentally the same command.
+LCOV_EXPORT = "SF:src/renpy/renpy.cpp\nDA:1,1\nend_of_record\n"
+
+
+def _handler(*, profraws: bool = True, tests_xml: bool = True,
+ replay_exit: int = 0, timed_exit: int = 0, report: str = "{}",
+ generated: bytes | None = None):
+ def handle(call: RecordedCall) -> diagnostics.ToolResult:
+ if call.name == "tests.exe":
+ if call.env and "LLVM_PROFILE_FILE" in call.env:
+ if profraws:
+ pattern = Path(call.env["LLVM_PROFILE_FILE"])
+ pattern.parent.mkdir(parents=True, exist_ok=True)
+ (pattern.parent / "coverage-1-1.profraw").write_bytes(b"raw")
+ if tests_xml:
+ _report(call)
+ return diagnostics.ToolResult(0, "suite output")
+ if call.name == "llvm-cov.exe":
+ if "--format=lcov" in call.argv:
+ return diagnostics.ToolResult(0, LCOV_EXPORT)
+ return diagnostics.ToolResult(0, report)
+ if call.name.startswith("fuzz-"):
+ timed = any(item.startswith("-max_total_time=") for item in call.argv)
+ if timed:
+ if generated is not None:
+ (Path(call.argv[1]) / "generated").write_bytes(generated)
+ return diagnostics.ToolResult(timed_exit, "timed")
+ return diagnostics.ToolResult(replay_exit, "replay")
+ return diagnostics.ToolResult(0, "")
+
+ return handle
+
+
+def _tools(root: Path) -> diagnostics.DiagnosticsTools:
+ return diagnostics.DiagnosticsTools(
+ msbuild=_touch(root / "tools" / "MSBuild.exe"),
+ vcpkg=_touch(root / "tools" / "vcpkg" / "vcpkg.exe"),
+ llvm_install=root / "llvm",
+ llvm_cov=_touch(root / "tools" / "llvm-cov.exe"),
+ llvm_profdata=_touch(root / "tools" / "llvm-profdata.exe"),
+ llvm_runtimes={"x86": root / "runtime-x86", "x64": root / "runtime-x64"},
+ asan_runtimes={
+ "x86": _touch(root / "runtime" / "clang_rt.asan_dynamic-i386.dll"),
+ "x64": _touch(root / "runtime" / "clang_rt.asan_dynamic-x86_64.dll"),
+ },
+ umdh=_touch(root / "tools" / "umdh.exe"),
+ )
+
+
+def _seed(repository: Path, target: str, **seeds: bytes) -> Path:
+ directory = repository / "src" / "fuzz" / "corpus" / target
+ directory.mkdir(parents=True, exist_ok=True)
+ for name, content in seeds.items():
+ (directory / name).write_bytes(content)
+ return directory
+
+
+def _fuzz_calls(runner: FakeRunner, exe: str, *, timed: bool) -> list[RecordedCall]:
+ return [
+ call for call in runner.calls
+ if call.name == exe
+ and any(item.startswith("-max_total_time=") for item in call.argv) is timed
+ ]
+
+
+class WorkspaceTestCase(unittest.TestCase):
+ def workspace(self) -> tuple[Path, Path, Path]:
+ root = Path(self.enterContext(tempfile.TemporaryDirectory()))
+ return root, root / "repo", root / "out"
+
+
+class RunnerTests(unittest.TestCase):
+ def test_the_real_runner_merges_the_environment_preserves_cwd_and_exit_code(self) -> None:
+ result = diagnostics.run_tool(
+ [sys.executable, "-c", "import os; print(os.environ['OBSERVER_PROBE'])"],
+ env={"OBSERVER_PROBE": "1"},
+ )
+
+ self.assertEqual(result.returncode, 0)
+ self.assertEqual(result.stdout.strip(), "1")
+
+ located = diagnostics.run_tool(
+ [sys.executable, "-c", "import os; print(os.getcwd())"],
+ cwd=Path(tempfile.gettempdir()),
+ )
+ self.assertEqual(Path(located.stdout.strip()), Path(tempfile.gettempdir()).resolve())
+
+ failed = diagnostics.run_tool([sys.executable, "-c", "raise SystemExit(9)"])
+ self.assertEqual(failed.returncode, 9)
+
+ def test_the_real_runner_always_passes_an_explicit_inherited_environment(self) -> None:
+ # A bare ``env=None`` lets the child inherit the raw Windows block with both PATH and
+ # Path, which makes the MSBuild ClangCl task throw MSB6001; the merged os.environ
+ # mapping normalises the duplicate through its case-insensitive lookup.
+ completed = subprocess.CompletedProcess([], 0, stdout="")
+ with mock.patch.object(diagnostics.subprocess, "run", return_value=completed) as run:
+ diagnostics.run_tool(["tool.exe"])
+ diagnostics.run_tool(["tool.exe"], env={"OBSERVER_PROBE": "1"})
+
+ inherited = run.call_args_list[0].kwargs["env"]
+ self.assertIsNotNone(inherited)
+ self.assertEqual(inherited, dict(os.environ))
+
+ override = run.call_args_list[1].kwargs["env"]
+ self.assertEqual(override["OBSERVER_PROBE"], "1")
+ self.assertEqual(override["PATH"], os.environ["PATH"])
+
+
+class CheckedRunnerTests(unittest.TestCase):
+ def test_the_adapter_passes_success_through_and_raises_on_failure(self) -> None:
+ runner = FakeRunner()
+ execute = diagnostics._checked_runner(runner)
+
+ result = execute(("tool", "arg"), Path("C:/cwd"), {"A": "1"})
+ self.assertEqual(result, diagnostics.ToolResult(0, ""))
+ self.assertEqual(runner.calls[0].argv, ("tool", "arg"))
+ self.assertEqual(runner.calls[0].cwd, Path("C:/cwd"))
+ self.assertEqual(runner.calls[0].env, {"A": "1"})
+
+ failing = diagnostics._checked_runner(
+ FakeRunner(lambda call: diagnostics.ToolResult(17, "boom"))
+ )
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "exit code 17"):
+ failing(("vcpkg", "install"), None, None)
+
+
+class JobCountTests(unittest.TestCase):
+ def test_jobs_default_to_the_cpu_count_and_reject_nonpositive_or_boolean_values(self) -> None:
+ self.assertEqual(diagnostics._job_count(None), os.cpu_count() or 1)
+ self.assertEqual(diagnostics._job_count(4), 4)
+ for value in (0, -1, True):
+ with self.assertRaisesRegex(ValueError, "jobs"):
+ diagnostics._job_count(value)
+
+
+class SelectionTests(unittest.TestCase):
+ def test_selections_reject_empty_unsupported_and_duplicate_values(self) -> None:
+ diagnostics._require_selection(("x86", "x64"), diagnostics.COVERAGE_ARCHITECTURES, "coverage")
+ with self.assertRaisesRegex(ValueError, "at least one"):
+ diagnostics._require_selection((), diagnostics.COVERAGE_ARCHITECTURES, "coverage")
+ with self.assertRaisesRegex(ValueError, "coverage does not support: arm64"):
+ diagnostics._require_selection(("arm64",), diagnostics.COVERAGE_ARCHITECTURES, "coverage")
+ with self.assertRaisesRegex(ValueError, "duplicate selection"):
+ diagnostics._require_selection(("x64", "x64"), diagnostics.COVERAGE_ARCHITECTURES, "coverage")
+
+ def test_sanitizers_and_hosts_are_validated(self) -> None:
+ diagnostics._require_sanitizer("asan")
+ with self.assertRaisesRegex(ValueError, "unsupported sanitizer"):
+ diagnostics._require_sanitizer("msan")
+ diagnostics._require_runnable(("x86",), "x64")
+ diagnostics._require_runnable(("x86",), None)
+ with self.assertRaisesRegex(ValueError, "cannot run"):
+ diagnostics._require_runnable(("x64",), "x86")
+ with self.assertRaisesRegex(ValueError, "unsupported host"):
+ diagnostics._require_runnable(("x64",), "sparc")
+
+
+class RestoreGuardTests(WorkspaceTestCase):
+ """Finding 1: every native.restore call must fail closed on a nonzero vcpkg exit."""
+
+ def _failing(self) -> FakeRunner:
+ return FakeRunner(
+ lambda call: diagnostics.ToolResult(17, "vcpkg boom")
+ if call.name == "vcpkg.exe" else diagnostics.ToolResult(0, "")
+ )
+
+ def test_a_failed_coverage_restore_stops_before_any_build(self) -> None:
+ root, repository, output = self.workspace()
+ runner = self._failing()
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "exit code 17"):
+ diagnostics.test_coverage(
+ repository, ("x86", "x64"), output, tools=_tools(root), runner=runner, host="x64",
+ )
+ self.assertEqual(runner.commands("msbuild.exe"), [])
+
+ def test_a_failed_sanitizer_restore_stops_before_any_build(self) -> None:
+ root, repository, output = self.workspace()
+ runner = self._failing()
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "exit code 17"):
+ diagnostics.test_sanitizer(
+ repository, "ubsan", ("x64",), output, tools=_tools(root), runner=runner, host="x64",
+ )
+ self.assertEqual(runner.commands("msbuild.exe"), [])
+
+ def test_a_failed_leak_restore_stops_before_any_build(self) -> None:
+ root, repository, output = self.workspace()
+ runner = self._failing()
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "exit code 17"):
+ diagnostics.test_leaks(
+ repository, output, tools=_tools(root), runner=runner, host="x64",
+ auditor=lambda *args, **kwargs: None,
+ )
+ self.assertEqual(runner.commands("msbuild.exe"), [])
+
+
+class CoverageTests(WorkspaceTestCase):
+ def test_coverage_builds_reports_and_gates_the_real_shared_objects(self) -> None:
+ root, repository, output = self.workspace()
+ tools = _tools(root)
+ runner = FakeRunner(_handler())
+
+ reports = diagnostics.test_coverage(
+ repository, ("x86", "x64"), output, tools=tools, runner=runner, host="x64",
+ )
+
+ self.assertEqual(
+ reports,
+ (output / "coverage" / "x86" / "coverage.json",
+ output / "coverage" / "x86" / "coverage.lcov",
+ output / "coverage" / "x64" / "coverage.json",
+ output / "coverage" / "x64" / "coverage.lcov"),
+ )
+ self.assertEqual(
+ [call.argv for call in runner.commands("vcpkg.exe")],
+ [native.restore_command(repository, architecture, tools)
+ for architecture in ("x86", "x64")],
+ )
+ self.assertEqual(
+ [call.argv for call in runner.commands("msbuild.exe")],
+ [
+ diagnostics._observer_command(
+ repository, architecture, "Coverage", diagnostics._job_count(None), tools,
+ runtime=tools.llvm_runtimes[architecture],
+ )
+ for architecture in ("x86", "x64")
+ ],
+ )
+ for architecture in ("x86", "x64"):
+ suite = next(
+ call for call in runner.commands("tests.exe")
+ if call.cwd == native.bin_directory(repository, architecture, "Coverage")
+ )
+ self.assertEqual(
+ suite.argv,
+ native.test_command(
+ repository, architecture, "Coverage",
+ output / "coverage" / architecture / "unit" / "tests.xml", None,
+ ),
+ )
+ self.assertEqual(
+ suite.env,
+ {"LLVM_PROFILE_FILE": str(
+ native.artifacts_root(repository) / "coverage" / architecture / "unit"
+ / "coverage-%m-%p.profraw")},
+ )
+ self.assertTrue(
+ (output / "coverage" / architecture / "coverage.json").is_file()
+ )
+ self.assertTrue(
+ (output / "coverage" / architecture / "coverage.lcov").is_file()
+ )
+
+ merge = runner.commands("llvm-profdata.exe")
+ self.assertEqual(len(merge), 2)
+ self.assertEqual(merge[0].argv[1], "merge")
+ self.assertEqual(
+ merge[0].argv[-2:],
+ ("-o", str(output / "coverage" / "x86" / "coverage.profdata")),
+ )
+ self.assertEqual(
+ merge[0].argv[3],
+ str(native.artifacts_root(repository) / "coverage" / "x86" / "unit"
+ / "coverage-1-1.profraw"),
+ )
+
+ export = runner.commands("llvm-cov.exe")
+ self.assertEqual(len(export), 4)
+ argv = export[0].argv
+ self.assertEqual(argv[1], "export")
+ self.assertEqual(argv[2], str(native.test_executable(repository, "x86", "Coverage")))
+ self.assertEqual(argv[3], "--instr-profile")
+ self.assertEqual(argv[4], str(output / "coverage" / "x86" / "coverage.profdata"))
+ self.assertEqual(argv[5], "--ignore-filename-regex")
+ self.assertEqual(argv[6], diagnostics.COVERAGE_IGNORED_SOURCES)
+ objects = [argv[index + 1] for index, item in enumerate(argv) if item == "--object"]
+ self.assertEqual(
+ objects,
+ [str(native.bin_directory(repository, "x86", "Coverage") / native.BINARIES[name])
+ for name in diagnostics.PROJECTS],
+ )
+ self.assertEqual(argv[-1], "--summary-only")
+
+ detail = export[1].argv
+ self.assertEqual(detail[1:3], argv[1:3])
+ self.assertEqual(detail[4:7], argv[4:7])
+ self.assertEqual(
+ [detail[index + 1] for index, item in enumerate(detail) if item == "--object"],
+ objects,
+ )
+ self.assertEqual(detail[-1], "--format=lcov")
+ self.assertNotIn("--summary-only", detail)
+
+ gate = runner.commands("python.exe")
+ self.assertEqual(len(gate), 2)
+ self.assertEqual(
+ gate[0].argv,
+ (str(diagnostics._PYTHON), "-m", "core.cpp_coverage", "gate",
+ str(output / "coverage" / "x86" / "coverage.json")),
+ )
+ self.assertEqual(gate[0].cwd, diagnostics._BUILD_ROOT)
+ self.assertEqual(
+ gate[0].env,
+ {"OBSERVER_OUT_DIR": str(output / "coverage" / "x86"),
+ "OBSERVER_BUILD_DIR": str(output / "coverage" / "x86")},
+ )
+
+ def test_coverage_emits_the_full_lcov_detail_alongside_the_summary(self) -> None:
+ root, repository, output = self.workspace()
+ tools = _tools(root)
+ runner = FakeRunner(_handler())
+
+ reports = diagnostics.test_coverage(
+ repository, ("x86",), output, tools=tools, runner=runner, host="x64",
+ )
+
+ json_report = output / "coverage" / "x86" / "coverage.json"
+ lcov_report = output / "coverage" / "x86" / "coverage.lcov"
+ self.assertEqual(reports, (json_report, lcov_report))
+ export = runner.commands("llvm-cov.exe")
+ self.assertEqual(len(export), 2)
+ summary, detail = (call.argv for call in export)
+ # Both exports drive the same executable, profile and shared objects...
+ self.assertEqual(summary[1:7], detail[1:7])
+
+ def objects(argv):
+ return [argv[index + 1] for index, item in enumerate(argv) if item == "--object"]
+
+ self.assertEqual(objects(summary), objects(detail))
+ # ...but only the summary is trimmed, while the detail keeps every line.
+ self.assertEqual(summary[-1], "--summary-only")
+ self.assertNotIn("--format=lcov", summary)
+ self.assertEqual(detail[-1], "--format=lcov")
+ self.assertNotIn("--summary-only", detail)
+ self.assertEqual(json_report.read_text(encoding="utf-8"), "{}")
+ self.assertEqual(lcov_report.read_text(encoding="utf-8"), LCOV_EXPORT)
+
+ def test_a_failing_lcov_export_fails_closed_without_the_gate(self) -> None:
+ root, repository, output = self.workspace()
+
+ def handler(call: RecordedCall) -> diagnostics.ToolResult:
+ if call.name == "llvm-cov.exe" and "--format=lcov" in call.argv:
+ return diagnostics.ToolResult(9, "lcov boom")
+ return _handler()(call)
+
+ runner = FakeRunner(handler)
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "exit code 9"):
+ diagnostics.test_coverage(
+ repository, ("x64",), output, tools=_tools(root), runner=runner, host="x64",
+ )
+
+ # The failed export is never written and the gate is never reached.
+ self.assertFalse((output / "coverage" / "x64" / "coverage.lcov").is_file())
+ self.assertEqual(runner.commands("python.exe"), [])
+
+ def test_coverage_with_corpus_runs_the_ordinary_and_compatibility_suites(self) -> None:
+ root, repository, output = self.workspace()
+ tools = _tools(root)
+ runner = FakeRunner(_handler())
+ corpus = root / "corpus"
+ corpus.mkdir()
+
+ reports = diagnostics.test_coverage(
+ repository, ("x64",), output, tools=tools, runner=runner, host="x64",
+ corpus=corpus,
+ )
+
+ self.assertEqual(
+ reports,
+ (output / "coverage" / "x64" / "coverage.json",
+ output / "coverage" / "x64" / "coverage.lcov"),
+ )
+ suites = runner.commands("tests.exe")
+ self.assertEqual(len(suites), 2)
+ unit = next(
+ call for call in suites
+ if call.argv == native.test_command(
+ repository, "x64", "Coverage",
+ output / "coverage" / "x64" / "unit" / "tests.xml", None,
+ )
+ )
+ self.assertNotIn("OBSERVER_TEST_CORPUS", unit.env)
+ corpus_call = next(
+ call for call in suites
+ if call.argv == native.test_command(
+ repository, "x64", "Coverage",
+ output / "coverage" / "x64" / "corpus" / "tests.xml", corpus,
+ )
+ )
+ self.assertIn("[compatibility]", corpus_call.argv)
+ self.assertEqual(corpus_call.env["OBSERVER_TEST_CORPUS"], str(corpus))
+ self.assertEqual(
+ corpus_call.env["LLVM_PROFILE_FILE"],
+ str(native.artifacts_root(repository) / "coverage" / "x64" / "corpus"
+ / "coverage-%m-%p.profraw"),
+ )
+ unit_raw = (native.artifacts_root(repository) / "coverage" / "x64" / "unit"
+ / "coverage-1-1.profraw")
+ corpus_raw = (native.artifacts_root(repository) / "coverage" / "x64" / "corpus"
+ / "coverage-1-1.profraw")
+ self.assertTrue(unit_raw.is_file() and corpus_raw.is_file())
+ merge = runner.commands("llvm-profdata.exe")[0]
+ self.assertEqual(merge.argv[3:5], (str(unit_raw), str(corpus_raw)))
+ self.assertTrue(
+ (output / "coverage" / "x64" / "unit" / "tests.xml").is_file()
+ )
+ self.assertTrue(
+ (output / "coverage" / "x64" / "corpus" / "tests.xml").is_file()
+ )
+
+ def test_coverage_rejects_a_missing_or_non_directory_corpus(self) -> None:
+ root, repository, output = self.workspace()
+ runner = FakeRunner()
+
+ with self.assertRaises(FileNotFoundError):
+ diagnostics.test_coverage(
+ repository, ("x64",), output, tools=_tools(root), runner=runner,
+ corpus=repository / "missing",
+ )
+ with self.assertRaises(NotADirectoryError):
+ diagnostics.test_coverage(
+ repository, ("x64",), output, tools=_tools(root), runner=runner,
+ corpus=_touch(root / "corpus-file"),
+ )
+ self.assertEqual(runner.calls, [])
+
+ def test_coverage_clears_stale_raw_profiles_before_running(self) -> None:
+ root, repository, output = self.workspace()
+ stale = native.artifacts_root(repository) / "coverage" / "x64" / "unit" / "stale.profraw"
+ stale.parent.mkdir(parents=True)
+ stale.write_bytes(b"old")
+
+ diagnostics.test_coverage(
+ repository, ("x64",), output, tools=_tools(root),
+ runner=FakeRunner(_handler()), host="x64",
+ )
+
+ self.assertFalse(stale.exists())
+ self.assertTrue((native.artifacts_root(repository) / "coverage" / "x64" / "unit"
+ / "coverage-1-1.profraw").is_file())
+
+ def test_coverage_requires_new_raw_profiles(self) -> None:
+ root, repository, output = self.workspace()
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "no LLVM raw profiles"):
+ diagnostics.test_coverage(
+ repository, ("x64",), output, tools=_tools(root),
+ runner=FakeRunner(_handler(profraws=False)), host="x64",
+ )
+
+ def test_coverage_requires_raw_profiles_from_every_suite(self) -> None:
+ root, repository, output = self.workspace()
+ corpus = root / "corpus"
+ corpus.mkdir()
+
+ def handler(call: RecordedCall) -> diagnostics.ToolResult:
+ # Only the compatibility suite yields a profile; the ordinary one is empty.
+ if call.name == "tests.exe":
+ if (
+ call.env
+ and "LLVM_PROFILE_FILE" in call.env
+ and "[compatibility]" in call.argv
+ ):
+ pattern = Path(call.env["LLVM_PROFILE_FILE"])
+ pattern.parent.mkdir(parents=True, exist_ok=True)
+ (pattern.parent / "coverage-1-1.profraw").write_bytes(b"raw")
+ _report(call)
+ return diagnostics.ToolResult(0, "output")
+
+ runner = FakeRunner(handler)
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "no LLVM raw profiles"):
+ diagnostics.test_coverage(
+ repository, ("x64",), output, tools=_tools(root), runner=runner,
+ host="x64", corpus=corpus,
+ )
+
+ # The empty ordinary suite stops the run before the profiles are merged.
+ self.assertEqual(runner.commands("llvm-profdata.exe"), [])
+
+ def test_coverage_rejects_an_architecture_without_a_profile_runtime(self) -> None:
+ root, repository, output = self.workspace()
+ runner = FakeRunner()
+
+ with self.assertRaisesRegex(ValueError, "arm64"):
+ diagnostics.test_coverage(
+ repository, ("arm64",), output, tools=_tools(root), runner=runner,
+ )
+ self.assertEqual(runner.calls, [])
+
+ def test_coverage_refuses_a_host_that_cannot_run_the_architecture(self) -> None:
+ root, repository, output = self.workspace()
+ runner = FakeRunner()
+
+ with self.assertRaisesRegex(ValueError, "cannot run"):
+ diagnostics.test_coverage(
+ repository, ("x64",), output, tools=_tools(root), runner=runner, host="x86",
+ )
+ self.assertEqual(runner.calls, [])
+
+ def test_a_failing_coverage_build_stops_before_the_suite_runs(self) -> None:
+ root, repository, output = self.workspace()
+ runner = FakeRunner(
+ lambda call: diagnostics.ToolResult(3, "boom")
+ if call.name == "msbuild.exe" else diagnostics.ToolResult(0, "")
+ )
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "exit code 3"):
+ diagnostics.test_coverage(
+ repository, ("x64",), output, tools=_tools(root), runner=runner, host="x64",
+ )
+ self.assertEqual(runner.commands("tests.exe"), [])
+
+
+class SanitizerTests(WorkspaceTestCase):
+ def test_asan_builds_copies_the_runtime_and_gates_the_suite_log(self) -> None:
+ root, repository, output = self.workspace()
+ tools = _tools(root)
+ runner = FakeRunner(_handler())
+
+ logs = diagnostics.test_sanitizer(
+ repository, "asan", ("x86", "x64"), output,
+ tools=tools, runner=runner, host="x64",
+ )
+
+ self.assertEqual(
+ logs,
+ (output / "sanitizer" / "asan" / "x86" / "tests.log",
+ output / "sanitizer" / "asan" / "x64" / "tests.log"),
+ )
+ restores = runner.commands("vcpkg.exe")
+ self.assertEqual(len(restores), 2)
+ for call in restores:
+ self.assertIn("-asan", call.argv[2])
+ self.assertEqual(
+ {call.argv[6] for call in restores},
+ {"observer-x86-windows-static-asan", "observer-x64-windows-static-asan"},
+ )
+ for call in runner.commands("msbuild.exe"):
+ self.assertFalse(any(item.startswith("/p:LLVM") for item in call.argv))
+ for architecture in ("x86", "x64"):
+ runtime = (native.bin_directory(repository, architecture, "ASan")
+ / diagnostics.ASAN_RUNTIMES[architecture])
+ self.assertTrue(runtime.is_file())
+ suite = runner.commands("tests.exe")[0]
+ self.assertEqual(suite.env, {"ASAN_OPTIONS": diagnostics.SANITIZER_ENVIRONMENT["asan"][1]})
+ gate = runner.commands("python.exe")[0]
+ self.assertEqual(
+ gate.argv[1:],
+ ("-m", "core.sanitizer", "gate", "asan",
+ str(output / "sanitizer" / "asan" / "x86" / "tests.log")),
+ )
+
+ def test_ubsan_links_the_runtime_directory_and_gates_the_suite_log(self) -> None:
+ root, repository, output = self.workspace()
+ tools = _tools(root)
+ runner = FakeRunner(_handler())
+
+ logs = diagnostics.test_sanitizer(
+ repository, "ubsan", ("x64",), output,
+ tools=tools, runner=runner, host="x64",
+ )
+
+ self.assertEqual(logs, (output / "sanitizer" / "ubsan" / "x64" / "tests.log",))
+ restore = runner.commands("vcpkg.exe")[0]
+ self.assertFalse(restore.argv[2].endswith("-asan"))
+ build = runner.commands("msbuild.exe")[0]
+ self.assertIn(f"/p:LLVMInstallDir={tools.llvm_install}", build.argv)
+ self.assertIn(f"/p:LLVMRuntimeDir={tools.llvm_runtimes['x64']}", build.argv)
+ self.assertEqual(list(native.bin_directory(repository, "x64", "UBSan").glob("*.dll")), [])
+ self.assertEqual(
+ runner.commands("tests.exe")[0].env,
+ {"UBSAN_OPTIONS": diagnostics.SANITIZER_ENVIRONMENT["ubsan"][1]},
+ )
+
+ def test_a_sanitizer_suite_without_results_is_a_failure(self) -> None:
+ root, repository, output = self.workspace()
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "tests.xml"):
+ diagnostics.test_sanitizer(
+ repository, "asan", ("x64",), output, tools=_tools(root),
+ runner=FakeRunner(_handler(tests_xml=False)), host="x64",
+ )
+
+ def test_sanitizers_reject_unknown_names_and_unsupported_architectures(self) -> None:
+ root, repository, output = self.workspace()
+ runner = FakeRunner()
+
+ with self.assertRaisesRegex(ValueError, "unsupported sanitizer"):
+ diagnostics.test_sanitizer(
+ repository, "msan", ("x64",), output, tools=_tools(root), runner=runner,
+ )
+ with self.assertRaisesRegex(ValueError, "ubsan does not support: x86"):
+ diagnostics.test_sanitizer(
+ repository, "ubsan", ("x86",), output, tools=_tools(root), runner=runner,
+ )
+ self.assertEqual(runner.calls, [])
+
+
+class FuzzTests(WorkspaceTestCase):
+ def test_fuzz_runs_the_timed_x64_budget_and_the_x86_replay_gate(self) -> None:
+ root, repository, output = self.workspace()
+ for target in diagnostics.FUZZ_TARGETS:
+ _seed(repository, target, **{"valid.hex": b"0011ff", "raw.bin": b"\x00\x01"})
+ tools = _tools(root)
+ runner = FakeRunner(_handler())
+
+ outputs = diagnostics.fuzz(
+ repository, ("x86", "x64"), diagnostics.FUZZ_TARGETS, 30, output,
+ tools=tools, runner=runner, host="x64",
+ )
+
+ self.assertEqual(
+ outputs,
+ tuple(output / "fuzz" / architecture / target
+ for architecture in ("x86", "x64") for target in diagnostics.FUZZ_TARGETS),
+ )
+ builds = runner.commands("msbuild.exe")
+ self.assertEqual(len(builds), 8)
+ for call in builds:
+ expected = tools.llvm_runtimes["x86" if "/p:Platform=Win32" in call.argv else "x64"]
+ self.assertIn(f"/p:LLVMRuntimeDir={expected}", call.argv)
+
+ timed = _fuzz_calls(runner, "fuzz-pickle.exe", timed=True)
+ replays = _fuzz_calls(runner, "fuzz-pickle.exe", timed=False)
+ self.assertEqual(len(timed), 1)
+ self.assertEqual(len(replays), 2)
+ self.assertTrue(all("-max_len=262144" in call.argv for call in timed + replays))
+
+ seed_dir = native.artifacts_root(repository) / "fuzz" / "x64" / "pickle" / "seeds"
+ self.assertEqual((seed_dir / "valid").read_bytes(), bytes.fromhex("0011ff"))
+ self.assertEqual((seed_dir / "raw.bin").read_bytes(), b"\x00\x01")
+ self.assertEqual(timed[0].argv[1], str(
+ native.artifacts_root(repository) / "fuzz" / "x64" / "pickle" / "run"))
+ x64_replay = next(call for call in replays if "x64" in call.argv[0])
+ self.assertIn(str(seed_dir / "valid"), x64_replay.argv)
+
+ published = output / "fuzz" / "x64" / "pickle"
+ self.assertEqual((published / "corpus" / "valid").read_bytes(), bytes.fromhex("0011ff"))
+ self.assertEqual((published / "status.txt").read_text(encoding="utf-8"), "0\n")
+ self.assertTrue((published / "artifacts").is_dir())
+
+ gates = runner.commands("python.exe")
+ self.assertEqual(len(gates), len(diagnostics.FUZZ_TARGETS))
+ for gate in gates:
+ self.assertEqual(gate.argv[1:5], ("-m", "core.sanitizer", "gate", "asan"))
+
+ def test_a_same_named_prior_seed_never_changes_the_hermetic_replay(self) -> None:
+ root, repository, output = self.workspace()
+ _seed(repository, "pickle", **{"valid": b"seed-bytes"})
+ prior = root / "prior"
+ (prior / "pickle").mkdir(parents=True)
+ (prior / "pickle" / "valid").write_bytes(b"prior-bytes")
+ tools = _tools(root)
+ runner = FakeRunner(_handler())
+
+ diagnostics.fuzz(
+ repository, ("x86", "x64"), ("pickle",), 5, output,
+ tools=tools, runner=runner, host="x64", prior_corpus=prior,
+ )
+
+ work = native.artifacts_root(repository) / "fuzz" / "x64" / "pickle"
+ self.assertEqual((work / "seeds" / "valid").read_bytes(), b"seed-bytes")
+ self.assertEqual((work / "prior" / "valid").read_bytes(), b"prior-bytes")
+ self.assertEqual((work / "run" / "valid").read_bytes(), b"prior-bytes")
+ x64_replay = next(
+ call for call in _fuzz_calls(runner, "fuzz-pickle.exe", timed=False)
+ if "x64" in call.argv[0]
+ )
+ self.assertIn(str(work / "seeds" / "valid"), x64_replay.argv)
+ self.assertNotIn(str(work / "prior" / "valid"), x64_replay.argv)
+ x86_work = native.artifacts_root(repository) / "fuzz" / "x86" / "pickle"
+ self.assertEqual((x86_work / "seeds" / "valid").read_bytes(), b"seed-bytes")
+ self.assertEqual((x86_work / "prior" / "valid").read_bytes(), b"prior-bytes")
+ x86_replay = next(
+ call for call in _fuzz_calls(runner, "fuzz-pickle.exe", timed=False)
+ if "x86" in call.argv[0]
+ )
+ self.assertIn(str(x86_work / "seeds" / "valid"), x86_replay.argv)
+ self.assertIn(str(x86_work / "prior" / "valid"), x86_replay.argv)
+ timed = _fuzz_calls(runner, "fuzz-pickle.exe", timed=True)[0]
+ self.assertEqual(timed.argv[1], str(work / "run"))
+
+ def test_fuzz_publishes_the_x64_corpus_and_reserves_it_for_reuse(self) -> None:
+ root, repository, output = self.workspace()
+ _seed(repository, "pickle", **{"valid.hex": b"00ff"})
+ tools, runner = _tools(root), FakeRunner(_handler(generated=b"new-input"))
+
+ diagnostics.fuzz(
+ repository, ("x64",), ("pickle",), 5, output,
+ tools=tools, runner=runner, host="x64",
+ )
+ published = output / "fuzz" / "x64" / "pickle" / "corpus"
+ self.assertEqual((published / "generated").read_bytes(), b"new-input")
+
+ second = root / "second"
+ runner2 = FakeRunner(_handler())
+ diagnostics.fuzz(
+ repository, ("x64",), ("pickle",), 5, second,
+ tools=tools, runner=runner2, host="x64", prior_corpus=output / "fuzz" / "x64",
+ )
+ self.assertTrue((published / "generated").is_file())
+ work = native.artifacts_root(repository) / "fuzz" / "x64" / "pickle"
+ self.assertEqual((work / "run" / "generated").read_bytes(), b"new-input")
+ self.assertTrue(
+ (second / "fuzz" / "x64" / "pickle" / "corpus" / "generated").is_file()
+ )
+
+ def test_absent_prior_corpus_entries_are_ignored(self) -> None:
+ root, repository, output = self.workspace()
+ _seed(repository, "pickle", **{"valid.hex": b"00ff"})
+ prior = root / "prior"
+ (prior / "renpy").mkdir(parents=True)
+ (prior / "renpy" / "other").write_bytes(b"other")
+
+ diagnostics.fuzz(
+ repository, ("x64",), ("pickle",), 5, output,
+ tools=_tools(root), runner=FakeRunner(_handler()), host="x64", prior_corpus=prior,
+ )
+
+ self.assertEqual(
+ sorted(path.name for path in
+ (native.artifacts_root(repository) / "fuzz" / "x64" / "pickle" / "run").iterdir()),
+ ["valid"],
+ )
+ self.assertFalse(
+ (native.artifacts_root(repository) / "fuzz" / "x64" / "pickle" / "prior").exists()
+ )
+
+ def test_a_missing_prior_corpus_directory_is_rejected(self) -> None:
+ root, repository, output = self.workspace()
+
+ with self.assertRaises(NotADirectoryError):
+ diagnostics.fuzz(
+ repository, ("x64",), ("pickle",), 5, output, tools=_tools(root),
+ runner=FakeRunner(), host="x64", prior_corpus=repository / "missing",
+ )
+
+ def test_fuzz_seed_loading_handles_missing_empty_and_nested_entries(self) -> None:
+ root, repository, output = self.workspace()
+ tools, runner = _tools(root), FakeRunner(_handler())
+
+ with self.assertRaisesRegex(FileNotFoundError, "no checked-in fuzzer seeds"):
+ diagnostics.fuzz(repository, ("x64",), ("pickle",), 5, output,
+ tools=tools, runner=runner, host="x64")
+
+ (repository / "src" / "fuzz" / "corpus" / "pickle").mkdir(parents=True)
+ with self.assertRaisesRegex(FileNotFoundError, "no checked-in fuzzer seeds"):
+ diagnostics.fuzz(repository, ("x64",), ("pickle",), 5, output,
+ tools=tools, runner=runner, host="x64")
+
+ directory = _seed(repository, "pickle", **{"valid.hex": b"00ff"})
+ (directory / "nested").mkdir()
+ diagnostics.fuzz(repository, ("x64",), ("pickle",), 5, output,
+ tools=tools, runner=runner, host="x64")
+ self.assertEqual(
+ [path.name for path in
+ (native.artifacts_root(repository) / "fuzz" / "x64" / "pickle" / "seeds").iterdir()],
+ ["valid"],
+ )
+
+ def test_a_timed_fuzzer_crash_is_reported_with_its_evidence(self) -> None:
+ root, repository, output = self.workspace()
+ _seed(repository, "pickle", **{"valid.hex": b"00ff"})
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "exit code 7"):
+ diagnostics.fuzz(
+ repository, ("x64",), ("pickle",), 5, output, tools=_tools(root),
+ runner=FakeRunner(_handler(timed_exit=7)), host="x64",
+ )
+
+ directory = output / "fuzz" / "x64" / "pickle"
+ self.assertEqual((directory / "status.txt").read_text(encoding="utf-8"), "7\n")
+ self.assertTrue((directory / "run.log").is_file())
+ self.assertTrue((directory / "artifacts").is_dir())
+ self.assertFalse((directory / "corpus").exists())
+
+ def test_a_replay_crash_is_reported_with_its_log(self) -> None:
+ root, repository, output = self.workspace()
+ _seed(repository, "pickle", **{"valid.hex": b"00ff"})
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "exit code 4"):
+ diagnostics.fuzz(
+ repository, ("x86",), ("pickle",), 5, output, tools=_tools(root),
+ runner=FakeRunner(_handler(replay_exit=4)), host="x64",
+ )
+
+ self.assertTrue((output / "fuzz" / "x86" / "pickle" / "replay.log").is_file())
+
+ def test_a_reused_x64_output_records_the_replay_failure_over_the_old_success(self) -> None:
+ root, repository, output = self.workspace()
+ _seed(repository, "pickle", **{"valid.hex": b"00ff"})
+ tools = _tools(root)
+
+ diagnostics.fuzz(
+ repository, ("x64",), ("pickle",), 5, output,
+ tools=tools, runner=FakeRunner(_handler(generated=b"new-input")), host="x64",
+ )
+ directory = output / "fuzz" / "x64" / "pickle"
+ self.assertEqual((directory / "status.txt").read_text(encoding="utf-8"), "0\n")
+ self.assertTrue((directory / "run.log").is_file())
+ self.assertTrue((directory / "corpus" / "generated").is_file())
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "exit code 4"):
+ diagnostics.fuzz(
+ repository, ("x64",), ("pickle",), 5, output,
+ tools=tools, runner=FakeRunner(_handler(replay_exit=4)), host="x64",
+ )
+
+ # The stale success is replaced by the current failure evidence.
+ self.assertEqual((directory / "status.txt").read_text(encoding="utf-8"), "4\n")
+ self.assertFalse((directory / "run.log").exists())
+ self.assertTrue((directory / "replay.log").is_file())
+ # The accumulated corpus from the earlier success is preserved for reuse.
+ self.assertTrue((directory / "corpus" / "generated").is_file())
+
+ def test_fuzz_rejects_unknown_targets_and_nonpositive_budgets(self) -> None:
+ root, repository, output = self.workspace()
+ runner = FakeRunner()
+
+ with self.assertRaisesRegex(ValueError, "fuzz does not support: bogus"):
+ diagnostics.fuzz(repository, ("x64",), ("bogus",), 5, output,
+ tools=_tools(root), runner=runner)
+ with self.assertRaisesRegex(ValueError, "positive integer"):
+ diagnostics.fuzz(repository, ("x64",), ("pickle",), 0, output,
+ tools=_tools(root), runner=runner)
+ with self.assertRaisesRegex(ValueError, "positive integer"):
+ diagnostics.fuzz(repository, ("x64",), ("pickle",), True, output,
+ tools=_tools(root), runner=runner)
+ self.assertEqual(runner.calls, [])
+
+
+class FreshMeasureTests(WorkspaceTestCase):
+ """Finding 4: stale per-operation measure output must be refreshed safely."""
+
+ def test_a_missing_owned_directory_is_left_alone(self) -> None:
+ root, _repository, _output = self.workspace()
+
+ diagnostics._remove_owned(root / "missing", root)
+
+ self.assertFalse((root / "missing").exists())
+
+ def test_an_existing_owned_directory_is_removed(self) -> None:
+ root, _repository, _output = self.workspace()
+ victim = root / "snapshots"
+ victim.mkdir(parents=True)
+ (victim / "old.txt").write_text("old", encoding="utf-8")
+
+ diagnostics._remove_owned(victim, root)
+
+ self.assertFalse(victim.exists())
+
+ def test_an_escape_outside_the_output_root_is_refused(self) -> None:
+ root, _repository, _output = self.workspace()
+ inside = root / "root"
+ outside = root / "outside"
+ inside.mkdir()
+ outside.mkdir()
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "outside the operation output"):
+ diagnostics._remove_owned(outside, inside)
+ self.assertTrue(outside.is_dir())
+
+ def test_a_reparse_point_is_refused(self) -> None:
+ root, _repository, _output = self.workspace()
+ inside = root / "root"
+ target = inside / "target"
+ target.mkdir(parents=True)
+ link = inside / "link"
+ os.symlink(target, link, target_is_directory=True)
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "reparse point"):
+ diagnostics._remove_owned(link, inside)
+ self.assertTrue(link.is_symlink())
+
+ def test_fresh_measure_refuses_a_linked_measure_directory_before_creating_it(self) -> None:
+ root, _repository, output = self.workspace()
+ outside = root / "external"
+ outside.mkdir()
+ (outside / "keep.txt").write_bytes(b"keep")
+ link = output / "leak" / "mode" / "scenario" / "measure"
+ link.parent.mkdir(parents=True)
+ os.symlink(outside, link, target_is_directory=True)
+
+ with self.assertRaisesRegex(
+ diagnostics.DiagnosticsError, "outside the operation output"
+ ):
+ diagnostics._fresh_measure(link, output)
+
+ # The linked measure directory and its external target are untouched.
+ self.assertEqual((outside / "keep.txt").read_bytes(), b"keep")
+ self.assertFalse((outside / "snapshots").exists())
+ self.assertTrue(link.is_symlink())
+
+ def test_remove_owned_validates_a_child_that_does_not_exist_yet(self) -> None:
+ root, _repository, output = self.workspace()
+ outside = root / "external"
+ outside.mkdir()
+ link = output / "leak" / "mode" / "scenario" / "measure"
+ link.parent.mkdir(parents=True)
+ os.symlink(outside, link, target_is_directory=True)
+
+ with self.assertRaisesRegex(
+ diagnostics.DiagnosticsError, "outside the operation output"
+ ):
+ diagnostics._remove_owned(link / "snapshots", output)
+
+ self.assertFalse((outside / "snapshots").exists())
+ self.assertTrue(link.is_symlink())
+
+
+class LeakTests(WorkspaceTestCase):
+ def test_leaks_builds_the_probe_and_gates_every_mode_scenario(self) -> None:
+ root, repository, output = self.workspace()
+ tools = _tools(root)
+ runner = FakeRunner(_handler())
+ audits: list[tuple] = []
+
+ def auditor(repository_, architectures, audit_output, *, include_leak_probe):
+ audits.append((repository_, architectures, audit_output, include_leak_probe))
+
+ summaries = diagnostics.test_leaks(
+ repository, output, warmup=2, iterations=3, windows=4, tolerance_bytes=8,
+ tools=tools, runner=runner, host="x64", auditor=auditor,
+ )
+
+ expected = tuple(
+ output / "leak" / mode / scenario / "measure" / "summary.json"
+ for mode in diagnostics.LEAK_MODES for scenario in diagnostics.LEAK_SCENARIOS
+ )
+ self.assertEqual(summaries, expected)
+ self.assertEqual(audits, [(repository, ("x64",), output / "audit", True)])
+ leak_calls = [call for call in runner.calls if call.argv[1:3] == ("-m", "core.leak")]
+ self.assertEqual(len(leak_calls), 1 + 3 * len(expected))
+ self.assertEqual(leak_calls[0].argv[3], "setup")
+ binary = native.bin_directory(repository, "x64", "Release")
+ self.assertEqual(
+ leak_calls[0].argv,
+ (str(diagnostics._PYTHON), "-m", "core.leak", "setup",
+ str(binary / "leak-probe.exe"),
+ *[str(binary / native.BINARIES[name]) for name in diagnostics.PROJECTS]),
+ )
+ measure = next(call for call in leak_calls if call.argv[3] == "measure")
+ self.assertEqual(measure.argv[5], str(tools.umdh))
+ self.assertIn(measure.argv[6], diagnostics.LEAK_MODES)
+ self.assertIn(measure.argv[7], diagnostics.LEAK_SCENARIOS)
+ self.assertEqual(measure.argv[-4:], ("2", "3", "4", "8"))
+ self.assertEqual(measure.argv[4], str(output / "leak" / "setup"))
+ self.assertEqual(
+ measure.env["OBSERVER_OUT_DIR"],
+ str(output / "leak" / diagnostics.LEAK_MODES[0]
+ / diagnostics.LEAK_SCENARIOS[0] / "measure"),
+ )
+ builds = runner.commands("msbuild.exe")
+ self.assertEqual(len(builds), 2)
+ self.assertEqual(
+ builds[1].argv[1],
+ str(repository / "build" / "projects" / "leak-probe.vcxproj"),
+ )
+
+ def test_the_default_auditor_delegates_to_packaging_ops(self) -> None:
+ root, repository, output = self.workspace()
+ recorded: dict[str, object] = {}
+ module = types.ModuleType("packaging_ops")
+
+ def audit_binaries(repository_, architectures, audit_output, *, include_leak_probe=False):
+ recorded["call"] = (repository_, architectures, audit_output, include_leak_probe)
+
+ module.audit_binaries = audit_binaries # type: ignore[attr-defined]
+ with mock.patch.dict(sys.modules, {"packaging_ops": module}):
+ diagnostics._default_auditor(
+ repository, ("x64",), output / "audit", include_leak_probe=True
+ )
+
+ self.assertEqual(
+ recorded["call"], (repository, ("x64",), output / "audit", True)
+ )
+
+ def test_the_default_auditor_creates_its_output_directory_before_delegating(self) -> None:
+ root, repository, output = self.workspace()
+ audit_output = output / "audit"
+ module = types.ModuleType("packaging_ops")
+ seen: dict[str, Path] = {}
+
+ def audit_binaries(repository_, architectures, output_, *, include_leak_probe=False):
+ # The real packaging_ops.audit_binaries.audit refuses a missing output root with a
+ # PackagingError; the default auditor must materialise it before delegating.
+ if not Path(output_).is_dir():
+ raise diagnostics.DiagnosticsError(
+ f"output directory must be an existing directory: {output_}"
+ )
+ seen["output"] = Path(output_)
+
+ module.audit_binaries = audit_binaries # type: ignore[attr-defined]
+ with mock.patch.dict(sys.modules, {"packaging_ops": module}):
+ diagnostics._default_auditor(
+ repository, ("x64",), audit_output, include_leak_probe=True
+ )
+
+ self.assertTrue(audit_output.is_dir())
+ self.assertEqual(seen["output"], audit_output)
+
+ def test_the_default_auditor_refuses_a_linked_output_before_creating_it(self) -> None:
+ root, repository, _output = self.workspace()
+ external = root / "external"
+ external.mkdir()
+ (external / "keep.txt").write_bytes(b"keep")
+ linked = root / "linked"
+ os.symlink(external, linked, target_is_directory=True)
+ audit_output = linked / "audit"
+ module = types.ModuleType("packaging_ops")
+ delegations: list[tuple] = []
+
+ def audit_binaries(repository_, architectures, output_, *, include_leak_probe=False):
+ delegations.append((repository_, architectures, output_, include_leak_probe))
+
+ module.audit_binaries = audit_binaries # type: ignore[attr-defined]
+ with mock.patch.dict(sys.modules, {"packaging_ops": module}):
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "reparse point"):
+ diagnostics._default_auditor(
+ repository, ("x64",), audit_output, include_leak_probe=True
+ )
+
+ # The linked parent and its external target are untouched, the missing audit child was
+ # never materialised outside the operation, and packaging never ran.
+ self.assertEqual(delegations, [])
+ self.assertFalse((external / "audit").exists())
+ self.assertEqual((external / "keep.txt").read_bytes(), b"keep")
+ self.assertTrue(linked.is_symlink())
+
+ def test_the_default_audit_runs_before_the_leak_setup(self) -> None:
+ root, repository, output = self.workspace()
+ module = types.ModuleType("packaging_ops")
+ order: list[str] = []
+
+ def audit_binaries(repository_, architectures, audit_output, *, include_leak_probe=False):
+ order.append("audit")
+
+ module.audit_binaries = audit_binaries # type: ignore[attr-defined]
+ runner = FakeRunner(_handler())
+ with mock.patch.dict(sys.modules, {"packaging_ops": module}):
+ diagnostics.test_leaks(
+ repository, output, warmup=1, iterations=1, windows=3,
+ tools=_tools(root), runner=runner, host="x64",
+ )
+
+ self.assertEqual(order, ["audit"])
+ setup = next(
+ call for call in runner.calls
+ if call.argv[1:4] == ("-m", "core.leak", "setup")
+ )
+ self.assertEqual(setup.argv[3], "setup")
+
+ def test_a_failing_audit_stops_before_the_leak_setup(self) -> None:
+ root, repository, output = self.workspace()
+ runner = FakeRunner()
+
+ def auditor(*args, **kwargs):
+ raise diagnostics.DiagnosticsError("binary audit failed")
+
+ with self.assertRaisesRegex(diagnostics.DiagnosticsError, "binary audit failed"):
+ diagnostics.test_leaks(
+ repository, output, tools=_tools(root), runner=runner,
+ host="x64", auditor=auditor,
+ )
+ self.assertEqual(
+ [call for call in runner.calls if "core.leak" in call.argv], []
+ )
+ self.assertEqual(len(runner.commands("msbuild.exe")), 2)
+
+ def test_a_repeated_leak_run_refreshes_the_measurement_directories(self) -> None:
+ root, repository, output = self.workspace()
+ scope = output / "leak" / diagnostics.LEAK_MODES[0] / diagnostics.LEAK_SCENARIOS[0]
+ for name in ("snapshots", "diffs"):
+ stale = scope / "measure" / name / "old.txt"
+ stale.parent.mkdir(parents=True)
+ stale.write_bytes(b"old")
+
+ diagnostics.test_leaks(
+ repository, output, warmup=1, iterations=1, windows=3,
+ tools=_tools(root), runner=FakeRunner(_handler()), host="x64",
+ auditor=lambda *args, **kwargs: None,
+ )
+
+ for name in ("snapshots", "diffs"):
+ self.assertFalse((scope / "measure" / name / "old.txt").exists())
+
+ def test_leak_measurements_are_validated_before_building(self) -> None:
+ root, repository, output = self.workspace()
+ runner = FakeRunner()
+
+ for options in (
+ {"warmup": 0}, {"iterations": 0}, {"windows": 0}, {"windows": 2},
+ {"tolerance_bytes": -1}, {"warmup": True},
+ ):
+ with self.assertRaises(ValueError):
+ diagnostics.test_leaks(
+ repository, output, tools=_tools(root), runner=runner,
+ host="x64", **options,
+ )
+ self.assertEqual(runner.calls, [])
+
+ def test_leaks_refuse_a_host_that_cannot_run_x64(self) -> None:
+ root, repository, output = self.workspace()
+ runner = FakeRunner()
+
+ with self.assertRaisesRegex(ValueError, "cannot run"):
+ diagnostics.test_leaks(
+ repository, output, tools=_tools(root), runner=runner, host="x86",
+ )
+ self.assertEqual(runner.calls, [])
+
+
+class ToolsTests(unittest.TestCase):
+ def test_the_tool_bag_exposes_the_vcpkg_root(self) -> None:
+ root = Path("C:/tools/vcpkg")
+ tools = diagnostics.DiagnosticsTools(
+ msbuild=Path("C:/tools/MSBuild.exe"),
+ vcpkg=root / "vcpkg.exe",
+ llvm_install=Path("C:/llvm"),
+ llvm_cov=Path("C:/tools/llvm-cov.exe"),
+ llvm_profdata=Path("C:/tools/llvm-profdata.exe"),
+ llvm_runtimes={},
+ asan_runtimes={},
+ umdh=Path("C:/tools/umdh.exe"),
+ )
+
+ self.assertEqual(tools.vcpkg_root, root)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_doctor.py b/build/tests/test_doctor.py
new file mode 100644
index 0000000..889a74c
--- /dev/null
+++ b/build/tests/test_doctor.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+from contextlib import redirect_stdout
+from io import StringIO
+from pathlib import Path
+from types import SimpleNamespace
+import sys
+import tomllib
+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
+
+
+# The interpreter pin lives in the project configuration; read it instead of
+# duplicating the version here so the doctor test cannot drift from the lock.
+PINNED_PYTHON = tomllib.loads(
+ (BUILD_ROOT / "pyproject.toml").read_text(encoding="utf-8")
+)["project"]["requires-python"].removeprefix("==")
+
+
+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()
+PROFILES = (("x86", object()), ("x64", object()))
+FUZZERS = (("x86", object()), ("x64", 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),
+ mock.patch.object(doctor, "resolve_profile_runtimes", return_value=PROFILES),
+ mock.patch.object(doctor, "resolve_fuzzer_runtimes", return_value=FUZZERS),
+ )
+
+ 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, \
+ self.patches()[4] as profiles, self.patches()[5] as fuzzers:
+ report = doctor.doctor_report()
+ self.assertEqual(report, doctor.doctor_report())
+ self.assertEqual(
+ report,
+ (
+ doctor.Probe("python", "OK", PINNED_PYTHON),
+ 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"),
+ doctor.Probe("profile-runtimes", "OK", "profile x86/x64"),
+ doctor.Probe("fuzzer-runtimes", "OK", "libFuzzer x86/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))
+ self.assertEqual(profiles.call_args, mock.call(TOOLCHAIN))
+ self.assertEqual(fuzzers.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")
+ missing_profile = FileNotFoundError("missing profile runtime clang_rt.profile-i386.lib")
+ missing_fuzzer = FileNotFoundError("missing libFuzzer runtime clang_rt.fuzzer-i386.lib")
+ 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),
+ mock.patch.object(doctor, "resolve_profile_runtimes", side_effect=missing_profile),
+ mock.patch.object(doctor, "resolve_fuzzer_runtimes", side_effect=missing_fuzzer),
+ ):
+ report = doctor.doctor_report()
+ self.assertEqual(
+ [item.status for item in report],
+ ["OK", "OK", "OK", "MISSING", "OK", "MISSING", "MISSING"],
+ )
+ self.assertEqual(report[3].detail, "quality missing")
+ self.assertEqual(report[5].detail, "missing profile runtime clang_rt.profile-i386.lib")
+ self.assertEqual(report[6].detail, "missing libFuzzer runtime clang_rt.fuzzer-i386.lib")
+ 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())
+ self.assertIn(
+ "profile-runtimes\tMISSING\tmissing profile runtime clang_rt.profile-i386.lib\n",
+ output.getvalue(),
+ )
+ self.assertIn(
+ "fuzzer-runtimes\tMISSING\tmissing libFuzzer runtime clang_rt.fuzzer-i386.lib\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,
+ mock.patch.object(doctor, "resolve_profile_runtimes") as profiles,
+ mock.patch.object(doctor, "resolve_fuzzer_runtimes") as fuzzers,
+ ):
+ report = doctor.doctor_report()
+ self.assertEqual([item.status for item in report], ["MISSING"] * 7)
+ self.assertIn(f"requires =={PINNED_PYTHON}, 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()
+ profiles.assert_not_called()
+ fuzzers.assert_not_called()
+
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_host.py b/build/tests/test_host.py
new file mode 100644
index 0000000..7b41a6e
--- /dev/null
+++ b/build/tests/test_host.py
@@ -0,0 +1,90 @@
+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.assertEqual(route.fuzz, ("x86", "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.assertEqual(route.fuzz, ())
+ self.assertFalse(route.run_x64_specialists)
+ self.assertEqual(
+ [item.gate for item in route.deferred],
+ ["tests", "package-runtime", "coverage", "asan", "ubsan", "leaks", "fuzz"],
+ )
+
+ def test_verify_route_runs_the_x86_replay_gate_without_the_x64_specialists(self) -> None:
+ route = verify_route(("x86",), "x86")
+
+ self.assertEqual(route.runnable, ("x86",))
+ self.assertEqual(route.coverage, ())
+ self.assertEqual(route.asan, ("x86",))
+ self.assertEqual(route.ubsan, ())
+ self.assertEqual(route.fuzz, ("x86",))
+ self.assertFalse(route.run_x64_specialists)
+ self.assertEqual(route.deferred, ())
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_leak_worker.py b/build/tests/test_leak_worker.py
new file mode 100644
index 0000000..5a87cef
--- /dev/null
+++ b/build/tests/test_leak_worker.py
@@ -0,0 +1,529 @@
+"""Standalone worker tests for the ``core.leak`` UMDH worker CLI.
+
+These assertions were preserved from ``test_leak_graph.py`` when the retired
+``graphs.leak`` composition was dropped. They exercise the real setup, capture
+and comparison stages, so the worker's evidence and error handling stays covered.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import inspect
+import io
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+import core.leak as leak # noqa: E402
+from core.leak import LeakError, MODES as LEAK_MODES, SCENARIOS as LEAK_SCENARIOS, main as leak_main # noqa: E402
+
+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"))
+
+ @staticmethod
+ def probe_lines(windows: int = 3) -> list[str]:
+ labels = ("baseline", *(f"window-{index}" for index in range(1, windows + 1)))
+ return [
+ "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed",
+ *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in labels),
+ "OBSERVER_LEAK_PROBE|DONE|pid=77|",
+ ]
+
+ def test_measure_captures_diffs_and_judges_in_one_worker_invocation(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()
+ output = root / "evidence"
+ output.mkdir()
+ 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:
+ settings = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else ""
+ return subprocess.CompletedProcess(argv, 0, settings)
+ self.assertEqual(str(root), _options["env"]["_NT_SYMBOL_PATH"])
+ self.assertEqual("1", _options["env"]["OANOCACHE"])
+ Path(argv[-1].removeprefix("-f:")).write_text(
+ "+ 500 (x) 1 allocs BackTrace ABC\nTotal increase == 500\n"
+ if "-d" in argv else "BackTrace 1\n",
+ encoding="utf-8",
+ )
+ return subprocess.CompletedProcess(argv, 0, "")
+
+ with self.output(output), mock.patch("core.leak.psutil.Popen", return_value=process) as popen, mock.patch(
+ "core.leak.subprocess.run", side_effect=snapshot
+ ) as invoked:
+ leak_main(("measure", str(root), str(umdh), "operations", "malformed", "1", "2", "3", "0"))
+
+ 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)
+ capture = json.loads((output / "capture.json").read_text())
+ self.assertEqual(77, capture["processId"])
+ self.assertIs(True, capture["stackTracesFromProcessStart"])
+ self.assertTrue((output / "probe.stderr.log").is_file())
+ self.assertEqual(
+ sorted(item.name for item in (output / "diffs").iterdir()),
+ [
+ "overall.json", "overall.txt", "window-1.json", "window-1.txt",
+ "window-2.json", "window-2.txt",
+ ],
+ )
+ self.assertEqual(
+ json.loads((output / "diffs/window-1.json").read_text()),
+ {
+ "label": "window-1", "totalIncrease": 500,
+ "positiveStacks": {"ABC": 500}, "report": "window-1.txt",
+ },
+ )
+ summary = json.loads((output / "summary.json").read_text())
+ self.assertEqual([500, 500], summary["totalGrowthByWindow"])
+ self.assertEqual(500, summary["overallGrowthBytes"])
+ self.assertEqual(["ABC"], summary["repeatedGrowingStacks"])
+ self.assertIs(False, summary["passed"])
+ self.assertEqual(
+ (1, 2, 3, 0),
+ (
+ summary["warmupRounds"], summary["iterationsPerWindow"],
+ summary["windows"], summary["toleranceBytes"],
+ ),
+ )
+
+ 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(("measure", str(root), str(umdh), "operations", "malformed", "1", "2", "3", "0"))
+ 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(("measure", str(root), str(umdh), "operations", "malformed", "1", "2", "3", "0"))
+ 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(("measure", str(root), str(umdh), "operations", "malformed", "1", "2", "3", "0"))
+ 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(
+ "Total increase == 0\n" if "-d" in argv else "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(("measure", str(root), str(umdh), "operations", "malformed", "1", "2", "3", "0"))
+ popen.assert_called_once()
+ self.assertIs(
+ False,
+ json.loads((elevation / "capture.json").read_text())["stackTracesFromProcessStart"],
+ )
+
+ preset = root / "gflags-preset"
+ preset.mkdir()
+ 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|",
+ ])
+
+ def already_enabled(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ if Path(argv[0]) == gflags:
+ return subprocess.CompletedProcess(
+ argv, 0, "Current Registry Settings for leak-probe.exe executable are: 00001000"
+ )
+ Path(argv[-1].removeprefix("-f:")).write_text(
+ "Total increase == 0\n" if "-d" in argv else "BackTrace\n", encoding="utf-8"
+ )
+ return subprocess.CompletedProcess(argv, 0, "")
+
+ with self.output(preset), mock.patch(
+ "core.leak.subprocess.run", side_effect=already_enabled
+ ) as invoked, mock.patch("core.leak.psutil.Popen", return_value=process):
+ leak_main(("measure", str(root), str(umdh), "operations", "malformed", "1", "2", "3", "0"))
+ self.assertEqual(
+ [call.args[0] for call in invoked.call_args_list if Path(call.args[0][0]) == gflags],
+ [[str(gflags), "/i", "leak-probe.exe"]],
+ )
+ self.assertIs(
+ True,
+ json.loads((preset / "capture.json").read_text())["stackTracesFromProcessStart"],
+ )
+
+ 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.assertEqual((False, True), 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.assertEqual((False, False), leak._enable_stack_traces(missing, "probe.exe"))
+
+ gflags = root / "gflags.exe"
+ gflags.touch()
+
+ denied = OSError("requires elevation")
+ denied.winerror = 740 # type: ignore[attr-defined]
+ with mock.patch("core.leak.subprocess.run", side_effect=denied) as blocked:
+ self.assertEqual((False, False), leak._enable_stack_traces(gflags, "probe.exe"))
+ blocked.assert_called_once()
+
+ refused = OSError("access denied")
+ refused.winerror = 5 # type: ignore[attr-defined]
+ with mock.patch("core.leak.subprocess.run", side_effect=refused), self.assertRaises(OSError):
+ leak._enable_stack_traces(gflags, "probe.exe")
+
+ 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.assertEqual((True, True), 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.assertEqual((True, True), 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_measure_preserves_growth_evidence_and_the_gate_owns_the_verdict(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ (root / "leak-probe.exe").touch()
+ umdh = root / "umdh.exe"
+ umdh.touch()
+
+ def runner(report: str):
+ def run(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
+ destination = Path(argv[-1].removeprefix("-f:"))
+ destination.write_text(
+ report if "-d" in argv else "BackTrace\n", encoding="utf-8"
+ )
+ return subprocess.CompletedProcess(argv, 0, "")
+
+ return run
+
+ leaking = "\n".join(("+ 500 (x) 1 allocs BackTrace ABC", "Total increase == 500"))
+ for index, (report, passed) in enumerate(((leaking, False), ("Total decrease == 7", True))):
+ output = root / f"evidence-{index}"
+ output.mkdir()
+ with self.subTest(passed=passed), self.output(output), mock.patch(
+ "core.leak.psutil.Popen", return_value=FakeProbe(self.probe_lines())
+ ), mock.patch("core.leak.subprocess.run", side_effect=runner(report)):
+ leak_main(("measure", str(root), str(umdh), "operations", "malformed", "1", "2", "3", "100"))
+ summary = json.loads((output / "summary.json").read_text())
+ self.assertIs(passed, summary["passed"])
+ # The measurement always publishes its evidence; only the gate fails.
+ with self.output(output):
+ if passed:
+ self.assertEqual(0, leak_main(("gate", str(output / "summary.json"))))
+ else:
+ with self.assertRaisesRegex(LeakError, "sustained"):
+ leak_main(("gate", str(output / "summary.json")))
+ self.assertEqual(
+ -7,
+ json.loads((root / "evidence-1/diffs/window-1.json").read_text())["totalIncrease"],
+ )
+ self.assertIs(
+ False,
+ json.loads((root / "evidence-1/capture.json").read_text())["stackTracesFromProcessStart"],
+ )
+
+ failures = ((2, "any", "failed"), (0, None, "failed"), (0, "no totals", "no total"))
+ for index, (returncode, content, message) in enumerate(failures):
+ output = root / f"unusable-{index}"
+ output.mkdir()
+
+ def compare(
+ argv: list[str], *, _code: int = returncode,
+ _content: str | None = content, **_options: object,
+ ) -> subprocess.CompletedProcess[str]:
+ destination = Path(argv[-1].removeprefix("-f:"))
+ if "-d" not in argv:
+ destination.write_text("BackTrace\n", encoding="utf-8")
+ return subprocess.CompletedProcess(argv, 0, "")
+ if _content is not None:
+ destination.write_text(_content, encoding="utf-8")
+ return subprocess.CompletedProcess(argv, _code, "bad")
+
+ with self.subTest(message=message), self.output(output), mock.patch(
+ "core.leak.psutil.Popen", return_value=FakeProbe(self.probe_lines())
+ ), mock.patch(
+ "core.leak.subprocess.run", side_effect=compare
+ ), self.assertRaisesRegex(LeakError, message):
+ leak_main(("measure", str(root), str(umdh), "operations", "malformed", "1", "2", "3", "0"))
+
+ 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, {})
+
+
+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..6b1a010
--- /dev/null
+++ b/build/tests/test_main.py
@@ -0,0 +1,1208 @@
+from __future__ import annotations
+
+import argparse
+from contextlib import redirect_stderr, redirect_stdout
+from io import StringIO
+import inspect
+import json
+import os
+from pathlib import Path
+import subprocess
+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 main # noqa: E402
+from core import result_export # noqa: E402
+from core.host import DeferredGate, VerifyRoute # noqa: E402
+
+import diagnostics as diagnostics_module # noqa: E402
+import native as native_module # noqa: E402
+import packaging_ops as packaging_module # noqa: E402
+import source_checks as source_module # noqa: E402
+
+
+_NATIVE_TOOLS = SimpleNamespace(msbuild=Path("msbuild.exe"), vcpkg=Path("vcpkg/vcpkg.exe"))
+_TOOLCHAIN = SimpleNamespace(llvm_dir=Path("llvm"))
+_FULL_ROUTE = VerifyRoute(("x64",), ("x64",), ("x86", "x64"), ("x64",), ("x86", "x64"), ())
+_EMPTY_ROUTE = VerifyRoute(
+ (), (), (), (), (), (DeferredGate("tests", "arm64", "host cannot execute arm64 tests"),)
+)
+
+
+def _spec(function, **options):
+ """An autospec mock, so a call with a wrong keyword fails against the real signature."""
+
+ return mock.create_autospec(function, **options)
+
+
+def _processes() -> main._Processes:
+ return main._Processes(mock.MagicMock(), mock.MagicMock(), mock.MagicMock())
+
+
+class Harness(unittest.TestCase):
+ def setUp(self) -> None:
+ self._temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self._temporary.cleanup)
+ self.repo = Path(self._temporary.name)
+
+ self.build = _spec(native_module.build, return_value=(Path("out/build"),))
+ self.restore = _spec(native_module.restore, return_value=(Path("out/restore"),))
+ self.test = _spec(native_module.test, return_value=(Path("out/test"),))
+ self.source_checks = _spec(source_module.source_checks, return_value=(Path("out/source"),))
+ self.compiler_analysis = _spec(source_module.compiler_analysis, return_value=(Path("out/analysis"),))
+ self.audit = _spec(packaging_module.audit_binaries, return_value=(Path("out/audit"),))
+ self.package = _spec(packaging_module.package, return_value=SimpleNamespace(
+ archives=(Path("out/packages/renpy.zip"),), deferred=()
+ ))
+ self.coverage = _spec(diagnostics_module.test_coverage, return_value=(Path("out/coverage"),))
+ self.sanitizer = _spec(diagnostics_module.test_sanitizer, return_value=(Path("out/sanitizer"),))
+ self.fuzz = _spec(diagnostics_module.fuzz, return_value=(Path("out/fuzz"),))
+ self.leaks = _spec(diagnostics_module.test_leaks, return_value=(Path("out/leaks"),))
+ self.export = mock.MagicMock(return_value=Path("export"))
+
+ self.processes = _processes()
+
+ self.patches = [
+ mock.patch.object(main.native, "locate_tools", return_value=_NATIVE_TOOLS),
+ mock.patch.object(main.native, "build", self.build),
+ mock.patch.object(main.native, "restore", self.restore),
+ mock.patch.object(main.native, "test", self.test),
+ mock.patch.object(main.source_checks, "locate_tools", return_value="source-tools"),
+ mock.patch.object(main.source_checks, "locate_analysis_tools", return_value="analysis-tools"),
+ mock.patch.object(main.source_checks, "source_checks", self.source_checks),
+ mock.patch.object(main.source_checks, "compiler_analysis", self.compiler_analysis),
+ mock.patch.object(main.packaging_ops, "locate_packaging_tools", return_value="packaging-tools"),
+ mock.patch.object(main.packaging_ops, "audit_binaries", self.audit),
+ mock.patch.object(main.packaging_ops, "package", self.package),
+ mock.patch.object(main.diagnostics, "test_coverage", self.coverage),
+ mock.patch.object(main.diagnostics, "test_sanitizer", self.sanitizer),
+ mock.patch.object(main.diagnostics, "fuzz", self.fuzz),
+ mock.patch.object(main.diagnostics, "test_leaks", self.leaks),
+ mock.patch.object(main, "discover_msvc_toolchain", return_value=_TOOLCHAIN),
+ mock.patch.object(main, "resolve_llvm", return_value=SimpleNamespace(path=Path("llvm-cov"))),
+ mock.patch.object(main, "resolve_profile_runtime", return_value=SimpleNamespace(path=Path("profile"))),
+ mock.patch.object(main, "resolve_ubsan_runtime", return_value=SimpleNamespace(path=Path("ubsan"))),
+ mock.patch.object(main, "resolve_fuzzer_runtime", return_value=SimpleNamespace(path=Path("fuzz-rt"))),
+ mock.patch.object(main, "resolve_asan_runtimes", return_value=(
+ ("x86", SimpleNamespace(path=Path("asan-x86"))),
+ ("x64", SimpleNamespace(path=Path("asan-x64"))),
+ )),
+ mock.patch.object(main, "resolve_umdh", return_value=SimpleNamespace(path=Path("umdh"))),
+ mock.patch.object(main, "detect_host_architecture", return_value="x64"),
+ mock.patch.object(main, "_prioritize", return_value=None),
+ mock.patch.object(main, "_restore_priority"),
+ mock.patch.object(main, "_processes", return_value=self.processes),
+ mock.patch.object(main, "_export", self.export),
+ ]
+ for patcher in self.patches:
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def invoke(self, argv: list[str]) -> int:
+ return main.main(argv)
+
+ def output(self, name: str) -> Path:
+ return self.repo / "out" / "reports" / name
+
+
+class DispatchTests(Harness):
+ def test_build_and_test_route_to_the_native_helpers(self) -> None:
+ self.assertEqual(self.invoke([
+ "build", "-Repository", str(self.repo), "-Arch", "x64", "-Config", "Release", "-Jobs", "3",
+ ]), 0)
+ self.build.assert_called_once_with(
+ self.repo, ("x64",), ("Release",), jobs=3, tools=_NATIVE_TOOLS,
+ runner=self.processes.native,
+ )
+
+ self.invoke([
+ "test", "-Repository", str(self.repo), "-Arch", "x86,x64", "-Config", "Debug,Release",
+ "-Corpus", str(self.repo / "golden"),
+ ])
+ self.test.assert_called_once_with(
+ self.repo, ("x86", "x64"), ("Debug", "Release"),
+ corpus=self.repo / "golden", jobs=None, tools=_NATIVE_TOOLS,
+ runner=self.processes.native,
+ )
+
+ def test_restore_defaults_and_asan_flavors(self) -> None:
+ self.invoke(["restore", "-Repository", str(self.repo)])
+ self.restore.assert_called_once_with(
+ self.repo, ("x64",), tools=_NATIVE_TOOLS, runner=self.processes.native
+ )
+ self.processes.native.assert_not_called()
+
+ self.restore.reset_mock()
+ self.invoke([
+ "restore", "-Repository", str(self.repo), "-Arch", "arm64,x86", "-RestoreFlavor", "all",
+ ])
+ self.restore.assert_called_once_with(
+ self.repo, ("arm64", "x86"), tools=_NATIVE_TOOLS, runner=self.processes.native
+ )
+ self.assertEqual(self.processes.native.call_count, 1)
+ self.assertIn("observer-x86-windows-static-asan", self.processes.native.call_args.args[0])
+
+ self.restore.reset_mock()
+ self.processes.native.reset_mock()
+ self.invoke(["restore", "-Repository", str(self.repo), "-Arch", "arm64", "-RestoreFlavor", "asan"])
+ self.restore.assert_not_called()
+ self.processes.native.assert_not_called()
+
+ def test_source_and_analysis_commands_use_their_own_output_tree(self) -> None:
+ self.invoke(["source-checks", "-Repository", str(self.repo)])
+ self.source_checks.assert_called_once_with(
+ self.repo, ("x64",), self.output("source-checks"), jobs=4, tools="source-tools",
+ runner=self.processes.native,
+ )
+ self.invoke(["compiler-analysis", "-Repository", str(self.repo), "-Jobs", "2"])
+ self.compiler_analysis.assert_called_once_with(
+ self.repo, ("x64",), self.output("compiler-analysis"), jobs=2, tools="analysis-tools",
+ runner=self.processes.native,
+ )
+
+ def test_diagnostic_commands_route_with_only_the_tools_they_need(self) -> None:
+ self.invoke(["test-coverage", "-Repository", str(self.repo)])
+ tools = self.coverage.call_args.kwargs["tools"]
+ self.assertIsInstance(tools, main.diagnostics.DiagnosticsTools)
+ self.assertEqual(tools.asan_runtimes, {})
+ self.assertEqual(tools.umdh, Path())
+ self.assertEqual(tools.llvm_cov, Path("llvm-cov"))
+ self.assertEqual(dict(tools.llvm_runtimes), {"x64": Path("profile")})
+ self.assertEqual(set(tools.asan_runtimes), set())
+ self.assertEqual(self.coverage.call_args.kwargs["corpus"], None)
+ self.assertEqual(self.coverage.call_args.kwargs["runner"], self.processes.tools)
+
+ self.invoke(["test-coverage", "-Repository", str(self.repo), "-Corpus", str(self.repo / "g")])
+ self.assertEqual(self.coverage.call_args.kwargs["corpus"], self.repo / "g")
+
+ self.invoke(["test-asan", "-Repository", str(self.repo), "-Arch", "x86,x64"])
+ asan = self.sanitizer.call_args.kwargs["tools"]
+ self.assertEqual(set(asan.asan_runtimes), {"x86", "x64"})
+ self.assertEqual(asan.llvm_runtimes, {})
+ self.sanitizer.assert_called_with(
+ self.repo, "asan", ("x86", "x64"), self.output("test-asan"), tools=asan,
+ runner=self.processes.tools, jobs=None,
+ )
+
+ self.invoke(["test-ubsan", "-Repository", str(self.repo)])
+ ubsan = self.sanitizer.call_args.kwargs["tools"]
+ self.assertEqual(ubsan.asan_runtimes, {})
+ self.assertEqual(ubsan.llvm_cov, Path())
+ self.assertEqual(dict(ubsan.llvm_runtimes), {"x64": Path("ubsan")})
+ self.invoke(["test-ubsan", "-Repository", str(self.repo), "-Arch", "x86"])
+ self.assertEqual(self.sanitizer.call_args.kwargs["tools"].llvm_runtimes, {})
+
+ self.invoke(["test-leaks", "-Repository", str(self.repo), "-LeakWarmup", "2",
+ "-LeakIterations", "6", "-LeakWindows", "5", "-LeakToleranceBytes", "9"])
+ self.assertEqual(self.leaks.call_args.args[2:6], (2, 6, 5, 9))
+ self.assertEqual(self.leaks.call_args.kwargs["tools"].umdh, Path("umdh"))
+
+ self.invoke(["fuzz", "-Repository", str(self.repo), "-Arch", "x86,x64",
+ "-FuzzSeconds", "17", "-FuzzTarget", "renpy"])
+ fuzz_tools = self.fuzz.call_args.kwargs["tools"]
+ self.assertEqual(fuzz_tools.llvm_cov, Path())
+ self.assertEqual(
+ dict(fuzz_tools.llvm_runtimes), {"x86": Path("fuzz-rt"), "x64": Path("fuzz-rt")}
+ )
+ self.fuzz.assert_called_once_with(
+ self.repo, ("x86", "x64"), ("renpy",), 17, self.output("fuzz"),
+ tools=self.fuzz.call_args.kwargs["tools"], runner=self.processes.tools, jobs=None,
+ )
+
+ def test_audit_and_package_build_release_then_gate(self) -> None:
+ self.invoke(["audit-binaries", "-Repository", str(self.repo), "-Module", "renpy,rpgmaker"])
+ self.build.assert_called_once_with(
+ self.repo, ("x64",), ("Release",), jobs=None, tools=_NATIVE_TOOLS,
+ runner=self.processes.native,
+ )
+ self.audit.assert_called_once_with(
+ self.repo, ("x64",), self.output("audit-binaries"),
+ modules=("renpy", "rpgmaker"), tools="packaging-tools",
+ runner=self.processes.packaging,
+ )
+
+ self.build.reset_mock()
+ self.invoke([
+ "package", "-Repository", str(self.repo), "-Module", "all", "-ExportDir", str(self.repo / "ev"),
+ ])
+ self.build.assert_called_once_with(
+ self.repo, ("x64",), ("Release",), jobs=None, tools=_NATIVE_TOOLS,
+ runner=self.processes.native,
+ )
+ self.package.assert_called_once_with(
+ self.repo, ("x64",), self.repo / "out" / "reports" / "package",
+ modules=("renpy", "rpgmaker", "zanzarah"), smoke_architectures=("x64",),
+ tools="packaging-tools", runner=self.processes.packaging, host="x64",
+ )
+ destination, command, operations = self.export.call_args.args
+ self.assertEqual((destination, command), (self.repo / "ev", "package"))
+ self.assertEqual(operations[0].status, result_export.PASSED)
+ self.assertIn(
+ self.repo / "out" / "reports" / "package" / "packages" / "packages.json",
+ self.export.call_args.kwargs["packages"],
+ )
+
+ def test_package_export_captures_a_failure_before_reraising(self) -> None:
+ self.package.side_effect = main.packaging_ops.PackagingError("no binary")
+ args = main._parser().parse_args(
+ ["package", "-Repository", str(self.repo), "-ExportDir", str(self.repo / "ev")]
+ )
+ with self.assertRaises(main.packaging_ops.PackagingError):
+ main._package_command(self.repo, args, self.processes)
+ operations = self.export.call_args.args[2]
+ self.assertEqual(operations[0].status, result_export.FAILED)
+ self.assertIn("no binary", operations[0].detail)
+
+ def test_package_without_export_does_not_export(self) -> None:
+ self.invoke(["package", "-Repository", str(self.repo)])
+ self.export.assert_not_called()
+
+ def test_package_success_logs_the_captured_output(self) -> None:
+ def fake_package(repository, architectures, output, **options):
+ print("packaging stdout")
+ return SimpleNamespace(archives=(Path("out/packages/renpy.zip"),), deferred=())
+
+ self.package.side_effect = fake_package
+ with redirect_stdout(StringIO()):
+ self.invoke([
+ "package", "-Repository", str(self.repo), "-ExportDir", str(self.repo / "ev"),
+ ])
+ operations = self.export.call_args.args[2]
+ self.assertEqual(operations[0].status, result_export.PASSED)
+ self.assertIn("packaging stdout", operations[0].log)
+
+ def test_package_failure_without_export_reraises_without_exporting(self) -> None:
+ self.package.side_effect = main.packaging_ops.PackagingError("boom")
+ args = main._parser().parse_args(["package", "-Repository", str(self.repo)])
+ with self.assertRaises(main.packaging_ops.PackagingError):
+ main._package_command(self.repo, args, self.processes)
+ self.export.assert_not_called()
+
+ def test_package_command_exports_current_evidence_without_stale_archives(self) -> None:
+ stale = self.repo / "out" / "packages" / "renpy-3.0.0-x64.zip"
+ stale.parent.mkdir(parents=True)
+ stale.write_text("old release", encoding="utf-8")
+
+ def fail_after_audit(repository, architectures, output, **options):
+ audit = Path(output) / "audit" / "x64" / "renpy" / "binskim.sarif"
+ audit.parent.mkdir(parents=True)
+ audit.write_text("{}", encoding="utf-8")
+ leftover = Path(output) / "packages" / "staging-partial.zip"
+ leftover.parent.mkdir(parents=True)
+ leftover.write_text("partial", encoding="utf-8")
+ raise main.packaging_ops.PackagingError("audit failed")
+
+ self.package.side_effect = fail_after_audit
+ args = main._parser().parse_args(
+ ["package", "-Repository", str(self.repo), "-ExportDir", str(self.repo / "ev")]
+ )
+ with self.assertRaises(main.packaging_ops.PackagingError):
+ main._package_command(self.repo, args, self.processes)
+ root = self.repo / "out" / "reports" / "package"
+ self.package.assert_called_once_with(
+ self.repo, ("x64",), root, modules=("renpy", "rpgmaker", "zanzarah"),
+ smoke_architectures=("x64",), tools="packaging-tools",
+ runner=self.processes.packaging, host="x64",
+ )
+ operations = self.export.call_args.args[2]
+ self.assertEqual(operations[0].status, result_export.FAILED)
+ self.assertIn("audit failed", operations[0].detail)
+ names = [path.name for path in operations[0].reports]
+ self.assertIn("binskim.sarif", names)
+ self.assertNotIn("staging-partial.zip", names)
+ self.assertNotIn("renpy-3.0.0-x64.zip", names)
+ self.assertTrue(stale.exists())
+ self.assertNotIn("packages", self.export.call_args.kwargs)
+
+ def test_package_command_success_exports_current_audit_evidence(self) -> None:
+ def ok_package(repository, architectures, output, **options):
+ root = Path(output)
+ audit = root / "audit" / "x64" / "renpy" / "binskim.sarif"
+ audit.parent.mkdir(parents=True)
+ audit.write_text("{}", encoding="utf-8")
+ packages = root / "packages"
+ (packages / "x64").mkdir(parents=True)
+ archive = packages / "x64" / "renpy-3.1.0-x64.zip"
+ archive.write_text("zip", encoding="utf-8")
+ (packages / "packages.json").write_text("{}", encoding="utf-8")
+ return SimpleNamespace(archives=(archive,), deferred=())
+
+ self.package.side_effect = ok_package
+ self.invoke([
+ "package", "-Repository", str(self.repo), "-ExportDir", str(self.repo / "ev"),
+ ])
+ operations = self.export.call_args.args[2]
+ self.assertEqual(operations[0].status, result_export.PASSED)
+ # The current audit evidence is published, while the release payload stays in packages.
+ self.assertEqual([path.name for path in operations[0].reports], ["binskim.sarif"])
+ packages = self.export.call_args.kwargs["packages"]
+ self.assertEqual(
+ sorted(path.name for path in packages), ["packages.json", "renpy-3.1.0-x64.zip"]
+ )
+
+ def test_clean_and_doctor_bypass_the_build_handlers(self) -> None:
+ with mock.patch.object(main, "clean_output", return_value=(Path("out/native"),)) as clean, \
+ mock.patch.object(main, "doctor_main", return_value=0) as doctor:
+ self.assertEqual(self.invoke(["doctor"]), 0)
+ doctor.assert_called_once_with(())
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ code = self.invoke(["clean", "-Repository", str(self.repo), "-CleanMode", "reports"])
+ self.assertEqual(code, 0)
+ clean.assert_called_once_with(self.repo, "reports")
+ self.assertIn("native", stdout.getvalue())
+
+
+class VerifyTests(Harness):
+ def test_verify_source_runs_the_flat_source_and_python_gates(self) -> None:
+ with mock.patch.object(main, "_python_coverage", return_value=(Path("coverage.txt"),)) as coverage:
+ result = self.invoke([
+ "verify-source", "-Repository", str(self.repo), "-ExportDir", str(self.repo / "ev"),
+ ])
+ self.assertEqual(result, 0)
+ self.source_checks.assert_called_once_with(
+ self.repo, ("x64",), self.output("verify-source") / "source", jobs=4, tools="source-tools",
+ runner=self.processes.native,
+ )
+ coverage.assert_called_once_with(
+ self.repo, self.output("verify-source") / "python-coverage", self.processes
+ )
+ operations = self.export.call_args.args[2]
+ self.assertEqual([item.name for item in operations], ["source", "python-coverage"])
+ self.assertEqual(self.export.call_args.kwargs["packages"], ())
+
+ def test_verify_arch_runs_every_routed_gate_and_blocks_package_on_a_failure(self) -> None:
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE):
+ self.test.side_effect = RuntimeError("tests failed")
+ stderr = StringIO()
+ with redirect_stderr(stderr):
+ result = self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "x64",
+ "-ExportDir", str(self.repo / "ev"),
+ ])
+ self.assertEqual(result, 1)
+ operations = {item.name: item for item in self.export.call_args.args[2]}
+ self.assertEqual(operations["tests-x64"].status, result_export.FAILED)
+ for name in ("source", "compiler-analysis", "coverage", "asan", "ubsan", "fuzz", "leaks", "audit"):
+ self.assertEqual(operations[name].status, result_export.PASSED)
+ self.assertEqual(operations["package"].status, result_export.DEFERRED)
+ self.assertIn("failed gates: tests-x64", stderr.getvalue())
+ self.assertEqual(self.export.call_args.kwargs["packages"], ())
+
+ def test_verify_arch_packages_on_success_with_flat_archives(self) -> None:
+ def write_package(repository, architectures, output, **options):
+ directory = Path(output) / "packages" / "x64"
+ directory.mkdir(parents=True)
+ archive = directory / "renpy-3.1.0-x64.zip"
+ archive.write_text("zip", encoding="utf-8")
+ manifest = Path(output) / "packages" / "packages.json"
+ manifest.write_text("{}", encoding="utf-8")
+ return SimpleNamespace(archives=(archive,), deferred=())
+
+ self.package.side_effect = write_package
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE):
+ result = self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "x64",
+ "-ExportDir", str(self.repo / "ev"),
+ ])
+ self.assertEqual(result, 0)
+ packages = self.export.call_args.kwargs["packages"]
+ self.assertEqual([path.name for path in packages], ["packages.json", "renpy-3.1.0-x64.zip"])
+ self.coverage.assert_called_once()
+ self.leaks.assert_called_once()
+ self.test.assert_any_call(
+ self.repo, ("x64",), ("Debug", "Release"), corpus=None, jobs=None,
+ tools=_NATIVE_TOOLS, runner=self.processes.native, host="x64",
+ )
+ # verify already built the Release tree, so packaging must not trigger a second build.
+ self.build.assert_not_called()
+
+ def test_verify_arch_retains_package_failure_evidence_from_a_dedicated_root(self) -> None:
+ def fail_after_audit(repository, architectures, output, **options):
+ root = Path(output)
+ audit = root / "audit" / "x64" / "renpy"
+ audit.mkdir(parents=True)
+ (audit / "binskim.sarif").write_text("{}", encoding="utf-8")
+ (audit / "headers.txt").write_text("headers", encoding="utf-8")
+ partial = root / "packages" / "renpy-3.1.0-x64.zip"
+ partial.parent.mkdir(parents=True, exist_ok=True)
+ partial.write_text("partial release", encoding="utf-8")
+ raise main.packaging_ops.PackagingError("audit failed")
+
+ self.package.side_effect = fail_after_audit
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE), \
+ redirect_stdout(StringIO()), redirect_stderr(StringIO()):
+ result = self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "x64",
+ "-ExportDir", str(self.repo / "ev"),
+ ])
+ self.assertEqual(result, 1)
+ # Packaging owns a dedicated root, so its current audit evidence can never be confused
+ # with the independent audit gate's tree.
+ self.package.assert_called_once_with(
+ self.repo, ("x64",), self.output("verify-arch") / "package",
+ modules=("renpy", "rpgmaker", "zanzarah"), smoke_architectures=("x64",),
+ tools="packaging-tools", runner=self.processes.packaging, host="x64",
+ )
+ operations = {item.name: item for item in self.export.call_args.args[2]}
+ self.assertEqual(operations["package"].status, result_export.FAILED)
+ self.assertIn("audit failed", operations["package"].detail)
+ names = [path.name for path in operations["package"].reports]
+ self.assertEqual(names, ["binskim.sarif", "headers.txt"])
+ self.assertIn("audit failed", operations["package"].log)
+ # A blocked package never exports a release payload.
+ self.assertEqual(self.export.call_args.kwargs["packages"], ())
+
+ def test_verify_arch_package_success_keeps_audit_and_excludes_packages(self) -> None:
+ def write_package(repository, architectures, output, **options):
+ root = Path(output)
+ audit = root / "audit" / "x64" / "renpy" / "binskim.sarif"
+ audit.parent.mkdir(parents=True)
+ audit.write_text("{}", encoding="utf-8")
+ packages = root / "packages"
+ (packages / "x64").mkdir(parents=True)
+ archive = packages / "x64" / "renpy-3.1.0-x64.zip"
+ archive.write_text("zip", encoding="utf-8")
+ (packages / "packages.json").write_text("{}", encoding="utf-8")
+ return SimpleNamespace(archives=(archive,), deferred=())
+
+ self.package.side_effect = write_package
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE):
+ result = self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "x64",
+ "-ExportDir", str(self.repo / "ev"),
+ ])
+ self.assertEqual(result, 0)
+ operations = {item.name: item for item in self.export.call_args.args[2]}
+ self.assertEqual(operations["package"].status, result_export.PASSED)
+ # The declared archives and manifest live under packages, so they are reports never;
+ # the package gate still publishes its own audit evidence and the release payload.
+ self.assertEqual(
+ [path.name for path in operations["package"].reports], ["binskim.sarif"]
+ )
+ packages = self.export.call_args.kwargs["packages"]
+ self.assertEqual(
+ sorted(path.name for path in packages), ["packages.json", "renpy-3.1.0-x64.zip"]
+ )
+
+ def test_verify_arch_reports_deferred_and_builds_unrunnable_targets(self) -> None:
+ with mock.patch.object(main, "verify_route", return_value=_EMPTY_ROUTE):
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ result = self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "arm64",
+ "-FuzzSeconds", "5", "-LeakWarmup", "1", "-LeakIterations", "1",
+ "-LeakWindows", "3", "-LeakToleranceBytes", "0",
+ ])
+ self.assertEqual(result, 0)
+ self.build.assert_any_call(
+ self.repo, ("arm64",), ("Debug", "Release"), jobs=None, tools=_NATIVE_TOOLS,
+ runner=self.processes.native,
+ )
+ self.test.assert_not_called()
+ self.coverage.assert_not_called()
+ self.assertIn("[DEFERRED] tests-arm64: host cannot execute arm64 tests", stdout.getvalue())
+
+ def test_verify_includes_the_python_coverage_gate(self) -> None:
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE), \
+ mock.patch.object(main, "_python_coverage", return_value=(Path("coverage.txt"),)) as coverage:
+ result = self.invoke([
+ "verify", "-Repository", str(self.repo), "-Arch", "x64",
+ "-ExportDir", str(self.repo / "ev"), "-Module", "renpy",
+ ])
+ self.assertEqual(result, 0)
+ coverage.assert_called_once()
+ self.assertIn("python-coverage", [item.name for item in self.export.call_args.args[2]])
+ # verify-arch must not run the python gate.
+ self.export.reset_mock()
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE):
+ self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "x64",
+ "-ExportDir", str(self.repo / "ev2"),
+ ])
+ self.assertNotIn("python-coverage", [item.name for item in self.export.call_args.args[2]])
+
+ def test_verify_passes_the_corpus_to_coverage(self) -> None:
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE):
+ self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "x64",
+ "-Corpus", str(self.repo / "golden"),
+ ])
+ self.assertEqual(self.coverage.call_args.kwargs["corpus"], self.repo / "golden")
+
+ def test_verify_retains_the_current_failure_evidence(self) -> None:
+ def fail_after_writing(repository, architectures, output, **options):
+ report = Path(output) / "analysis.sarif"
+ report.parent.mkdir(parents=True, exist_ok=True)
+ report.write_text("{}", encoding="utf-8")
+ raise RuntimeError("source gate failed")
+
+ self.source_checks.side_effect = fail_after_writing
+ with mock.patch.object(main, "verify_route", return_value=_EMPTY_ROUTE), \
+ redirect_stdout(StringIO()), redirect_stderr(StringIO()):
+ result = self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "arm64",
+ "-ExportDir", str(self.repo / "ev"),
+ ])
+ self.assertEqual(result, 1)
+ operations = {item.name: item for item in self.export.call_args.args[2]}
+ self.assertEqual(operations["source"].status, result_export.FAILED)
+ self.assertEqual([path.name for path in operations["source"].reports], ["analysis.sarif"])
+ self.assertIn("source gate failed", operations["source"].detail)
+
+ def test_verify_arch_refreshes_only_the_selected_arch_report_tree(self) -> None:
+ tests_root = self.repo / "out" / "native" / "reports" / "tests"
+ stale = tests_root / "x64" / "Debug" / "unit" / "tests.xml"
+ stale.parent.mkdir(parents=True)
+ stale.write_text("stale success", encoding="utf-8")
+ untouched = tests_root / "x86" / "Debug" / "unit" / "tests.xml"
+ untouched.parent.mkdir(parents=True)
+ untouched.write_text("other arch", encoding="utf-8")
+
+ self.test.side_effect = RuntimeError("early build failure")
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE), \
+ redirect_stdout(StringIO()), redirect_stderr(StringIO()):
+ result = self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "x64",
+ "-ExportDir", str(self.repo / "ev"),
+ ])
+ self.assertEqual(result, 1)
+ operations = {item.name: item for item in self.export.call_args.args[2]}
+ self.assertEqual(operations["tests-x64"].status, result_export.FAILED)
+ self.assertEqual(operations["tests-x64"].reports, ())
+ self.assertIn("early build failure", operations["tests-x64"].log)
+ self.assertFalse(stale.exists())
+ self.assertTrue(untouched.exists())
+
+ def test_verify_arch_blocks_package_when_an_independent_gate_fails(self) -> None:
+ self.source_checks.side_effect = RuntimeError("source gate failed")
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE), \
+ redirect_stdout(StringIO()), redirect_stderr(StringIO()):
+ result = self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "x64",
+ "-ExportDir", str(self.repo / "ev"),
+ ])
+ self.assertEqual(result, 1)
+ operations = {item.name: item for item in self.export.call_args.args[2]}
+ self.assertEqual(operations["source"].status, result_export.FAILED)
+ self.assertEqual(operations["package"].status, result_export.DEFERRED)
+ self.package.assert_not_called()
+ self.assertEqual(self.export.call_args.kwargs["packages"], ())
+
+
+class Arm64ExportRegressionTests(unittest.TestCase):
+ """A cross-built, non-runnable architecture must export a unique ``build-`` record.
+
+ This exercises the real result exporter (never a mock), because the CI failure was the
+ exporter rejecting a duplicated operation name: ``verify-arch -Arch arm64`` on an x64 host
+ recorded the cross-build as ``tests-arm64`` and then the routing deferral added a second
+ ``tests-arm64``. The record must be named ``build-arm64`` instead, leaving ``tests-arm64``
+ to carry only the deferred verdict.
+ """
+
+ def setUp(self) -> None:
+ self._temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self._temporary.cleanup)
+ self.repo = Path(self._temporary.name)
+
+ self.build = _spec(native_module.build, return_value=())
+ self.test = _spec(native_module.test, return_value=())
+ self.source_checks = _spec(source_module.source_checks, return_value=())
+ self.compiler_analysis = _spec(source_module.compiler_analysis, return_value=())
+ self.audit = _spec(packaging_module.audit_binaries, return_value=())
+ self.package = _spec(packaging_module.package, return_value=SimpleNamespace(
+ archives=(), deferred=()
+ ))
+
+ self.processes = _processes()
+ self.patches = [
+ mock.patch.object(main.native, "locate_tools", return_value=_NATIVE_TOOLS),
+ mock.patch.object(main.native, "build", self.build),
+ mock.patch.object(main.native, "test", self.test),
+ mock.patch.object(main.source_checks, "locate_tools", return_value="source-tools"),
+ mock.patch.object(main.source_checks, "locate_analysis_tools", return_value="analysis-tools"),
+ mock.patch.object(main.source_checks, "source_checks", self.source_checks),
+ mock.patch.object(main.source_checks, "compiler_analysis", self.compiler_analysis),
+ mock.patch.object(main.packaging_ops, "locate_packaging_tools", return_value="packaging-tools"),
+ mock.patch.object(main.packaging_ops, "audit_binaries", self.audit),
+ mock.patch.object(main.packaging_ops, "package", self.package),
+ mock.patch.object(main, "detect_host_architecture", return_value="x64"),
+ mock.patch.object(main, "_prioritize", return_value=None),
+ mock.patch.object(main, "_restore_priority"),
+ mock.patch.object(main, "_processes", return_value=self.processes),
+ ]
+ for patcher in self.patches:
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_arm64_cross_build_exports_a_unique_build_record(self) -> None:
+ export = self.repo / "evidence-arm64"
+ stdout, stderr = StringIO(), StringIO()
+ with redirect_stdout(stdout), redirect_stderr(stderr):
+ result = main.main([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "arm64",
+ "-ExportDir", str(export),
+ ])
+ self.assertEqual(result, 0, stderr.getvalue())
+
+ manifest = json.loads((export / "manifest.json").read_text(encoding="utf-8"))
+ names = [item["name"] for item in manifest["operations"]]
+ self.assertEqual(len(names), len(set(names)), names)
+ self.assertIn("build-arm64", manifest["passed"])
+ self.assertNotIn("tests-arm64", manifest["passed"])
+ self.assertEqual(
+ [item["name"] for item in manifest["deferred"]],
+ ["tests-arm64", "package-runtime-arm64"],
+ )
+ for name in ("source", "compiler-analysis", "audit", "package"):
+ self.assertIn(name, manifest["passed"])
+ self.assertEqual(manifest["status"], "success")
+ self.assertEqual(manifest["packages"], [])
+
+ self.build.assert_called_once_with(
+ self.repo, ("arm64",), ("Debug", "Release"), jobs=None, tools=_NATIVE_TOOLS,
+ runner=self.processes.native,
+ )
+ self.test.assert_not_called()
+ self.assertIn("[DEFERRED] tests-arm64", stdout.getvalue())
+
+
+class AuditDirectoryRegressionTests(unittest.TestCase):
+ """``verify`` and ``verify-arch`` must create the audit root before auditing.
+
+ ``packaging_ops.audit_binaries`` fails closed unless its output root already exists, and the
+ shared ``work`` tree is reset fresh per run, so the audit gate itself must create
+ ``work/audit``; otherwise the whole verification aborts with ``output directory must be an
+ existing directory`` before any gate evidence is exported (GitHub run 38161629221). The
+ real audit work is mocked away, but its output boundary is asserted to be a real directory.
+ """
+
+ def setUp(self) -> None:
+ self._temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self._temporary.cleanup)
+ self.repo = Path(self._temporary.name)
+
+ self.build = _spec(native_module.build, return_value=())
+ self.test = _spec(native_module.test, return_value=())
+ self.source_checks = _spec(source_module.source_checks, return_value=())
+ self.compiler_analysis = _spec(source_module.compiler_analysis, return_value=())
+ self.seen_roots: list[Path] = []
+
+ def audit(repository, architectures, output, **options):
+ # The real gate fails closed on a missing root, so record whether it existed here.
+ self.seen_roots.append(Path(output))
+ return ()
+
+ self.audit = _spec(packaging_module.audit_binaries, side_effect=audit)
+ self.package = _spec(packaging_module.package, return_value=SimpleNamespace(
+ archives=(), deferred=()
+ ))
+
+ self.processes = _processes()
+ self.patches = [
+ mock.patch.object(main.native, "locate_tools", return_value=_NATIVE_TOOLS),
+ mock.patch.object(main.native, "build", self.build),
+ mock.patch.object(main.native, "test", self.test),
+ mock.patch.object(main.source_checks, "locate_tools", return_value="source-tools"),
+ mock.patch.object(main.source_checks, "locate_analysis_tools", return_value="analysis-tools"),
+ mock.patch.object(main.source_checks, "source_checks", self.source_checks),
+ mock.patch.object(main.source_checks, "compiler_analysis", self.compiler_analysis),
+ mock.patch.object(main.packaging_ops, "locate_packaging_tools", return_value="packaging-tools"),
+ mock.patch.object(main.packaging_ops, "audit_binaries", self.audit),
+ mock.patch.object(main.packaging_ops, "package", self.package),
+ mock.patch.object(main, "detect_host_architecture", return_value="x64"),
+ mock.patch.object(main, "_prioritize", return_value=None),
+ mock.patch.object(main, "_restore_priority"),
+ mock.patch.object(main, "_processes", return_value=self.processes),
+ ]
+ for patcher in self.patches:
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_verify_arch_creates_the_audit_root_before_auditing(self) -> None:
+ export = self.repo / "evidence-arm64"
+ stdout, stderr = StringIO(), StringIO()
+ with redirect_stdout(stdout), redirect_stderr(stderr):
+ result = main.main([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "arm64",
+ "-ExportDir", str(export),
+ ])
+ self.assertEqual(result, 0, stderr.getvalue())
+
+ audit_root = self.repo / "out" / "reports" / "verify-arch" / "audit"
+ self.assertTrue(self.seen_roots, "audit_binaries was never invoked")
+ for seen in self.seen_roots:
+ self.assertEqual(seen, audit_root)
+ self.assertTrue(seen.is_dir(), f"audit ran without an existing root: {seen}")
+
+ manifest = json.loads((export / "manifest.json").read_text(encoding="utf-8"))
+ self.assertIn("audit", manifest["passed"])
+ self.assertIn("package", manifest["passed"])
+ self.assertEqual(manifest["status"], "success")
+
+
+class ReportNormalizationTests(unittest.TestCase):
+ def test_fuzz_target_directories_export_as_distinct_nested_files(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ targets = []
+ for name, body in (("x64/renpy", "renpy-body"), ("x64/pickle", "pickle-body")):
+ directory = root / "work" / "fuzz" / name
+ directory.mkdir(parents=True)
+ (directory / "status.json").write_text(body, encoding="utf-8")
+ (directory / "run.log").write_text(body, encoding="utf-8")
+ targets.append(directory)
+ records: list[result_export.Operation] = []
+ with redirect_stdout(StringIO()):
+ self.assertTrue(main._attempt(records, "fuzz", lambda: tuple(targets)))
+ self.assertEqual(len(records[0].reports), 4)
+ published = main._export(root / "export", "verify-arch", records)
+ manifest = json.loads((published / "manifest.json").read_text(encoding="utf-8"))
+ paths = sorted(record["path"] for record in manifest["reports"])
+ self.assertEqual(manifest["status"], "success")
+ self.assertEqual(len(paths), len(set(paths)))
+ self.assertIn("reports/fuzz/renpy/run.log", paths)
+ self.assertIn("reports/fuzz/pickle/status.json", paths)
+ self.assertEqual(
+ (published / "reports" / "fuzz" / "renpy" / "status.json").read_text(
+ encoding="utf-8"
+ ),
+ "renpy-body",
+ )
+ self.assertEqual(
+ (published / "reports" / "fuzz" / "pickle" / "status.json").read_text(
+ encoding="utf-8"
+ ),
+ "pickle-body",
+ )
+
+
+class HelperTests(unittest.TestCase):
+ def test_selection_accepts_lists_rejects_mixed_all_and_unknown(self) -> None:
+ parse = main._selection(("x86", "x64"))
+ self.assertEqual(parse("x64, x86"), ("x64", "x86"))
+ self.assertEqual(parse("all"), ("x86", "x64"))
+ self.assertEqual(parse("X64,x64"), ("x64",))
+ for value in ("", "x64,", "all,x64", "mips"):
+ with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError):
+ parse(value)
+
+ def test_integer_bounds(self) -> None:
+ self.assertEqual(main._integer(1, 10)("5"), 5)
+ for value in ("x", "0", "11"):
+ with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError):
+ main._integer(1, 10)(value)
+ with self.assertRaisesRegex(argparse.ArgumentTypeError, ">= 1"):
+ main._integer(1)("0")
+
+ def test_concise_and_jobs(self) -> None:
+ self.assertEqual(main._concise(ValueError(" boom here ")), "boom here")
+ self.assertEqual(main._concise(ValueError(" ")), "ValueError")
+ self.assertEqual(main._jobs(None, 4), 4)
+ self.assertEqual(main._jobs(3, 4), 3)
+
+ def test_priority_is_set_on_the_current_process_and_restored(self) -> None:
+ priority = main._PRIORITIES["below-normal"]
+ with (
+ mock.patch.object(main.win32api, "GetCurrentProcess", return_value=123),
+ mock.patch.object(main.win32process, "GetPriorityClass", return_value=64),
+ mock.patch.object(main.win32process, "SetPriorityClass") as setter,
+ ):
+ state = main._prioritize(priority)
+ self.assertEqual(state, (123, 64))
+ setter.assert_called_once_with(123, priority)
+ self.assertIsNone(main._prioritize(None))
+ with mock.patch.object(main.win32process, "SetPriorityClass") as restore:
+ main._restore_priority(state)
+ restore.assert_called_once_with(123, 64)
+ main._restore_priority(None)
+
+ def test_attempt_collects_a_failure_and_keeps_going(self) -> None:
+ records: list[result_export.Operation] = []
+ self.assertTrue(main._attempt(records, "good", lambda: (Path("a"),)))
+ self.assertFalse(main._attempt(
+ records, "bad", lambda: (_ for _ in ()).throw(RuntimeError("boom"))
+ ))
+ by_name = {item.name: item for item in records}
+ self.assertEqual(by_name["good"].status, result_export.PASSED)
+ self.assertEqual(by_name["bad"].status, result_export.FAILED)
+ self.assertIn("boom", by_name["bad"].detail)
+
+ def test_attempt_records_the_current_failure_evidence(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ evidence = Path(temporary)
+ (evidence / "current.sarif").write_text("{}", encoding="utf-8")
+ records: list[result_export.Operation] = []
+ main._attempt(
+ records, "source",
+ lambda: (_ for _ in ()).throw(RuntimeError("boom")),
+ evidence=evidence,
+ )
+ self.assertEqual([path.name for path in records[0].reports], ["current.sarif"])
+
+ def test_attempt_merges_declared_and_current_evidence_with_dedupe(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ evidence = Path(temporary)
+ declared = evidence / "junit.xml"
+ declared.write_text("", encoding="utf-8")
+ nested = evidence / "Debug" / "unit" / "junit.xml"
+ nested.parent.mkdir(parents=True)
+ nested.write_text("", encoding="utf-8")
+ leak = evidence / "leaks" / "snapshot.diff"
+ leak.parent.mkdir()
+ leak.write_text("diff", encoding="utf-8")
+ records: list[result_export.Operation] = []
+ with redirect_stdout(StringIO()):
+ self.assertTrue(
+ main._attempt(records, "tests-x64", lambda: (declared,), evidence=evidence)
+ )
+ reports = records[0].reports
+ # The declared report is also on disk, so the merge must keep it exactly once while
+ # retaining the nested JUnit and leak evidence the operation also produced.
+ self.assertEqual(len(reports), len(set(reports)))
+ self.assertEqual(set(reports), {declared, nested, leak})
+
+ def test_attempt_persists_stdout_on_success_and_failure(self) -> None:
+ records: list[result_export.Operation] = []
+
+ def good() -> tuple[Path, ...]:
+ print("child stdout ok")
+ return (Path("a"),)
+
+ def bad() -> tuple[Path, ...]:
+ print("child stdout bad")
+ raise RuntimeError("exploded")
+
+ with redirect_stdout(StringIO()):
+ self.assertTrue(main._attempt(records, "good", good))
+ self.assertFalse(main._attempt(records, "bad", bad))
+ by_name = {item.name: item for item in records}
+ self.assertEqual(by_name["good"].status, result_export.PASSED)
+ self.assertIn("child stdout ok", by_name["good"].log)
+ self.assertIn("child stdout bad", by_name["bad"].log)
+ self.assertIn("Traceback", by_name["bad"].log)
+ self.assertIn("exploded", by_name["bad"].log)
+
+ def test_tee_writes_to_every_stream_and_flushes(self) -> None:
+ first, second = StringIO(), StringIO()
+ tee = main._Tee(first, second)
+ self.assertEqual(tee.write("hello"), 5)
+ tee.flush()
+ self.assertEqual(first.getvalue(), "hello")
+ self.assertEqual(second.getvalue(), "hello")
+
+ def test_files_under_is_flat_sorted_and_tolerant(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ self.assertEqual(main._files_under(root / "absent"), ())
+ (root / "x64").mkdir()
+ (root / "x64" / "b.zip").write_text("b", encoding="utf-8")
+ (root / "packages.json").write_text("{}", encoding="utf-8")
+ self.assertEqual(
+ [path.name for path in main._files_under(root)], ["packages.json", "b.zip"]
+ )
+
+ def test_tests_action_runs_or_only_builds(self) -> None:
+ args = argparse.Namespace(corpus=None, jobs=None)
+ processes = _processes()
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ with mock.patch.object(main.native, "locate_tools", return_value=_NATIVE_TOOLS), \
+ mock.patch.object(main.native, "test") as test, \
+ mock.patch.object(main.native, "build") as build:
+ main._tests_action(repository, args, "x64", ("x64",), processes, "x64")()
+ test.assert_called_once_with(
+ repository, ("x64",), ("Debug", "Release"), corpus=None, jobs=None,
+ tools=_NATIVE_TOOLS, runner=processes.native, host="x64",
+ )
+ main._tests_action(repository, args, "arm64", ("x64",), processes, "x64")()
+ build.assert_called_once_with(
+ repository, ("arm64",), ("Debug", "Release"), jobs=None, tools=_NATIVE_TOOLS,
+ runner=processes.native,
+ )
+ # The selected architecture's report tree is cleared before the build or test.
+ self.assertTrue(
+ (repository / "out" / "native" / "reports" / "tests" / "x64").is_dir()
+ )
+
+ def test_python_coverage_invokes_the_gate_module(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ (repository / "build").mkdir()
+ output = repository / "out"
+ native = mock.MagicMock()
+ processes = main._Processes(native, mock.MagicMock(), mock.MagicMock())
+ reports = main._python_coverage(repository, output, processes)
+ argv, cwd, env = native.call_args.args
+ # The retained gate writes its raw data file into the build directory and then copies
+ # it into the output directory, so the two must be distinct or its ``copyfile`` raises
+ # SameFileError before the evidence is published.
+ self.assertNotEqual(env["OBSERVER_BUILD_DIR"], env["OBSERVER_OUT_DIR"])
+ self.assertTrue(Path(env["OBSERVER_BUILD_DIR"]).is_dir())
+ self.assertTrue(Path(env["OBSERVER_OUT_DIR"]).is_dir())
+ self.assertEqual(reports, (
+ output / ".coverage", output / "coverage.json", output / "coverage.xml",
+ output / "coverage.toml", output / "coverage.txt",
+ ))
+ self.assertEqual(argv[1:3], ("-m", "core.python_coverage"))
+ self.assertEqual(cwd, (repository / "build").resolve())
+ self.assertEqual(env["OBSERVER_OUT_DIR"], str(output))
+
+ def test_python_coverage_runs_the_real_gate_with_distinct_directories(self) -> None:
+ from core import python_coverage as coverage_gate
+
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ build = repository / "build"
+ build.mkdir()
+ (build / "pyproject.toml").write_text("[coverage]\n", encoding="utf-8")
+ output = repository / "out" / "reports" / "verify-source" / "python-coverage"
+ native = mock.MagicMock()
+
+ def run_gate(argv, cwd, env):
+ def fake_run(command, **options):
+ if "run" in command:
+ data = Path(command[command.index("--data-file") + 1])
+ data.write_text("raw coverage data", encoding="utf-8")
+ if "-o" in command:
+ Path(command[command.index("-o") + 1]).write_text(
+ "generated\n", encoding="utf-8"
+ )
+ return subprocess.CompletedProcess(command, 0, "coverage report\n")
+
+ with mock.patch.dict(os.environ, env), \
+ mock.patch.object(coverage_gate.subprocess, "run", side_effect=fake_run):
+ coverage_gate.run(build)
+
+ native.side_effect = run_gate
+ processes = main._Processes(native, mock.MagicMock(), mock.MagicMock())
+ reports = main._python_coverage(repository, output, processes)
+ self.assertEqual(reports, (
+ output / ".coverage", output / "coverage.json", output / "coverage.xml",
+ output / "coverage.toml", output / "coverage.txt",
+ ))
+ self.assertTrue(all(path.is_file() for path in reports))
+ self.assertEqual(
+ (output / "coverage.txt").read_text(encoding="utf-8"), "coverage report\n"
+ )
+ self.assertTrue((output / "work" / ".coverage").is_file())
+
+ def test_files_under_skips_reparse_entries(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ (root / "keep").mkdir()
+ (root / "keep" / "status.json").write_text("{}", encoding="utf-8")
+ linked = root / "skip"
+ linked.mkdir()
+ (linked / "inner.txt").write_text("x", encoding="utf-8")
+ with mock.patch.object(main, "_is_reparse", side_effect=lambda path: Path(path) == linked):
+ self.assertEqual([path.name for path in main._files_under(root)], ["status.json"])
+ with mock.patch.object(main, "_is_reparse", return_value=True):
+ self.assertEqual(main._files_under(root), ())
+
+ def test_evidence_reports_expands_directories_and_keeps_files(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ target = root / "fuzz" / "x64" / "renpy"
+ target.mkdir(parents=True)
+ (target / "status.txt").write_text("0", encoding="utf-8")
+ (target / "run.log").write_text("log", encoding="utf-8")
+ expanded = main._evidence_reports((target, root / "absent.xml"))
+ self.assertEqual(
+ sorted(path.name for path in expanded), ["absent.xml", "run.log", "status.txt"]
+ )
+
+ def test_reset_output_rebuilds_a_fresh_tree(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ stale = repository / "out" / "reports" / "verify-source" / "old.txt"
+ stale.parent.mkdir(parents=True)
+ stale.write_text("stale", encoding="utf-8")
+ output = main._reset_output(repository, "verify-source")
+ self.assertEqual(list(output.iterdir()), [])
+ self.assertTrue(main._output_dir(repository, "verify-source").is_dir())
+
+ def test_export_publishes_through_the_result_export_module(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ destination = Path(temporary) / "evidence"
+ published = main._export(
+ destination, "verify", (result_export.Operation("source", result_export.PASSED),)
+ )
+ self.assertEqual(published, destination)
+ self.assertTrue((published / "manifest.json").is_file())
+
+ def test_exported_manifest_includes_python_coverage_outputs_and_nested_reports(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ output = root / "python-coverage"
+ output.mkdir()
+ names = (".coverage", "coverage.json", "coverage.xml", "coverage.toml", "coverage.txt")
+ for name in names:
+ (output / name).write_text(name, encoding="utf-8")
+ nested = output / "work" / ".coverage"
+ nested.parent.mkdir()
+ nested.write_text("raw coverage data", encoding="utf-8")
+ records: list[result_export.Operation] = []
+ with redirect_stdout(StringIO()):
+ self.assertTrue(main._attempt(
+ records, "python-coverage",
+ lambda: tuple(output / name for name in names),
+ evidence=output,
+ ))
+ published = main._export(root / "evidence", "verify", records)
+ manifest = json.loads((published / "manifest.json").read_text(encoding="utf-8"))
+ paths = {record["path"] for record in manifest["reports"]}
+ self.assertEqual(manifest["status"], "success")
+ for name in names:
+ self.assertIn(f"reports/python-coverage/{name}", paths)
+ self.assertIn("reports/python-coverage/work/.coverage", paths)
+
+
+class ProcessTests(unittest.TestCase):
+ def _runner_processes(self, returncode: int, stdout: str):
+ runner = mock.MagicMock()
+ runner.run.return_value = subprocess.CompletedProcess(["tool"], returncode, stdout)
+ return mock.patch.object(main, "WindowsProcessRunner", return_value=runner)
+
+ def test_native_runner_shows_captured_output(self) -> None:
+ with self._runner_processes(0, "compiler output"):
+ processes = main._processes(None)
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ processes.native(["tool"])
+ self.assertIn("compiler output", stdout.getvalue())
+
+ def test_native_failure_reports_captured_output(self) -> None:
+ with self._runner_processes(2, "error text"):
+ processes = main._processes(None)
+ stdout = StringIO()
+ with redirect_stdout(stdout), self.assertRaises(subprocess.CalledProcessError) as raised:
+ processes.native(["tool"])
+ self.assertIn("error text", stdout.getvalue())
+ self.assertEqual(raised.exception.output, "error text")
+
+ def test_tool_runner_shows_captured_output(self) -> None:
+ with self._runner_processes(0, "diagnostic output"):
+ processes = main._processes(None)
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ result = processes.tools(["tool"])
+ self.assertEqual(result.stdout, "diagnostic output")
+ self.assertIn("diagnostic output", stdout.getvalue())
+
+ def test_packaging_runner_shows_captured_output(self) -> None:
+ with self._runner_processes(0, "package output"):
+ processes = main._processes(None)
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ processes.packaging(["tool"])
+ self.assertIn("package output", stdout.getvalue())
+
+ def test_adapters_translate_one_runner_to_each_contract(self) -> None:
+ runner = mock.MagicMock()
+ runner.run.return_value = subprocess.CompletedProcess(["tool"], 0, "captured")
+ with mock.patch.object(main, "WindowsProcessRunner", return_value=runner) as factory:
+ processes = main._processes(64)
+ factory.assert_called_once_with(priority_class=64)
+
+ self.assertIsNone(processes.native(["tool", "-x"], Path("cwd"), None))
+ tool = processes.tools(["tool"], None, {"K": "V"})
+ self.assertEqual((tool.returncode, tool.stdout), (0, "captured"))
+ stream = StringIO()
+ self.assertIsNone(processes.packaging(["tool"], cwd=Path("cwd"), env=None, stdout=stream))
+ self.assertIs(runner.run.call_args.kwargs["stdout"], stream)
+
+ def test_adapters_raise_called_process_error_on_nonzero(self) -> None:
+ runner = mock.MagicMock()
+ runner.run.return_value = subprocess.CompletedProcess(["tool"], 5, "")
+ with mock.patch.object(main, "WindowsProcessRunner", return_value=runner):
+ processes = main._processes(None)
+ with self.assertRaises(subprocess.CalledProcessError) as native_error:
+ processes.native(["tool"])
+ self.assertEqual(native_error.exception.returncode, 5)
+ with self.assertRaises(subprocess.CalledProcessError) as packaging_error:
+ processes.packaging(["tool"])
+ self.assertEqual(packaging_error.exception.returncode, 5)
+ self.assertEqual(processes.tools(["tool"]).returncode, 5)
+ self.assertEqual(processes.tools(["tool"]).stdout, "")
+
+
+class ContractTests(unittest.TestCase):
+ def test_package_keyword_matches_the_real_signature(self) -> None:
+ parameters = inspect.signature(main.packaging_ops.package).parameters
+ self.assertIn("smoke_architectures", parameters)
+ self.assertNotIn("smoke_archs", parameters)
+
+ def test_the_autospec_guard_rejects_a_wrong_keyword(self) -> None:
+ guard = _spec(main.packaging_ops.package)
+ guard(Path("repo"), ("x64",), Path("out"), smoke_architectures=("x64",))
+ with self.assertRaises(TypeError):
+ guard(Path("repo"), ("x64",), Path("out"), smoke_archs=("x64",))
+
+
+class GrammarTests(Harness):
+ def test_help_and_removed_flags_are_rejected_without_discovery(self) -> None:
+ with redirect_stdout(StringIO()):
+ self.assertEqual(self.invoke([]), 0)
+ cases = (
+ ["build", "-Arch", "mips"], ["build", "-Arch", "all,x64"], ["build", "-Config", "Profile"],
+ ["build", "-Jobs", "0"], ["restore", "-RestoreFlavor", "ubsan"], ["fuzz", "-Arch", "arm64"],
+ ["fuzz", "-FuzzSeconds", "0"], ["test-leaks", "-Arch", "x86"], ["verify-arch", "-Arch", "all"],
+ ["build", "-SkipDependencyRestore"], ["verify-arch", "-Arch", "x64", "-PruneCas"],
+ ["audit-binaries", "-Module", "bad"],
+ )
+ for argv in cases:
+ with (
+ self.subTest(argv=argv),
+ redirect_stderr(StringIO()),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ main.main(argv)
+ self.assertEqual(raised.exception.code, 2)
+ main.native.locate_tools.assert_not_called()
+ main.source_checks.locate_tools.assert_not_called()
+
+ def test_priority_normal_skips_the_priority_side_effect(self) -> None:
+ self.invoke(["build", "-Repository", str(self.repo), "-Priority", "normal"])
+ main._prioritize.assert_called_once_with(None)
+
+
+class FailureTests(Harness):
+ def test_a_called_process_error_reports_its_exit_code(self) -> None:
+ self.build.side_effect = subprocess.CalledProcessError(3, ["msbuild"])
+ stderr = StringIO()
+ with redirect_stderr(stderr):
+ self.assertEqual(self.invoke(["build", "-Repository", str(self.repo)]), 3)
+ self.assertIn("exit code 3", stderr.getvalue())
+
+ def test_an_operation_error_returns_two(self) -> None:
+ self.coverage.side_effect = main.diagnostics.DiagnosticsError("no profile")
+ stderr = StringIO()
+ with redirect_stderr(stderr):
+ self.assertEqual(self.invoke(["test-coverage", "-Repository", str(self.repo)]), 2)
+ self.assertIn("no profile", stderr.getvalue())
+
+ def test_a_failed_verification_exports_and_returns_one(self) -> None:
+ with mock.patch.object(main, "verify_route", return_value=_FULL_ROUTE):
+ self.package.side_effect = RuntimeError("gate failed")
+ stdout = StringIO()
+ with redirect_stdout(stdout):
+ self.assertEqual(self.invoke([
+ "verify-arch", "-Repository", str(self.repo), "-Arch", "x64",
+ "-ExportDir", str(self.repo / "ev"),
+ ]), 1)
+ operations = {item.name: item for item in self.export.call_args.args[2]}
+ self.assertEqual(operations["package"].status, result_export.FAILED)
+ self.assertEqual(self.export.call_args.kwargs["packages"], ())
+ self.assertIn("source", stdout.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_module_version.py b/build/tests/test_module_version.py
new file mode 100644
index 0000000..f77b742
--- /dev/null
+++ b/build/tests/test_module_version.py
@@ -0,0 +1,226 @@
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+import re
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+REPOSITORY = BUILD_ROOT.parent
+sys.path.insert(0, str(BUILD_ROOT))
+
+from core.module_version import ( # noqa: E402
+ MODULES,
+ VERSION_PROPS,
+ VERSION_RESOURCE,
+ ModuleVersionError,
+ binary_version,
+ content_identity,
+ main as module_version_main,
+ parse_version,
+ project_version_inputs,
+ read_version,
+ require_binary_version,
+ shared_content_inputs,
+ version_path,
+)
+from core.package import MODULES as PACKAGE_MODULES # noqa: E402
+
+
+SYSTEM_BINARY = Path("C:/Windows/System32/kernel32.dll")
+
+
+class ModuleVersionFileTests(unittest.TestCase):
+ def test_every_shipped_module_continues_its_published_lineage(self) -> None:
+ self.assertEqual(MODULES, PACKAGE_MODULES)
+ self.assertEqual(
+ {module: read_version(REPOSITORY, module) for module in MODULES},
+ {"renpy": "3.1.0", "rpgmaker": "1.1.0", "zanzarah": "2.1.0"},
+ )
+ for module in MODULES:
+ with self.subTest(module=module):
+ lines = (REPOSITORY / version_path(module)).read_text(encoding="ascii").splitlines()
+ self.assertEqual(len(lines), 1)
+
+ def test_module_sources_report_the_injected_version_instead_of_literals(self) -> None:
+ for module in MODULES:
+ with self.subTest(module=module):
+ source = (REPOSITORY / f"src/modules/{module}/{module}.cpp").read_text(
+ encoding="utf-8"
+ )
+ body = source[source.index("version_info get_version_info"):]
+ body = body[: body.index("\n }")]
+ self.assertIn("OBSERVER_MODULE_VERSION_MAJOR,", body)
+ self.assertIn("OBSERVER_MODULE_VERSION_MINOR,", body)
+ self.assertIn("build/ObserverModuleVersion.props", source)
+ # No hardcoded major/minor component may survive beside the injected macros.
+ self.assertEqual([], re.findall(r"^\s+\d+,\s*$", body, re.MULTILINE))
+
+ def test_version_inputs_follow_every_project_that_compiles_a_module(self) -> None:
+ self.assertEqual(version_path("renpy"), "src/modules/renpy/VERSION")
+ self.assertEqual((VERSION_PROPS, VERSION_RESOURCE), (
+ "build/ObserverModuleVersion.props", "src/modules/version.rc",
+ ))
+ # A shipped DLL also carries the shared resource; its fuzz target only recompiles the
+ # same translation units, so it signs the VERSION file alone.
+ self.assertEqual(
+ project_version_inputs("zanzarah"),
+ (VERSION_RESOURCE, "src/modules/zanzarah/VERSION"),
+ )
+ self.assertEqual(
+ project_version_inputs("fuzz-renpy"), ("src/modules/renpy/VERSION",)
+ )
+ for project in ("tests", "leak-probe", "fuzz-pickle", "fuzz-", "renpy-fuzz"):
+ with self.subTest(project=project):
+ self.assertEqual(project_version_inputs(project), ())
+ for name in (VERSION_PROPS, VERSION_RESOURCE):
+ with self.subTest(name=name):
+ self.assertTrue((REPOSITORY / name).is_file())
+ with self.assertRaisesRegex(ModuleVersionError, "unknown module"):
+ version_path("pickle")
+
+ def test_shared_content_inputs_cover_the_version_and_dependency_plumbing(self) -> None:
+ names = shared_content_inputs(REPOSITORY)
+
+ self.assertEqual(names, tuple(sorted(set(names))))
+ self.assertIn(VERSION_PROPS, names)
+ self.assertIn(VERSION_RESOURCE, names)
+ self.assertIn("vcpkg.json", names)
+ self.assertIn("build/vcpkg/triplets/observer-x64-windows-static.cmake", names)
+ self.assertNotIn("vcpkg-configuration.json", names)
+ for name in names:
+ with self.subTest(name=name):
+ self.assertTrue((REPOSITORY / name).is_file())
+
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ for name in (VERSION_PROPS, VERSION_RESOURCE, "vcpkg.json",
+ "vcpkg-configuration.json", "build/vcpkg/ports/zlib/portfile.cmake"):
+ path = root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("x\n", encoding="utf-8")
+ self.assertEqual(
+ shared_content_inputs(root),
+ (
+ VERSION_PROPS,
+ "build/vcpkg/ports/zlib/portfile.cmake",
+ VERSION_RESOURCE,
+ "vcpkg-configuration.json",
+ "vcpkg.json",
+ ),
+ )
+
+ def test_versions_are_validated_as_bounded_three_component_semver(self) -> None:
+ self.assertEqual(parse_version("3.1.0"), (3, 1, 0))
+ self.assertEqual(parse_version("0.0.0"), (0, 0, 0))
+ for invalid in ("3.1", "3.1.0.0", "03.1.0", "3.1.0-rc1", "", "v3.1.0", "3.1.x"):
+ with self.subTest(invalid=invalid), self.assertRaisesRegex(
+ ModuleVersionError, "must be X.Y.Z"
+ ):
+ parse_version(invalid)
+ with self.assertRaisesRegex(ModuleVersionError, "exceeds 65535"):
+ parse_version("99999.0.0")
+
+ def test_reading_a_missing_or_malformed_version_file_fails_closed(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ with self.assertRaisesRegex(ModuleVersionError, "unreadable"):
+ read_version(root, "renpy")
+ path = root / version_path("renpy")
+ path.parent.mkdir(parents=True)
+ path.write_text(" 2.4.6\n\n", encoding="utf-8")
+ self.assertEqual(read_version(root, "renpy"), "2.4.6")
+ path.write_text("2.4\n", encoding="utf-8")
+ with self.assertRaisesRegex(ModuleVersionError, "must be X.Y.Z"):
+ read_version(root, "renpy")
+
+
+class ContentIdentityTests(unittest.TestCase):
+ def test_identity_covers_exact_bytes_and_names_regardless_of_order(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ for name, content in (("a.cpp", "one\n"), ("b/c.h", "two\n")):
+ path = root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+
+ first = content_identity(root, ("a.cpp", "b/c.h"))
+ self.assertEqual(first, content_identity(root, ("b/c.h", "a.cpp", "a.cpp")))
+ expected = hashlib.sha256()
+ for name in ("a.cpp", "b/c.h"):
+ digest = hashlib.sha256((root / name).read_bytes()).hexdigest()
+ expected.update(f"{name}\0{digest}\n".encode("utf-8"))
+ self.assertEqual(first, expected.hexdigest())
+
+ (root / "a.cpp").write_text("changed\n", encoding="utf-8")
+ self.assertNotEqual(first, content_identity(root, ("a.cpp", "b/c.h")))
+ (root / "renamed.cpp").write_text("changed\n", encoding="utf-8")
+ self.assertNotEqual(
+ content_identity(root, ("a.cpp", "b/c.h")),
+ content_identity(root, ("renamed.cpp", "b/c.h")),
+ )
+
+ def test_identity_rejects_an_empty_set_and_the_version_file_itself(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ with self.assertRaisesRegex(ModuleVersionError, "at least one input"):
+ content_identity(root, ())
+ with self.assertRaisesRegex(ModuleVersionError, "must exclude"):
+ content_identity(root, ("src/modules/renpy/VERSION",))
+
+
+class BinaryVersionTests(unittest.TestCase):
+ def test_a_real_version_resource_is_read_as_two_numeric_quads(self) -> None:
+ file_version, product_version = binary_version(SYSTEM_BINARY)
+
+ self.assertEqual(len(file_version), 4)
+ self.assertEqual(len(product_version), 4)
+ self.assertTrue(all(0 <= item <= 0xFFFF for item in file_version))
+ self.assertGreaterEqual(file_version[0], 10)
+
+ def test_a_binary_without_a_version_resource_fails_closed(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ binary = Path(temporary) / "renpy.so"
+ binary.write_bytes(b"not a portable executable")
+ with self.assertRaisesRegex(ModuleVersionError, "no version resource"):
+ binary_version(binary)
+ with self.assertRaisesRegex(ModuleVersionError, "no version resource"):
+ require_binary_version(binary, "3.1.0")
+
+ def test_required_version_must_match_both_resource_quads_exactly(self) -> None:
+ with self.assertRaisesRegex(ModuleVersionError, "version resource is"):
+ require_binary_version(SYSTEM_BINARY, "3.1.0")
+
+ with mock.patch(
+ "core.module_version.binary_version",
+ return_value=((3, 1, 0, 0), (3, 1, 0, 0)),
+ ):
+ self.assertIsNone(require_binary_version(SYSTEM_BINARY, "3.1.0"))
+ for actual in (((3, 1, 1, 0), (3, 1, 0, 0)), ((3, 1, 0, 0), (3, 0, 0, 0))):
+ with (
+ self.subTest(actual=actual),
+ mock.patch("core.module_version.binary_version", return_value=actual),
+ self.assertRaisesRegex(ModuleVersionError, "version resource is"),
+ ):
+ require_binary_version(SYSTEM_BINARY, "3.1.0")
+
+
+class ModuleVersionCommandTests(unittest.TestCase):
+ def test_verify_command_gates_one_binary_and_requires_a_subcommand(self) -> None:
+ with mock.patch(
+ "core.module_version.binary_version",
+ return_value=((1, 1, 0, 0), (1, 1, 0, 0)),
+ ):
+ self.assertEqual(0, module_version_main(("verify", "1.1.0", str(SYSTEM_BINARY))))
+
+
+ with self.assertRaises(SystemExit):
+ module_version_main(())
+
+
+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..6b18cbc
--- /dev/null
+++ b/build/tests/test_msbuild_contracts.py
@@ -0,0 +1,341 @@
+from __future__ import annotations
+
+from pathlib import Path
+import re
+import sys
+import unittest
+import xml.etree.ElementTree as ET
+
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
+MSBUILD = "{http://schemas.microsoft.com/developer/msbuild/2003}"
+sys.path.insert(0, str(REPOSITORY_ROOT / "build"))
+
+from core.module_version import ModuleVersionError, parse_version # noqa: E402
+
+
+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_coverage_links_the_architecture_specific_profile_runtime(self) -> None:
+ project = _project("build/ObserverProject.props")
+
+ names = {
+ node.get("Condition"): node.text
+ for node in project.findall(f".//{MSBUILD}ObserverProfileRuntime")
+ }
+ self.assertEqual(
+ names,
+ {
+ "'$(Configuration)' == 'Coverage' And '$(Platform)' == 'Win32'":
+ "clang_rt.profile-i386.lib",
+ "'$(Configuration)' == 'Coverage' And '$(Platform)' == 'x64'":
+ "clang_rt.profile-x86_64.lib",
+ },
+ )
+
+ link = project.find(f".//{MSBUILD}ItemDefinitionGroup/{MSBUILD}Link")
+ dependencies = [
+ node.text for node in link.findall(f"{MSBUILD}AdditionalDependencies")
+ if node.get("Condition") == "'$(ObserverProfileRuntime)' != ''"
+ ]
+ options = [
+ node.text for node in link.findall(f"{MSBUILD}AdditionalOptions")
+ if node.get("Condition") == "'$(ObserverProfileRuntime)' != ''"
+ ]
+ self.assertEqual(
+ dependencies,
+ ["$(LLVMRuntimeDir)\\$(ObserverProfileRuntime);%(AdditionalDependencies)"],
+ )
+ self.assertEqual(
+ options,
+ ["/NODEFAULTLIB:clang_rt.profile.lib %(AdditionalOptions)"],
+ )
+
+ def test_coverage_rejects_a_manual_build_without_a_usable_profile_runtime(self) -> None:
+ project = _project("build/ObserverProject.props")
+ validation = project.findall(
+ f".//{MSBUILD}Target[@Name='ValidateObserverProfileRuntime']/{MSBUILD}Error"
+ )
+
+ self.assertEqual(len(validation), 2)
+ self.assertEqual(
+ validation[0].get("Condition"),
+ "'$(ObserverCompileAnalysis)' != 'true' And "
+ "'$(ObserverProfileRuntime)' != '' And '$(LLVMRuntimeDir)' == ''",
+ )
+ self.assertIn("build.ps1 test-coverage", validation[0].get("Text"))
+ self.assertEqual(
+ validation[1].get("Condition"),
+ "'$(ObserverCompileAnalysis)' != 'true' And "
+ "'$(Configuration)' == 'Coverage' And '$(ObserverProfileRuntime)' == ''",
+ )
+ self.assertIn("Win32 and x64 only", validation[1].get("Text"))
+
+ def test_fuzz_links_the_architecture_specific_libfuzzer_runtime(self) -> None:
+ project = _project("build/ObserverFuzz.props")
+
+ suffixes = {
+ node.get("Condition"): node.text
+ for node in project.findall(f".//{MSBUILD}ObserverFuzzRuntimeSuffix")
+ }
+ self.assertEqual(
+ suffixes,
+ {
+ "'$(Configuration)' == 'Fuzz' And '$(Platform)' == 'Win32'": "i386",
+ "'$(Configuration)' == 'Fuzz' And '$(Platform)' == 'x64'": "x86_64",
+ },
+ )
+
+ link = project.find(f".//{MSBUILD}ItemDefinitionGroup/{MSBUILD}Link")
+ dependencies = link.find(f"{MSBUILD}AdditionalDependencies").text
+ options = link.find(f"{MSBUILD}AdditionalOptions").text
+ # No architecture may remain hard-coded now that Fuzz|Win32 links the same way.
+ self.assertNotIn("x86_64", dependencies)
+ self.assertNotIn("x86_64", options)
+ for library in ("clang_rt.asan", "clang_rt.asan_cxx"):
+ self.assertIn(
+ f"$(LLVMRuntimeDir)\\{library}-$(ObserverFuzzRuntimeSuffix).lib;",
+ dependencies,
+ )
+ self.assertTrue(dependencies.endswith(";%(AdditionalDependencies)"))
+ for library in ("clang_rt.fuzzer", "clang_rt.asan", "clang_rt.asan_cxx"):
+ self.assertIn(
+ f'/WHOLEARCHIVE:"$(LLVMRuntimeDir)\\{library}-$(ObserverFuzzRuntimeSuffix).lib"',
+ options,
+ )
+ self.assertIn("/INFERASANLIBS:NO", options)
+
+ def test_fuzz_win32_keeps_msvc_exception_unwinding_under_asan(self) -> None:
+ project = _project("build/ObserverFuzz.props")
+
+ options = project.findall(
+ f".//{MSBUILD}ItemDefinitionGroup/{MSBUILD}ClCompile/{MSBUILD}AdditionalOptions"
+ )
+ self.assertEqual(len(options), 1)
+ self.assertEqual(
+ options[0].get("Condition"),
+ "'$(Configuration)' == 'Fuzz' And '$(Platform)' == 'Win32'",
+ )
+ self.assertEqual(
+ options[0].text,
+ "/clang:-mllvm /clang:-asan-stack-dynamic-alloca=0 %(AdditionalOptions)",
+ )
+
+ def test_fuzz_validation_allows_only_win32_and_x64_fuzz_or_compile_analysis(self) -> None:
+ project = _project("build/ObserverFuzz.props")
+ validation = project.findall(
+ f".//{MSBUILD}Target[@Name='ValidateFuzzConfiguration']/{MSBUILD}Error"
+ )
+
+ self.assertEqual(len(validation), 2)
+ self.assertEqual(
+ validation[0].get("Condition"),
+ "'$(ObserverCompileAnalysis)' != 'true' And "
+ "'$(ObserverFuzzRuntimeSuffix)' == ''",
+ )
+ self.assertIn("Fuzz|Win32 and Fuzz|x64", validation[0].get("Text"))
+ self.assertEqual(
+ validation[1].get("Condition"),
+ "'$(ObserverCompileAnalysis)' != 'true' And '$(LLVMRuntimeDir)' == ''",
+ )
+ self.assertIn("build.ps1 fuzz", validation[1].get("Text"))
+
+ def test_props_own_no_compile_analysis_manifest_plumbing(self) -> None:
+ # Direct MSBuild names the compiler manifests itself, so the props must not keep the
+ # retired per-file manifest target or the build-graph override hooks it keyed on.
+ for relative_path in ("build/ObserverProject.props", "build/ObserverFuzz.props"):
+ with self.subTest(path=relative_path):
+ text = (REPOSITORY_ROOT / relative_path).read_text(encoding="utf-8")
+ self.assertNotRegex(text, r"(?i)\bgraph\b", "no retired build-graph wording")
+
+ project = _project("build/ObserverProject.props")
+ targets = [node.get("Name") for node in project.findall(f".//{MSBUILD}Target")]
+ self.assertNotIn("ObserverNameCompilerManifests", targets)
+ self.assertIsNone(project.find(f".//{MSBUILD}ObserverManifestName"))
+
+ text = (REPOSITORY_ROOT / "build/ObserverProject.props").read_text(encoding="utf-8")
+ for retired in (
+ "ObserverNameCompilerManifests",
+ "ObserverManifestName",
+ "sourceDependencies",
+ "clang:-MJ",
+ ):
+ with self.subTest(retired=retired):
+ self.assertNotIn(retired, text)
+
+ def test_module_version_props_derive_every_macro_from_the_version_file(self) -> None:
+ project = _project("build/ObserverModuleVersion.props")
+
+ version_file = project.find(f".//{MSBUILD}ObserverModuleVersionFile")
+ self.assertIsNotNone(version_file)
+ self.assertEqual(
+ version_file.text,
+ "$(RepositoryRoot)src\\modules\\$(ObserverModuleName)\\VERSION",
+ )
+ raw = project.find(f".//{MSBUILD}ObserverModuleVersionRaw")
+ self.assertIsNotNone(raw)
+ self.assertEqual(
+ raw.text,
+ "$([System.IO.File]::ReadAllText('$(ObserverModuleVersionFile)'))",
+ )
+ for component, member in (("Major", "Major"), ("Minor", "Minor"), ("Patch", "Build")):
+ with self.subTest(component=component):
+ node = project.find(f".//{MSBUILD}ObserverModuleVersion{component}")
+ self.assertIsNotNone(node)
+ self.assertEqual(
+ node.text,
+ f"$([System.Version]::Parse('$(ObserverModuleVersion)').{member})",
+ )
+ self.assertEqual(
+ node.get("Condition"), "'$(ObserverModuleVersionValid)' == 'true'"
+ )
+
+ definitions = [
+ node.text
+ for node in project.findall(f".//{MSBUILD}PreprocessorDefinitions")
+ ]
+ self.assertEqual(len(definitions), 2)
+ for text in definitions:
+ with self.subTest(text=text):
+ for macro in ("MAJOR", "MINOR", "PATCH"):
+ self.assertIn(f"OBSERVER_MODULE_VERSION_{macro}=$(ObserverModuleVersion", text)
+ self.assertTrue(text.endswith(";%(PreprocessorDefinitions)"))
+ resource_definitions = project.find(
+ f".//{MSBUILD}ResourceCompile/{MSBUILD}PreprocessorDefinitions"
+ )
+ self.assertIsNotNone(resource_definitions)
+ for macro in (
+ "OBSERVER_MODULE_NAME=$(ObserverModuleName)",
+ "OBSERVER_MODULE_FILE=$(ObserverModuleName).so",
+ "OBSERVER_MODULE_VERSION=$(ObserverModuleVersion)",
+ ):
+ self.assertIn(f"{macro};", resource_definitions.text)
+
+ resource = project.find(f".//{MSBUILD}ItemGroup/{MSBUILD}ResourceCompile")
+ self.assertIsNotNone(resource)
+ self.assertEqual(resource.get("Include"), "$(RepositoryRoot)src\\modules\\version.rc")
+ item_group = project.find(f".//{MSBUILD}ItemGroup[{MSBUILD}ResourceCompile]")
+ self.assertEqual(
+ item_group.get("Condition"), "'$(ObserverConfigurationType)' == 'DynamicLibrary'"
+ )
+
+ # MSBuild and core.module_version.parse_version must accept exactly the same versions.
+ valid = project.find(f".//{MSBUILD}ObserverModuleVersionValid")
+ self.assertIsNotNone(valid)
+ self.assertEqual(
+ valid.text,
+ "$([System.Text.RegularExpressions.Regex]::IsMatch("
+ "'$(ObserverModuleVersion)', "
+ "'^(0|[1-9][0-9]{0,4})\\.(0|[1-9][0-9]{0,4})\\.(0|[1-9][0-9]{0,4})$'))",
+ )
+ pattern = re.compile(re.search(r"'(\^\(0\|.*\$)'", valid.text).group(1))
+ for version in ("3.1.0", "0.0.0", "65535.65535.65535"):
+ with self.subTest(version=version):
+ self.assertIsNotNone(pattern.fullmatch(version))
+ self.assertEqual(parse_version(version), tuple(
+ int(part) for part in version.split(".")
+ ))
+ for version in ("03.1.0", "3.1", "3.1.0.0", "3.1.0-rc1", "", "999999.0.0"):
+ with self.subTest(version=version):
+ self.assertIsNone(pattern.fullmatch(version))
+ with self.assertRaises(ModuleVersionError):
+ parse_version(version)
+
+ errors = project.findall(
+ f".//{MSBUILD}Target[@Name='ValidateObserverModuleVersion']/{MSBUILD}Error"
+ )
+ self.assertEqual(
+ [node.get("Condition") for node in errors],
+ [
+ "'$(ObserverModuleName)' == ''",
+ "'$(ObserverModuleVersionValid)' != 'true'",
+ "'$(ObserverModuleVersionValid)' == 'true' And ("
+ "$(ObserverModuleVersionMajor) > 65535 Or "
+ "$(ObserverModuleVersionMinor) > 65535 Or "
+ "$(ObserverModuleVersionPatch) > 65535)",
+ ],
+ )
+
+ def test_every_project_compiling_a_module_declares_its_version_identity(self) -> None:
+ for project_name, module in (
+ ("renpy", "renpy"),
+ ("rpgmaker", "rpgmaker"),
+ ("zanzarah", "zanzarah"),
+ ("fuzz-renpy", "renpy"),
+ ("fuzz-rpgmaker", "rpgmaker"),
+ ("fuzz-zanzarah", "zanzarah"),
+ ):
+ with self.subTest(project=project_name):
+ project = _project(f"build/projects/{project_name}.vcxproj")
+ imports = [node.get("Project") for node in project.findall(f"{MSBUILD}Import")]
+ self.assertIn("..\\ObserverModuleVersion.props", imports)
+ self.assertLess(
+ imports.index("..\\ObserverProject.props"),
+ imports.index("..\\ObserverModuleVersion.props"),
+ )
+ name = project.find(f".//{MSBUILD}ObserverModuleName")
+ self.assertIsNotNone(name)
+ self.assertEqual(name.text, module)
+
+ def test_projects_without_module_sources_stay_out_of_the_version_contract(self) -> None:
+ for project_name in ("tests", "leak-probe", "fuzz-pickle"):
+ with self.subTest(project=project_name):
+ project = _project(f"build/projects/{project_name}.vcxproj")
+ imports = [node.get("Project") for node in project.findall(f"{MSBUILD}Import")]
+ self.assertNotIn("..\\ObserverModuleVersion.props", imports)
+ self.assertIsNone(project.find(f".//{MSBUILD}ObserverModuleName"))
+
+ 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.py b/build/tests/test_native.py
new file mode 100644
index 0000000..e58a15f
--- /dev/null
+++ b/build/tests/test_native.py
@@ -0,0 +1,909 @@
+from __future__ import annotations
+
+import argparse
+from dataclasses import dataclass
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+import xml.etree.ElementTree as ET
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+REPOSITORY_ROOT = BUILD_ROOT.parent
+sys.path.insert(0, str(BUILD_ROOT))
+
+import native # noqa: E402
+
+
+MSBUILD = "{http://schemas.microsoft.com/developer/msbuild/2003}"
+
+
+def _executable(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.touch()
+ return path
+
+
+@dataclass
+class RecordedCall:
+ argv: tuple[str, ...]
+ cwd: Path | None
+ env: dict[str, str] | None
+
+
+class RecordingRunner:
+ """Records every tool invocation and can be told to fail one of them."""
+
+ def __init__(self, failure: type[BaseException] | None = None) -> None:
+ self.calls: list[RecordedCall] = []
+ self._failure = failure
+
+ def __call__(self, argv, cwd=None, env=None) -> None:
+ self.calls.append(RecordedCall(tuple(argv), cwd, None if env is None else dict(env)))
+ if self._failure is not None:
+ raise self._failure
+
+
+@dataclass(frozen=True)
+class FakeTools:
+ msbuild: Path
+ vcpkg: Path
+
+
+def _tools(root: Path) -> FakeTools:
+ return FakeTools(
+ msbuild=_executable(root / "tools" / "MSBuild.exe"),
+ vcpkg=_executable(root / "tools" / "vcpkg" / "vcpkg.exe"),
+ )
+
+
+class LayoutTests(unittest.TestCase):
+ def test_stable_layout_lives_under_out_native(self) -> None:
+ repository = Path("C:/work/repo")
+
+ self.assertEqual(
+ native.artifacts_root(repository),
+ repository / "out" / "native",
+ )
+ self.assertEqual(
+ native.restore_root(repository, "x64"),
+ repository / "out" / "native" / "vcpkg_installed" / "x64",
+ )
+ self.assertEqual(
+ native.bin_directory(repository, "arm64", "Release"),
+ repository / "out" / "native" / "bin" / "arm64" / "Release",
+ )
+
+ def test_artifacts_are_the_shipped_module_and_test_binaries(self) -> None:
+ repository = Path("C:/work/repo")
+ directory = repository / "out" / "native" / "bin" / "x86" / "Debug"
+
+ self.assertEqual(
+ native.artifacts(repository, "x86", "Debug"),
+ (
+ directory / "renpy.so",
+ directory / "rpgmaker.so",
+ directory / "zanzarah.so",
+ directory / "tests.exe",
+ ),
+ )
+ self.assertEqual(
+ native.test_executable(repository, "x86", "Debug"),
+ directory / "tests.exe",
+ )
+
+
+class RestoreCommandTests(unittest.TestCase):
+ def test_restore_installs_pinned_triplet_into_the_stable_per_arch_root(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+
+ command = native.restore_command(repository, "x86", tools)
+
+ scratch = repository / "out" / "native" / "vcpkg_scratch" / "x86"
+ self.assertEqual(
+ command,
+ (
+ str(tools.vcpkg),
+ "install",
+ f"--x-install-root={repository / 'out' / 'native' / 'vcpkg_installed' / 'x86'}",
+ f"--x-buildtrees-root={scratch / 'buildtrees'}",
+ f"--x-packages-root={scratch / 'packages'}",
+ "--triplet",
+ "observer-x86-windows-static",
+ f"--x-manifest-root={repository}",
+ f"--overlay-triplets={repository / 'build' / 'vcpkg' / 'triplets'}",
+ ),
+ )
+
+
+class BuildCommandTests(unittest.TestCase):
+ def test_build_drives_observer_proj_with_the_platform_and_artifact_overrides(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+ artifacts = repository / "out" / "native"
+
+ command = native.build_command(repository, "arm64", "Release", 6, tools)
+
+ self.assertEqual(
+ command,
+ (
+ str(tools.msbuild),
+ str(repository / "build" / "Observer.proj"),
+ "/nologo",
+ "/m:6",
+ "/nr:false",
+ "/t:Build",
+ "/p:Configuration=Release",
+ "/p:Platform=ARM64",
+ f"/p:ArtifactsRoot={artifacts}{os.sep}",
+ f"/p:VcpkgRoot={tools.vcpkg.parent}",
+ "/p:VcpkgManifestInstall=false",
+ ),
+ )
+
+ def test_every_architecture_maps_to_its_msbuild_platform(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+
+ platforms = {
+ architecture: native.build_command(repository, architecture, "Debug", 1, tools)[7]
+ for architecture in native.ARCHITECTURES
+ }
+
+ self.assertEqual(
+ platforms,
+ {
+ "x86": "/p:Platform=Win32",
+ "x64": "/p:Platform=x64",
+ "arm64": "/p:Platform=ARM64",
+ },
+ )
+
+
+class TestCommandTests(unittest.TestCase):
+ def test_unit_suite_runs_the_test_binary_from_its_binary_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ report = repository / "out" / "native" / "reports" / "tests.xml"
+
+ command = native.test_command(repository, "x64", "Debug", report, None)
+
+ self.assertEqual(
+ command,
+ (
+ str(repository / "out" / "native" / "bin" / "x64" / "Debug" / "tests.exe"),
+ "--reporter",
+ "compact",
+ "--reporter",
+ f"JUnit::out={report}",
+ "--durations",
+ "yes",
+ "--order",
+ "lex",
+ ),
+ )
+
+ def test_corpus_suite_selects_only_the_compatibility_tag(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ report = repository / "out" / "native" / "reports" / "corpus.xml"
+
+ command = native.test_command(repository, "x64", "Release", report, Path("C:/corpus"))
+
+ self.assertEqual(command[1], "[compatibility]")
+ self.assertIn(f"JUnit::out={report}", command)
+
+
+class SelectionTests(unittest.TestCase):
+ def test_architecture_and_configuration_selections_accept_all_and_lists(self) -> None:
+ architecture = native._selection(native.ARCHITECTURES)
+ configuration = native._selection(native.CONFIGURATIONS)
+
+ self.assertEqual(architecture("all"), native.ARCHITECTURES)
+ self.assertEqual(architecture("x86,x64"), ("x86", "x64"))
+ self.assertEqual(architecture("X64,x64"), ("x64",))
+ self.assertEqual(configuration("debug"), ("Debug",))
+
+ def test_a_bad_selection_names_the_supported_values(self) -> None:
+ architecture = native._selection(native.ARCHITECTURES)
+
+ with self.assertRaises(argparse.ArgumentTypeError):
+ architecture("sparc")
+
+ def test_all_is_only_accepted_on_its_own(self) -> None:
+ for choices in (native.ARCHITECTURES, native.CONFIGURATIONS):
+ parse = native._selection(choices)
+
+ self.assertEqual(parse("all"), choices)
+ with self.assertRaises(argparse.ArgumentTypeError):
+ parse(f"all,{choices[0]}")
+ with self.assertRaises(argparse.ArgumentTypeError):
+ parse("all,sparc")
+ with self.assertRaises(argparse.ArgumentTypeError):
+ parse("all,all")
+
+ def test_empty_and_invalid_tokens_are_rejected_consistently(self) -> None:
+ for choices in (native.ARCHITECTURES, native.CONFIGURATIONS):
+ parse = native._selection(choices)
+
+ for value in ("", ",", f"{choices[0]},", f",{choices[0]}", f"{choices[0]},Typo"):
+ with self.subTest(choices=choices, value=value):
+ with self.assertRaises(argparse.ArgumentTypeError):
+ parse(value)
+
+ def test_a_job_count_must_be_a_positive_integer(self) -> None:
+ self.assertEqual(native._positive_integer("4"), 4)
+ with self.assertRaises(argparse.ArgumentTypeError):
+ native._positive_integer("0")
+ with self.assertRaises(argparse.ArgumentTypeError):
+ native._positive_integer("many")
+
+
+class RelativeRepositoryTests(unittest.TestCase):
+ """Public entry points resolve the repository before generating argv or cwd."""
+
+ def _relative(self, repository: Path) -> Path:
+ return Path(os.path.relpath(repository, Path.cwd()))
+
+ def test_build_resolves_a_relative_repository_for_argv_outputs_and_cwd(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary).resolve()
+ tools = _tools(repository)
+ runner = RecordingRunner()
+
+ outputs = native.build(
+ self._relative(repository), ("x64",), ("Debug",), tools=tools, runner=runner
+ )
+
+ self.assertEqual(outputs, native.artifacts(repository, "x64", "Debug"))
+ build_argv = runner.calls[-1].argv
+ self.assertIn(str(repository / "build" / "Observer.proj"), build_argv)
+ self.assertIn(
+ f"/p:ArtifactsRoot={repository / 'out' / 'native'}{os.sep}", build_argv
+ )
+ for call in runner.calls:
+ self.assertEqual(call.cwd, repository)
+
+ def test_restore_resolves_a_relative_repository(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary).resolve()
+ tools = _tools(repository)
+ runner = RecordingRunner()
+
+ roots = native.restore(
+ self._relative(repository), ("x86",), tools=tools, runner=runner
+ )
+
+ expected = native.restore_root(repository, "x86")
+ self.assertEqual(roots, (expected,))
+ install_root = next(
+ item for item in runner.calls[0].argv if item.startswith("--x-install-root=")
+ )
+ self.assertEqual(install_root, f"--x-install-root={expected}")
+ self.assertEqual(runner.calls[0].cwd, repository)
+
+ def test_test_resolves_a_relative_repository_for_the_report_and_binary_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary).resolve()
+ (repository / "build" / "projects").mkdir(parents=True)
+ tools = _tools(repository)
+ runner = RecordingRunner()
+
+ reports = native.test(
+ self._relative(repository), ("x64",), ("Debug",), host="x64",
+ tools=tools, runner=runner,
+ )
+
+ expected = (
+ repository / "out" / "native" / "reports" / "tests"
+ / "x64" / "Debug" / "unit" / "tests.xml"
+ )
+ self.assertEqual(reports, (expected,))
+ self.assertEqual(runner.calls[-1].cwd, native.bin_directory(repository, "x64", "Debug"))
+
+
+class ValidationTests(unittest.TestCase):
+ def test_unknown_architectures_and_configurations_are_rejected_without_running_a_tool(self) -> None:
+ runner = RecordingRunner()
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+
+ with self.assertRaisesRegex(ValueError, "architecture"):
+ native.build(repository, ("sparc",), ("Debug",), tools=tools, runner=runner)
+ with self.assertRaisesRegex(ValueError, "configuration"):
+ native.build(repository, ("x64",), ("RelWithDebInfo",), tools=tools, runner=runner)
+ with self.assertRaisesRegex(ValueError, "architecture"):
+ native.build(repository, (), ("Debug",), tools=tools, runner=runner)
+ with self.assertRaisesRegex(ValueError, "configuration"):
+ native.build(repository, ("x64",), (), tools=tools, runner=runner)
+
+ self.assertEqual(runner.calls, [])
+
+
+class HostTests(unittest.TestCase):
+ def test_machine_names_canonicalise_to_a_supported_host(self) -> None:
+ self.assertEqual(native.host_architecture("AMD64"), "x64")
+ self.assertEqual(native.host_architecture("x86_64"), "x64")
+ self.assertEqual(native.host_architecture("ARM64"), "arm64")
+ self.assertEqual(native.host_architecture("aarch64"), "arm64")
+ self.assertEqual(native.host_architecture("i686"), "x86")
+
+ def test_an_unknown_host_is_rejected(self) -> None:
+ with self.assertRaisesRegex(ValueError, "unsupported Windows host architecture"):
+ native.host_architecture("mips64")
+
+ def test_runtime_tests_refuse_targets_the_host_cannot_execute(self) -> None:
+ native.require_runnable(("x86", "x64"), "x64")
+ native.require_runnable(("x86", "x64", "arm64"), "arm64")
+ with self.assertRaisesRegex(ValueError, "cannot run"):
+ native.require_runnable(("arm64",), "x64")
+ with self.assertRaisesRegex(ValueError, "cannot run"):
+ native.require_runnable(("x64",), "x86")
+ with self.assertRaisesRegex(ValueError, "unsupported host architecture"):
+ native.require_runnable(("x64",), "sparc")
+
+ def test_the_host_policy_is_delegated_to_core_host(self) -> None:
+ from core import host as host_policy
+
+ for machine in ("AMD64", "x86_64", "arm64", "aarch64", "x86", "i386", "i686"):
+ self.assertEqual(
+ native.host_architecture(machine),
+ host_policy.detect_host_architecture(machine),
+ )
+
+ # One policy only: the duplicated alias table is gone and no longer accepts
+ # machine names that ``core.host`` rejects.
+ self.assertFalse(hasattr(native, "_MACHINE_ALIASES"))
+ self.assertFalse(hasattr(native, "_HOST_CAPABILITIES"))
+ with self.assertRaisesRegex(ValueError, "unsupported Windows host architecture"):
+ native.host_architecture("em64t")
+
+ def test_runnable_acceptance_matches_core_host(self) -> None:
+ from core import host as host_policy
+
+ def rejects(action) -> bool:
+ try:
+ action()
+ except (ValueError, RuntimeError):
+ return True
+ return False
+
+ for host in ("x86", "x64", "arm64", "sparc"):
+ for requested in (("x86",), ("arm64",), ("x86", "x64"), ("x86", "x64", "arm64")):
+ self.assertEqual(
+ rejects(lambda: native.require_runnable(requested, host)),
+ rejects(lambda: host_policy.require_runnable(requested, host)),
+ msg=f"{host} {requested}",
+ )
+
+
+class ToolDiscoveryTests(unittest.TestCase):
+ def test_vswhere_is_located_under_program_files_x86(self) -> None:
+ environ = {"ProgramFiles(x86)": "C:/Program Files (x86)"}
+
+ self.assertEqual(
+ native.vswhere_path(environ),
+ Path("C:/Program Files (x86)") / "Microsoft Visual Studio" / "Installer" / "vswhere.exe",
+ )
+ with self.assertRaises(FileNotFoundError):
+ native.vswhere_path({})
+
+ def test_msbuild_is_resolved_from_the_vswhere_installation(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ installation = Path(temporary) / "Visual Studio"
+ amd64 = _executable(
+ installation / "MSBuild" / "Current" / "Bin" / "amd64" / "MSBuild.exe"
+ )
+ recorded: list[tuple[str, ...]] = []
+
+ def run(argv, **kwargs):
+ recorded.append(tuple(argv))
+ return subprocess.CompletedProcess(argv, 0, stdout=f"{installation}\r\n", stderr="")
+
+ resolved = native.find_msbuild(Path("C:/vswhere.exe"), run=run)
+
+ self.assertEqual(resolved, amd64)
+ self.assertIn("-requires", recorded[0])
+ self.assertIn("installationPath", recorded[0])
+
+ def test_msbuild_falls_back_to_the_plain_binary_and_rejects_empty_output(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ installation = Path(temporary) / "Visual Studio"
+ plain = _executable(installation / "MSBuild" / "Current" / "Bin" / "MSBuild.exe")
+
+ def run(argv, **kwargs):
+ return subprocess.CompletedProcess(argv, 0, stdout=f"{installation}\n", stderr="")
+
+ self.assertEqual(native.find_msbuild(Path("C:/vswhere.exe"), run=run), plain)
+
+ with self.assertRaisesRegex(RuntimeError, "Visual Studio"):
+ native.find_msbuild(
+ Path("C:/vswhere.exe"),
+ run=lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0, stdout="\n", stderr=""),
+ )
+
+ def test_msbuild_query_requires_the_cpp_build_tools_and_not_llvm(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ installation = Path(temporary) / "Visual Studio"
+ _executable(installation / "MSBuild" / "Current" / "Bin" / "amd64" / "MSBuild.exe")
+ recorded: list[tuple[str, ...]] = []
+
+ def run(argv, **kwargs):
+ recorded.append(tuple(argv))
+ return subprocess.CompletedProcess(argv, 0, stdout=f"{installation}\n", stderr="")
+
+ native.find_msbuild(Path("C:/vswhere.exe"), run=run)
+
+ argv = recorded[0]
+ requires = [argv[index + 1] for index, token in enumerate(argv) if token == "-requires"]
+ self.assertIn("Microsoft.Component.MSBuild", requires)
+ self.assertIn("Microsoft.VisualStudio.Component.VC.Tools.x86.x64", requires)
+ self.assertNotIn("Microsoft.VisualStudio.Component.VC.Llvm.Clang", requires)
+
+ def test_vcpkg_requires_a_real_root_and_refuses_a_path_shim(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary) / "vcpkg"
+ _executable(root / "vcpkg.exe")
+ (root / "scripts" / "buildsystems" / "msbuild").mkdir(parents=True, exist_ok=True)
+ (root / "scripts" / "buildsystems" / "msbuild" / "vcpkg.props").write_text(
+ "\n", encoding="utf-8"
+ )
+
+ self.assertEqual(native.find_vcpkg({"VCPKG_ROOT": str(root)}), (root / "vcpkg.exe").resolve())
+
+ with self.assertRaises(FileNotFoundError):
+ native.find_vcpkg({})
+ with self.assertRaises(FileNotFoundError):
+ native.find_vcpkg({"VCPKG_ROOT": "C:/shims/vcpkg"})
+
+ def test_locate_tools_composes_vswhere_and_vcpkg_resolution(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ program_files = root / "Program Files (x86)"
+ vswhere = _executable(
+ program_files / "Microsoft Visual Studio" / "Installer" / "vswhere.exe"
+ )
+ installation = root / "Visual Studio"
+ msbuild = _executable(
+ installation / "MSBuild" / "Current" / "Bin" / "amd64" / "MSBuild.exe"
+ )
+ vcpkg_root = root / "vcpkg"
+ vcpkg = _executable(vcpkg_root / "vcpkg.exe")
+ (vcpkg_root / "scripts" / "buildsystems" / "msbuild").mkdir(parents=True)
+ (vcpkg_root / "scripts" / "buildsystems" / "msbuild" / "vcpkg.props").write_text(
+ "\n", encoding="utf-8"
+ )
+ environ = {"ProgramFiles(x86)": str(program_files), "VCPKG_ROOT": str(vcpkg_root)}
+
+ def run(argv, **kwargs):
+ return subprocess.CompletedProcess(argv, 0, stdout=f"{installation}\n", stderr="")
+
+ tools = native.locate_tools(environ=environ, run=run)
+
+ self.assertEqual(tools.msbuild, msbuild)
+ self.assertEqual(tools.vcpkg, vcpkg)
+ self.assertEqual(tools.vcpkg_root, vcpkg_root)
+ self.assertTrue(vswhere.is_file())
+
+ def test_a_missing_required_binary_is_reported(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ environ = {"ProgramFiles(x86)": temporary}
+
+ with self.assertRaisesRegex(FileNotFoundError, "vswhere.exe"):
+ native.locate_tools(environ=environ)
+
+
+class BuildTests(unittest.TestCase):
+ def test_build_restores_then_compiles_each_arch_and_config_and_returns_artifacts(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+ runner = RecordingRunner()
+
+ outputs = native.build(
+ repository, ("x64", "arm64"), ("Debug", "Release"), jobs=3,
+ tools=tools, runner=runner,
+ )
+
+ # Every dependency tree is restored before any compilation starts.
+ expected_commands = [
+ native.restore_command(repository, architecture, tools)
+ for architecture in ("x64", "arm64")
+ ]
+ for architecture in ("x64", "arm64"):
+ for configuration in ("Debug", "Release"):
+ expected_commands.append(
+ native.build_command(repository, architecture, configuration, 3, tools)
+ )
+ self.assertEqual(tuple(call.argv for call in runner.calls), tuple(expected_commands))
+ self.assertTrue(all(call.cwd == repository for call in runner.calls))
+ self.assertTrue(all(call.env is None for call in runner.calls))
+
+ expected_artifacts = tuple(
+ artifact
+ for architecture in ("x64", "arm64")
+ for configuration in ("Debug", "Release")
+ for artifact in native.artifacts(repository, architecture, configuration)
+ )
+ self.assertEqual(outputs, expected_artifacts)
+
+ def test_build_defaults_jobs_to_the_cpu_count_and_rejects_a_zero_job_count(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+ runner = RecordingRunner()
+
+ native.build(repository, ("x64",), ("Debug",), tools=tools, runner=runner)
+ self.assertEqual(runner.calls[1].argv[3], f"/m:{os.cpu_count() or 1}")
+
+ with self.assertRaisesRegex(ValueError, "jobs"):
+ native.build(repository, ("x64",), ("Debug",), jobs=0, tools=tools, runner=runner)
+
+ def test_build_does_not_swallow_a_failing_tool(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+ failure = subprocess.CalledProcessError(7, "MSBuild.exe")
+
+ with self.assertRaises(subprocess.CalledProcessError) as raised:
+ native.build(
+ repository, ("x64",), ("Debug",),
+ tools=tools, runner=RecordingRunner(failure),
+ )
+ self.assertEqual(raised.exception.returncode, 7)
+
+
+class TestRunTests(unittest.TestCase):
+ def _tools_and_runner(self, repository: Path) -> tuple[FakeTools, RecordingRunner]:
+ return _tools(repository), RecordingRunner()
+
+ def test_unit_test_builds_compiles_runs_from_the_binary_directory_and_returns_reports(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ (repository / "build" / "projects").mkdir(parents=True)
+ tools, runner = self._tools_and_runner(repository)
+
+ reports = native.test(
+ repository, ("x64",), ("Debug",), host="x64", tools=tools, runner=runner,
+ )
+
+ expected_report = (
+ repository / "out" / "native" / "reports" / "tests" / "x64" / "Debug" / "unit" / "tests.xml"
+ )
+ self.assertEqual(reports, (expected_report,))
+ self.assertTrue(expected_report.parent.is_dir())
+ run = runner.calls[-1]
+ self.assertEqual(
+ run.argv,
+ native.test_command(
+ repository, "x64", "Debug", expected_report, None,
+ ),
+ )
+ self.assertEqual(run.cwd, native.bin_directory(repository, "x64", "Debug"))
+ self.assertIsNone(run.env)
+
+ def _report(self, repository: Path, architecture: str, configuration: str, scope: str) -> Path:
+ return (
+ repository / "out" / "native" / "reports" / "tests"
+ / architecture / configuration / scope / "tests.xml"
+ )
+
+ def test_corpus_test_runs_the_ordinary_suite_then_appends_the_compatibility_suite(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ corpus = repository / "corpus"
+ corpus.mkdir()
+ tools, runner = self._tools_and_runner(repository)
+
+ reports = native.test(
+ repository, ("x86",), ("Release",), corpus=corpus, host="x64",
+ tools=tools, runner=runner,
+ )
+
+ unit = self._report(repository, "x86", "Release", "unit")
+ compatibility = self._report(repository, "x86", "Release", "corpus")
+ self.assertEqual(reports, (unit, compatibility))
+
+ unit_run, corpus_run = runner.calls[-2], runner.calls[-1]
+ self.assertEqual(
+ unit_run.argv,
+ native.test_command(repository, "x86", "Release", unit, None),
+ )
+ # The ordinary suite must never inherit the compatibility corpus environment.
+ self.assertIsNone(unit_run.env)
+ self.assertEqual(corpus_run.argv[1], "[compatibility]")
+ self.assertEqual(corpus_run.env, {"OBSERVER_TEST_CORPUS": str(corpus)})
+
+ def test_every_variant_runs_its_ordinary_suite_even_with_a_corpus(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ corpus = repository / "corpus"
+ corpus.mkdir()
+ tools, runner = self._tools_and_runner(repository)
+
+ reports = native.test(
+ repository, ("x86", "x64"), ("Debug", "Release"), corpus=corpus, host="x64",
+ tools=tools, runner=runner,
+ )
+
+ expected = tuple(
+ self._report(repository, architecture, configuration, scope)
+ for architecture in ("x86", "x64")
+ for configuration in ("Debug", "Release")
+ for scope in ("unit", "corpus")
+ )
+ self.assertEqual(reports, expected)
+ # Every unit suite precedes its compatibility suite for the same variant.
+ runs = runner.calls[-(2 * 4):]
+ self.assertEqual(
+ ["corpus" if call.argv[1] == "[compatibility]" else "unit" for call in runs],
+ ["unit", "corpus"] * 4,
+ )
+
+ def test_a_failure_in_the_compatibility_suite_propagates(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ corpus = repository / "corpus"
+ corpus.mkdir()
+ tools = _tools(repository)
+ seen: list[tuple[str, ...]] = []
+
+ def runner(argv, cwd=None, env=None):
+ seen.append(tuple(argv))
+ if "[compatibility]" in argv:
+ raise subprocess.CalledProcessError(5, "tests.exe")
+
+ with self.assertRaises(subprocess.CalledProcessError) as raised:
+ native.test(
+ repository, ("x64",), ("Debug",), corpus=corpus, host="x64",
+ tools=tools, runner=runner,
+ )
+ self.assertEqual(raised.exception.returncode, 5)
+ # The ordinary suite ran first and succeeded before the compatibility suite failed.
+ self.assertTrue(any("[compatibility]" not in argv for argv in seen))
+
+ def test_the_ordinary_suite_failure_propagates_before_the_compatibility_suite(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ corpus = repository / "corpus"
+ corpus.mkdir()
+ tools = _tools(repository)
+ seen: list[tuple[str, ...]] = []
+ ordinary = str(native.test_executable(repository, "x64", "Debug"))
+
+ def runner(argv, cwd=None, env=None):
+ seen.append(tuple(argv))
+ # Only the ordinary suite fails: the vcpkg restore and MSBuild steps before it
+ # must run, so the test actually exercises the ordinary invocation.
+ if argv[0] == ordinary and "[compatibility]" not in argv:
+ raise subprocess.CalledProcessError(4, "tests.exe")
+
+ with self.assertRaises(subprocess.CalledProcessError) as raised:
+ native.test(
+ repository, ("x64",), ("Debug",), corpus=corpus, host="x64",
+ tools=tools, runner=runner,
+ )
+ self.assertEqual(raised.exception.returncode, 4)
+ # The ordinary suite actually ran and failed; the compatibility suite never did.
+ self.assertEqual(
+ len([
+ argv for argv in seen
+ if argv[0] == ordinary and "[compatibility]" not in argv
+ ]),
+ 1,
+ )
+ self.assertFalse(any("[compatibility]" in argv for argv in seen))
+
+ def test_a_corpus_path_must_be_an_existing_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools, runner = self._tools_and_runner(repository)
+
+ with self.assertRaises(OSError):
+ native.test(
+ repository, ("x64",), ("Debug",), corpus=repository / "missing",
+ host="x64", tools=tools, runner=runner,
+ )
+ self.assertEqual(runner.calls, [])
+
+ def test_a_corpus_path_that_is_a_file_is_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ corpus = repository / "corpus.txt"
+ corpus.write_text("not a directory", encoding="utf-8")
+ tools, runner = self._tools_and_runner(repository)
+
+ with self.assertRaises(NotADirectoryError):
+ native.test(
+ repository, ("x64",), ("Debug",), corpus=corpus,
+ host="x64", tools=tools, runner=runner,
+ )
+ self.assertEqual(runner.calls, [])
+
+ def test_runtime_tests_refuse_an_arch_the_host_cannot_execute(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools, runner = self._tools_and_runner(repository)
+
+ with self.assertRaisesRegex(ValueError, "cannot run"):
+ native.test(
+ repository, ("arm64",), ("Release",), host="x64", tools=tools, runner=runner,
+ )
+ self.assertEqual(runner.calls, [])
+
+
+class RunToolTests(unittest.TestCase):
+ def test_the_real_runner_executes_and_merges_the_environment(self) -> None:
+ native.run_tool([sys.executable, "-c", "raise SystemExit(0)"])
+ with tempfile.TemporaryDirectory() as temporary:
+ native.run_tool(
+ [sys.executable, "-c", "import os; assert os.environ['OBSERVER_PROBE'] == '1'"],
+ cwd=Path(temporary), env={"OBSERVER_PROBE": "1"},
+ )
+
+ def test_the_real_runner_always_passes_an_explicit_inherited_environment(self) -> None:
+ # MSBuild fails with MSB6001 when it inherits the raw Windows block, which carries both
+ # PATH and Path; an explicit merged mapping is normalised by os.environ's case-folding.
+ with mock.patch.object(native.subprocess, "run") as run:
+ native.run_tool(["tool.exe", "arg"])
+ native.run_tool(["tool.exe", "arg"], env={"OBSERVER_PROBE": "1"})
+
+ inherited = run.call_args_list[0].kwargs["env"]
+ self.assertIsNotNone(inherited)
+ self.assertEqual(inherited, dict(os.environ))
+
+ override = run.call_args_list[1].kwargs["env"]
+ self.assertEqual(override["OBSERVER_PROBE"], "1")
+ self.assertEqual(override["PATH"], os.environ["PATH"])
+
+ def test_the_real_runner_preserves_a_nonzero_exit(self) -> None:
+ with self.assertRaises(subprocess.CalledProcessError) as raised:
+ native.run_tool([sys.executable, "-c", "raise SystemExit(9)"])
+ self.assertEqual(raised.exception.returncode, 9)
+
+
+class CommandLineTests(unittest.TestCase):
+ def _invoke(self, argv, **options) -> tuple[int, str, str]:
+ import contextlib
+ import io
+
+ out, err = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ code = native.main(argv, **options)
+ return code, out.getvalue(), err.getvalue()
+
+ def test_build_command_prints_the_returned_artifact_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+ runner = RecordingRunner()
+
+ code, out, _ = self._invoke(
+ ["build", "-Repository", temporary, "-Arch", "all", "-Config", "Release", "-Jobs", "2"],
+ tools=tools, runner=runner,
+ )
+
+ self.assertEqual(code, 0)
+ printed = [Path(line) for line in out.splitlines()]
+ self.assertEqual(
+ printed,
+ [
+ artifact
+ for architecture in native.ARCHITECTURES
+ for artifact in native.artifacts(repository, architecture, "Release")
+ ],
+ )
+
+ def test_restore_command_runs_only_the_restore_step(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ style = _tools(Path(temporary))
+ runner = RecordingRunner()
+
+ code, out, _ = self._invoke(
+ ["restore", "-Repository", temporary, "-Arch", "x64"],
+ tools=style, runner=runner,
+ )
+
+ self.assertEqual(code, 0)
+ self.assertEqual(len(runner.calls), 1)
+ self.assertEqual(out.strip(), str(native.restore_root(Path(temporary), "x64")))
+
+ def test_test_command_runs_the_suite(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+ runner = RecordingRunner()
+
+ code, out, _ = self._invoke(
+ ["test", "-Repository", temporary, "-Arch", "x86,x64", "-Config", "Debug"],
+ tools=tools, runner=runner,
+ )
+
+ self.assertEqual(code, 0)
+ self.assertEqual(len(out.splitlines()), 2)
+ self.assertTrue(runner.calls[-1].argv[0].endswith("tests.exe"))
+
+ def test_usage_errors_and_tool_failures_become_nonzero_exit_codes(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ tools = _tools(repository)
+
+ code, _, err = self._invoke(
+ ["build", "-Repository", temporary, "-Config", "Release"],
+ tools=tools, runner=RecordingRunner(),
+ )
+ self.assertEqual(code, 0) # x64 Debug is the default selection
+
+ code, _, err = self._invoke(
+ ["test", "-Repository", temporary, "-Arch", "x64"],
+ environ={}, tools=None, runner=None,
+ )
+ self.assertEqual(code, 2)
+ self.assertIn("observer-native", err)
+
+ failure = subprocess.CalledProcessError(3, "vcpkg.exe")
+ code, _, err = self._invoke(
+ ["restore", "-Repository", temporary, "-Arch", "x64"],
+ tools=tools, runner=RecordingRunner(failure),
+ )
+ self.assertEqual(code, 3)
+ self.assertIn("exit code 3", err)
+
+ def test_an_invalid_selection_is_a_usage_error(self) -> None:
+ with self.assertRaises(SystemExit):
+ self._invoke(["build", "-Arch", "sparc"])
+
+
+class ProjectContractTests(unittest.TestCase):
+ def test_observer_proj_builds_every_project_through_one_msbuild_submission(self) -> None:
+ project = ET.parse(REPOSITORY_ROOT / "build" / "Observer.proj").getroot()
+
+ included = [
+ Path(item.get("Include")).name
+ for item in project.findall(f".//{MSBUILD}ObserverProject")
+ ]
+ self.assertEqual(
+ included,
+ ["renpy.vcxproj", "rpgmaker.vcxproj", "zanzarah.vcxproj", "tests.vcxproj"],
+ )
+
+ build = project.find(f".//{MSBUILD}Target[@Name='Build']/{MSBUILD}MSBuild")
+ self.assertIsNotNone(build)
+ self.assertEqual(build.get("BuildInParallel"), "$(BuildInParallel)")
+
+ artifacts = project.find(f".//{MSBUILD}ArtifactsRoot")
+ self.assertIsNotNone(artifacts)
+ self.assertIn("out", artifacts.text)
+ self.assertIn("native", artifacts.text)
+
+ def test_tests_project_references_the_modules_without_linking_them(self) -> None:
+ project = ET.parse(REPOSITORY_ROOT / "build" / "projects" / "tests.vcxproj").getroot()
+ references = {
+ Path(item.get("Include")).name: item
+ for item in project.findall(f".//{MSBUILD}ProjectReference")
+ }
+
+ self.assertEqual(
+ set(references),
+ {"renpy.vcxproj", "rpgmaker.vcxproj", "zanzarah.vcxproj"},
+ )
+ for reference in references.values():
+ self.assertEqual(
+ reference.find(f"{MSBUILD}LinkLibraryDependencies").text, "false"
+ )
+ self.assertEqual(
+ reference.find(f"{MSBUILD}ReferenceOutputAssembly").text, "false"
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_package_actions.py b/build/tests/test_package_actions.py
new file mode 100644
index 0000000..e0cece8
--- /dev/null
+++ b/build/tests/test_package_actions.py
@@ -0,0 +1,344 @@
+"""Standalone worker tests for the ``core.package`` packaging actions.
+
+These assertions were preserved from ``test_package_graph.py`` when the retired
+``graphs.package`` composition was dropped. They drive the real stage, archive,
+aggregate and validation CLI actions against a materialised repository fixture,
+so the packaging behaviour stays covered.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+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.package import PackageError, main as package_main # noqa: E402
+
+
+MODULES = ("renpy", "rpgmaker", "zanzarah")
+VERSIONS = {"renpy": "3.1.0", "rpgmaker": "1.1.0", "zanzarah": "2.1.0"}
+LICENSES = {
+ "renpy": ("Observer.txt", "rpatool.txt", "serde-pickle.txt", "zlib.txt"),
+ "rpgmaker": ("Observer.txt", "rgssad.txt"),
+ "zanzarah": ("Observer.txt", "zanzapak.txt"),
+}
+PROJECT = """
+
+
+
+ $(RepositoryRoot)src\\modules\\{module}\\{module}.def
+
+
+
+
+
+
+
+"""
+
+
+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
+
+
+class PackageActionTests(unittest.TestCase):
+ def repository(self, root: Path) -> Path:
+ for module, licenses in LICENSES.items():
+ write(root, f"src/modules/{module}/observer_user.ini", f"[{module}]\n")
+ write(root, f"src/modules/{module}/{module}.cpp", f"// {module}\n")
+ write(root, f"src/modules/{module}/{module}.def", "EXPORTS\n")
+ write(root, f"src/modules/{module}/VERSION", f"{VERSIONS[module]}\n")
+ write(
+ root,
+ f"src/modules/{module}/ChangeLog",
+ f"Version {VERSIONS[module]}\n-------------\n * {module} release.\n",
+ )
+ write(root, f"build/projects/{module}.vcxproj", PROJECT.format(module=module))
+ for name in licenses:
+ write(root, f"licenses/{name}", f"license:{name}\n")
+ # A header beside a module source is signed into the build without being declared.
+ write(root, f"src/modules/{module}/{module}_private.h", "#pragma once\n")
+ write(root, "src/modules/extractor.h", "#pragma once\n")
+ write(root, "src/modules/version.rc", "VS_VERSION_INFO VERSIONINFO\n")
+ for name in (
+ "build/ObserverProjectConfigurations.props",
+ "build/ObserverConfiguration.props",
+ "build/ObserverProject.props",
+ "build/ObserverModuleVersion.props",
+ ):
+ write(root, name, "\n")
+ write(root, "vcpkg.json", '{"dependencies":["zlib"]}\n')
+ write(root, "build/vcpkg/triplets/observer-x64-windows-static.cmake", "static\n")
+ write(root, "LICENSE.txt", "project license\n")
+ return 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)
+
+ @staticmethod
+ def records(**overrides: str) -> tuple[str, ...]:
+ identities = {module: hashlib.sha256(module.encode()).hexdigest() for module in MODULES}
+ return tuple(
+ argument
+ for module in MODULES
+ for argument in (
+ "--module",
+ overrides.get(module, f"{module}={VERSIONS[module]}={identities[module]}"),
+ )
+ )
+
+ 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", "3.1.0",
+ str(binary), str(repository)),
+ )
+
+ payload = stage / "payload"
+ expected = {
+ "ChangeLog", "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", "3.1.0"),
+ (document["architecture"], document["kind"], document["module"],
+ document["version"]),
+ )
+ 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", "3.1.0", str(stage))
+ self.invoke(archive_two, "archive-module", "x64", "renpy", "3.1.0", str(stage))
+ first = archive_one / "renpy-3.1.0-x64.zip"
+ second = archive_two / "renpy-3.1.0-x64.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"))
+ # The raw human ChangeLog travels at the archive root, byte for byte.
+ self.assertEqual(
+ archive.read("ChangeLog"),
+ (repository / "src/modules/renpy/ChangeLog").read_bytes(),
+ )
+
+ validation = root / "validation"
+ self.assertEqual(
+ 0,
+ self.invoke(validation, "validate-module", "x64", "renpy", "3.1.0",
+ 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"]])
+ with self.assertRaisesRegex(PackageError, "manifest does not match"):
+ self.invoke(root / "wrong-version-validation", "validate-module", "x64",
+ "renpy", "3.1.1", str(first), str(stage))
+
+ 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",
+ "3.1.0", 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", "3.1.0", str(invalid), str(stage))
+
+ aggregate = root / "aggregate"
+ self.invoke(aggregate, "aggregate", *self.records(), 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-3.1.0-x64.zip", packages["archives"][0]["name"])
+ self.assertEqual(
+ hashlib.sha256(first.read_bytes()).hexdigest(),
+ packages["archives"][0]["sha256"],
+ )
+ self.assertEqual(
+ packages["modules"],
+ [
+ {
+ "content": hashlib.sha256(module.encode()).hexdigest(),
+ "module": module,
+ "version": VERSIONS[module],
+ }
+ for module in MODULES
+ ],
+ )
+
+ def test_stage_module_requires_a_matching_change_log_but_symbols_do_not(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ binary = root / "renpy.so"
+ binary.write_bytes(b"module")
+ changelog = repository / "src/modules/renpy/ChangeLog"
+
+ changelog.unlink()
+ with self.assertRaisesRegex(PackageError, "ChangeLog"):
+ self.invoke(root / "missing", "stage-module", "x64", "renpy", "3.1.0",
+ str(binary), str(repository))
+ changelog.write_text(
+ "Version 3.0.0\n-------------\n * old\n", encoding="utf-8"
+ )
+ with self.assertRaisesRegex(PackageError, "ChangeLog"):
+ self.invoke(root / "mismatch", "stage-module", "x64", "renpy", "3.1.0",
+ str(binary), str(repository))
+
+ symbol = root / "renpy.pdb"
+ symbol.write_bytes(b"symbols")
+ self.assertEqual(
+ 0,
+ self.invoke(root / "symbol-stage", "stage-symbol", "x64", "renpy", str(symbol)),
+ )
+
+ def test_package_manifest_rejects_incomplete_or_malformed_module_records(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ archive = root / "renpy-3.1.0-x64.zip"
+ archive.write_bytes(b"archive")
+ identity = hashlib.sha256(b"renpy").hexdigest()
+ for index, (records, message) in enumerate((
+ (("--module", f"renpy=3.1.0={identity}"), "expected module set"),
+ (self.records(renpy=f"pickle=3.1.0={identity}"), "invalid module record"),
+ (self.records(renpy="renpy=3.1.0=not-a-digest"), "invalid module record"),
+ (self.records(renpy=f"renpy=3.1={identity}"), "invalid module version"),
+ (
+ (*self.records(), "--module", f"renpy=3.1.0={identity}"),
+ "duplicate module record",
+ ),
+ )):
+ with self.subTest(records=records), self.assertRaisesRegex(PackageError, message):
+ self.invoke(root / f"bad-manifest-{index}", "aggregate", *records, str(archive))
+
+ 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-3.1.0-x64.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", "3.1.0", 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", "3.1.0", str(stage))
+ with self.assertRaisesRegex(PackageError, "invalid module version"):
+ self.invoke(root / "bad-version", "stage-module", "x86", "renpy", "3.1",
+ str(binary), str(repository))
+
+ archive = root / "same.zip"
+ archive.write_bytes(b"same")
+ with self.assertRaisesRegex(PackageError, "duplicate archive name"):
+ self.invoke(root / "bad-aggregate", "aggregate", *self.records(),
+ str(archive), str(archive))
+
+ with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(PackageError, "OBSERVER_OUT_DIR"):
+ package_main(("aggregate", *self.records(), "missing.zip"))
+
+ def test_symbol_archive_requires_the_expected_module_set(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))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_packaging_ops.py b/build/tests/test_packaging_ops.py
new file mode 100644
index 0000000..11ddc09
--- /dev/null
+++ b/build/tests/test_packaging_ops.py
@@ -0,0 +1,1048 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import hashlib
+import json
+import os
+from pathlib import Path
+import site
+import shutil
+import stat
+import sys
+import tempfile
+import types
+import unittest
+from unittest import mock
+import zipfile
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+VENV_SITE = BUILD_ROOT / ".venv" / "Lib" / "site-packages"
+if VENV_SITE.is_dir():
+ site.addsitedir(str(VENV_SITE))
+
+import core.package as core_package # noqa: E402
+import packaging_ops # noqa: E402
+from core import binary_audit # noqa: E402
+from core.binary_audit import AuditError # noqa: E402
+from core.host import DeferredGate # noqa: E402
+from native import NativeTools # noqa: E402
+from packaging_ops import ( # noqa: E402
+ PackagingError,
+ PackagingTools,
+ PackageResult,
+ audit_binaries,
+ module_content_inputs,
+ package,
+)
+
+
+MODULES = ("renpy", "rpgmaker", "zanzarah")
+VERSIONS = {"renpy": "3.1.0", "rpgmaker": "1.1.0", "zanzarah": "2.1.0"}
+LICENSES = {
+ "renpy": ("Observer.txt", "rpatool.txt", "serde-pickle.txt", "zlib.txt"),
+ "rpgmaker": ("Observer.txt", "rgssad.txt"),
+ "zanzarah": ("Observer.txt", "zanzapak.txt"),
+}
+
+
+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 touch(root: Path, relative: str, content: bytes = b"binary") -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(content)
+ return path
+
+
+@dataclass(frozen=True)
+class Call:
+ argv: tuple[str, ...]
+ cwd: Path | None
+ env: dict[str, str] | None
+ captured: bool
+
+
+def python(*arguments: str) -> tuple[str, ...]:
+ return (sys.executable, "-m", *arguments)
+
+
+def symlink(link: Path, target: Path) -> bool:
+ """Create a real file symlink, reporting whether the host allowed it."""
+
+ try:
+ os.symlink(target, link)
+ except (OSError, NotImplementedError):
+ return False
+ return True
+
+
+def _writers(calls: list[Call]) -> list[Call]:
+ """Return the run-binskim writer invocations from a recorded call list."""
+
+ return [call for call in calls if len(call.argv) > 3 and call.argv[3] == "run-binskim"]
+
+
+class ScriptedRunner:
+ """A subprocess seam that answers dumpbin/MSBuild and runs core.package in process."""
+
+ def __init__(self, tools: PackagingTools, inventory: dict[str, dict[str, tuple[str, ...]]]) -> None:
+ self.tools = tools
+ self.inventory = inventory
+ self.calls: list[Call] = []
+
+ def _machine(self, binary: str) -> bytes:
+ architectures = {
+ "x86": b" 14C machine (x86)\n",
+ "x64": b" 8664 machine (x64)\n",
+ "arm64": b" AA64 machine (ARM64)\n",
+ }
+ architecture = next(part for part in Path(binary).parts if part in architectures)
+ return architectures[architecture]
+
+ def __call__(self, argv, *, cwd=None, env=None, stdout=None) -> None:
+ argv = tuple(str(item) for item in argv)
+ self.calls.append(Call(argv, cwd, None if env is None else dict(env), stdout is not None))
+ head = argv[0]
+ if head == str(self.tools.dumpbin):
+ mode = argv[1].lstrip("/")
+ if stdout is not None:
+ if mode == "headers":
+ stdout.write(self._machine(argv[2]))
+ elif mode == "dependents":
+ stdout.write(b" KERNEL32.dll\n")
+ elif Path(argv[2]).name == "leak-probe.exe":
+ # The real leak probe is a self-contained EXE with an empty export table.
+ stdout.write(b" Summary\n")
+ else:
+ stdout.write(b" 1 0 0001 LoadSubModule\n 2 1 0002 UnloadSubModule\n")
+ return
+ if head == str(self.tools.msbuild):
+ document = {
+ "Items": {
+ key: [{"Identity": value} for value in values]
+ for key, values in self.inventory[Path(argv[1]).stem].items()
+ }
+ }
+ if stdout is not None:
+ stdout.write(json.dumps(document).encode("utf-8"))
+ return
+ if argv[:3] == (sys.executable, "-m", "core.binary_audit"):
+ if argv[3] == "pe":
+ binary_audit.main(argv[3:])
+ return
+ if argv[:3] == (sys.executable, "-m", "core.package"):
+ if argv[3] == "smoke":
+ return
+ with mock.patch.dict(os.environ, env or {}):
+ core_package.main(argv[3:])
+ return
+
+
+class MutatingRunner(ScriptedRunner):
+ """A runner that swaps one repository binary the moment its audit begins."""
+
+ def __init__(self, tools: PackagingTools, inventory, *, target: str, swap) -> None:
+ super().__init__(tools, inventory)
+ self.target = target
+ self.swap = swap
+ self.swapped = False
+
+ def __call__(self, argv, *, cwd=None, env=None, stdout=None) -> None:
+ argv = tuple(str(item) for item in argv)
+ if argv[0] == str(self.tools.dumpbin) and argv[2].endswith(self.target) and not self.swapped:
+ self.swapped = True
+ self.swap()
+ super().__call__(argv, cwd=cwd, env=env, stdout=stdout)
+
+
+class Fixture:
+ def __init__(self, root: Path, *, architectures: tuple[str, ...] = ("x64",)) -> None:
+ self.root = root
+ self.repository = root / "repo"
+ self.output = root / "output"
+ self.output.mkdir(parents=True, exist_ok=True)
+ self._repository()
+ self._artifacts(architectures)
+
+ def tool(self, relative: str) -> Path:
+ path = self.root / "tools" / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(b"tool")
+ return path
+
+ def tools(self) -> PackagingTools:
+ return PackagingTools(
+ msbuild=self.tool("MSBuild.exe"),
+ vcpkg_root=self.tool("vcpkg/vcpkg.exe").parent,
+ dumpbin=self.tool("dumpbin.exe"),
+ binskim=self.tool("BinSkim.exe"),
+ )
+
+ def inventory(self) -> dict[str, dict[str, tuple[str, ...]]]:
+ return {
+ module: {
+ "ClCompile": (str(self.repository / f"src/modules/{module}/{module}.cpp"),),
+ "ClInclude": (str(self.repository / "src/modules/extractor.h"),),
+ }
+ for module in MODULES
+ }
+
+ def _repository(self) -> None:
+ for module, licenses in LICENSES.items():
+ write(self.repository, f"src/modules/{module}/observer_user.ini", f"[{module}]\n")
+ write(self.repository, f"src/modules/{module}/{module}.cpp", f"// {module}\n")
+ write(self.repository, f"src/modules/{module}/{module}.def", "EXPORTS\n")
+ write(self.repository, f"src/modules/{module}/{module}_private.h", "#pragma once\n")
+ write(self.repository, f"src/modules/{module}/VERSION", f"{VERSIONS[module]}\n")
+ write(
+ self.repository,
+ f"src/modules/{module}/ChangeLog",
+ f"Version {VERSIONS[module]}\n-------------\n * {module} release.\n",
+ )
+ write(self.repository, f"build/projects/{module}.vcxproj", "\n")
+ for name in licenses:
+ write(self.repository, f"licenses/{name}", f"license:{name}\n")
+ write(self.repository, "src/api.h", "#pragma once\n")
+ write(self.repository, "src/common/shared.h", "#pragma once\n")
+ write(self.repository, "src/core/bounded.h", "#pragma once\n")
+ write(self.repository, "src/modules/extractor.h", "#pragma once\n")
+ write(self.repository, "src/modules/version.rc", "VS_VERSION_INFO VERSIONINFO\n")
+ for name in (
+ "build/ObserverProjectConfigurations.props",
+ "build/ObserverConfiguration.props",
+ "build/ObserverProject.props",
+ "build/ObserverModuleVersion.props",
+ ):
+ write(self.repository, name, "\n")
+ write(self.repository, "vcpkg.json", '{"dependencies":["zlib"]}\n')
+ write(
+ self.repository,
+ "build/vcpkg/triplets/observer-x64-windows-static.cmake",
+ "static\n",
+ )
+ write(self.repository, "LICENSE.txt", "project license\n")
+
+ def _artifacts(self, architectures: tuple[str, ...]) -> None:
+ for architecture in architectures:
+ for module in MODULES:
+ touch(
+ self.repository,
+ f"out/native/bin/{architecture}/Release/{module}.so",
+ f"{module}-{architecture}-release".encode(),
+ )
+ touch(
+ self.repository,
+ f"out/native/bin/{architecture}/Release/{module}.pdb",
+ f"{module}-{architecture}-pdb".encode(),
+ )
+ touch(self.repository, f"out/native/bin/{architecture}/Release/tests.exe", b"tests")
+
+
+class SelectionTests(unittest.TestCase):
+ def test_module_selection_defaults_to_all_and_reorders(self) -> None:
+ self.assertEqual(packaging_ops._modules(None), MODULES)
+ self.assertEqual(packaging_ops._modules("all"), MODULES)
+ self.assertEqual(packaging_ops._modules("renpy"), ("renpy",))
+ self.assertEqual(packaging_ops._modules(("zanzarah", "renpy")), ("renpy", "zanzarah"))
+
+ def test_module_selection_rejects_empty_unknown_and_duplicate(self) -> None:
+ for selection, message in (
+ ((), "must not be empty"),
+ (("pickle",), "unknown module"),
+ (("renpy", "renpy"), "must not repeat"),
+ ):
+ with self.subTest(selection=selection), self.assertRaisesRegex(ValueError, message):
+ packaging_ops._modules(selection)
+
+ def test_architecture_selection_is_unique_and_supported(self) -> None:
+ self.assertEqual(packaging_ops._architectures(("arm64", "x86")), ("x86", "arm64"))
+ for architectures, message in (
+ ((), "at least one"),
+ (("riscv64",), "unsupported architecture"),
+ (("x64", "x64"), "must not repeat"),
+ ):
+ with self.subTest(architectures=architectures), self.assertRaisesRegex(ValueError, message):
+ packaging_ops._architectures(architectures)
+
+ def test_smoke_selection_must_be_a_selected_subset(self) -> None:
+ self.assertEqual(packaging_ops._smoke(("x64", "arm64"), None), ("x64", "arm64"))
+ self.assertEqual(packaging_ops._smoke(("x64", "arm64"), ("arm64",)), ("arm64",))
+ for smokes, message in ((("x86",), "not selected"), (("x64", "x64"), "must not repeat")):
+ with self.subTest(smokes=smokes), self.assertRaisesRegex(ValueError, message):
+ packaging_ops._smoke(("x64", "arm64"), smokes)
+
+
+class ConfinementTests(unittest.TestCase):
+ def test_is_reparse_reports_missing_links_and_windows_attributes(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ self.assertFalse(packaging_ops._is_reparse(root / "missing"))
+ link = types.SimpleNamespace(st_mode=stat.S_IFLNK, st_file_attributes=0)
+ with mock.patch("packaging_ops.os.lstat", return_value=link):
+ self.assertTrue(packaging_ops._is_reparse(root))
+ junction = types.SimpleNamespace(
+ st_mode=stat.S_IFDIR, st_file_attributes=stat.FILE_ATTRIBUTE_REPARSE_POINT
+ )
+ with mock.patch("packaging_ops.os.lstat", return_value=junction):
+ self.assertTrue(packaging_ops._is_reparse(root))
+ plain = types.SimpleNamespace(st_mode=stat.S_IFDIR, st_file_attributes=0)
+ with mock.patch("packaging_ops.os.lstat", return_value=plain):
+ self.assertFalse(packaging_ops._is_reparse(root))
+
+ def test_confined_rejects_escaping_and_reparse_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ output = root / "output"
+ output.mkdir()
+ self.assertEqual(packaging_ops._confined(output / "deep", output), output / "deep")
+ with self.assertRaisesRegex(PackagingError, "escapes"):
+ packaging_ops._confined(root / "other", output)
+ with mock.patch("packaging_ops._is_reparse", side_effect=lambda path: path == output):
+ with self.assertRaisesRegex(PackagingError, "reparse point"):
+ packaging_ops._confined(output / "deep", output)
+
+ def test_reset_directory_rebuilds_but_refuses_a_file(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ output = Path(temporary)
+ stale = output / "staging" / "inner"
+ stale.mkdir(parents=True)
+ (stale / "leftover.txt").write_text("stale", encoding="utf-8")
+ target = packaging_ops._reset_directory(output / "staging", output)
+ self.assertTrue(target.is_dir())
+ self.assertEqual(list(target.iterdir()), [])
+ blocker = output / "file"
+ blocker.write_text("not a directory", encoding="utf-8")
+ with self.assertRaisesRegex(PackagingError, "not a directory"):
+ packaging_ops._reset_directory(blocker, output)
+
+ def test_output_root_requires_an_existing_plain_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ missing = root / "missing"
+ with self.assertRaisesRegex(PackagingError, "must be an existing directory"):
+ packaging_ops._output_root(missing)
+ output = root / "output"
+ output.mkdir()
+ self.assertEqual(packaging_ops._output_root(output), output)
+ with mock.patch("packaging_ops._is_reparse", side_effect=lambda path: path == output):
+ with self.assertRaisesRegex(PackagingError, "escapes|reparse point"):
+ packaging_ops._output_root(output)
+
+ def test_leaf_refuses_a_reparse_or_directory_leaf(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ self.assertEqual(packaging_ops._leaf(root / "fresh.txt", root), root / "fresh.txt")
+ directory = root / "taken"
+ directory.mkdir()
+ with self.assertRaisesRegex(PackagingError, "leaf is a directory"):
+ packaging_ops._leaf(directory, root)
+ with mock.patch("packaging_ops._is_reparse", side_effect=lambda path: path.name == "link.txt"):
+ with self.assertRaisesRegex(PackagingError, "reparse point"):
+ packaging_ops._leaf(root / "link.txt", root)
+
+ def test_reset_directory_refuses_to_remove_through_a_reparse_child(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ output = Path(temporary)
+ sentinel = output / "staging" / "inner" / "leftover.txt"
+ sentinel.parent.mkdir(parents=True)
+ sentinel.write_text("stale", encoding="utf-8")
+ with mock.patch(
+ "packaging_ops._is_reparse", side_effect=lambda path: path.name == "leftover.txt"
+ ):
+ with self.assertRaisesRegex(PackagingError, "reparse point"):
+ packaging_ops._reset_directory(output / "staging", output)
+ self.assertEqual(sentinel.read_text(encoding="utf-8"), "stale")
+
+ def test_publish_replaces_a_previous_tree_and_refuses_a_file(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ pending = root / "staging" / "publish"
+ (pending / "x64").mkdir(parents=True)
+ (pending / "x64" / "renpy.zip").write_bytes(b"new")
+ previous = root / "packages"
+ previous.mkdir()
+ (previous / "stale.txt").write_bytes(b"old")
+ published = packaging_ops._publish(pending, previous, root)
+ self.assertEqual(published, previous)
+ self.assertEqual([item.name for item in published.iterdir()], ["x64"])
+ blocker = root / "blocker"
+ blocker.write_text("file", encoding="utf-8")
+ with self.assertRaisesRegex(PackagingError, "not a directory"):
+ packaging_ops._publish(pending, blocker, root)
+
+ def test_publish_restores_the_previous_tree_when_replacement_fails(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ pending = root / "staging" / "publish"
+ (pending / "x64").mkdir(parents=True)
+ (pending / "x64" / "renpy.zip").write_bytes(b"new")
+ previous = root / "packages"
+ (previous / "x64").mkdir(parents=True)
+ (previous / "x64" / "renpy.zip").write_bytes(b"old-archive")
+ (previous / "packages.json").write_bytes(b"old-manifest")
+ real_replace = os.replace
+
+ def flaky(source: str, target: str) -> None:
+ if Path(source) == pending and Path(target) == previous:
+ raise PermissionError("sharing violation")
+ real_replace(source, target)
+
+ with mock.patch("packaging_ops.os.replace", side_effect=flaky):
+ with self.assertRaises(PermissionError):
+ packaging_ops._publish(pending, previous, root)
+ # The old manifest and archive bytes survive the failed replacement.
+ self.assertEqual((previous / "packages.json").read_bytes(), b"old-manifest")
+ self.assertEqual((previous / "x64" / "renpy.zip").read_bytes(), b"old-archive")
+ # The freshly staged tree is untouched so the caller can retry.
+ self.assertTrue((pending / "x64" / "renpy.zip").is_file())
+
+ def test_publish_clears_a_stale_backup_before_swapping(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ pending = root / "staging" / "publish"
+ (pending / "x64").mkdir(parents=True)
+ (pending / "x64" / "renpy.zip").write_bytes(b"new")
+ previous = root / "packages"
+ previous.mkdir()
+ (previous / "stale.txt").write_bytes(b"old")
+ backup = root / "packages.previous"
+ backup.mkdir()
+ (backup / "leftover.txt").write_bytes(b"junk")
+ published = packaging_ops._publish(pending, previous, root)
+ self.assertEqual(published, previous)
+ self.assertEqual([item.name for item in published.iterdir()], ["x64"])
+ self.assertFalse(backup.exists())
+
+ def test_publish_propagates_a_failure_without_a_previous_tree(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ pending = root / "staging" / "publish"
+ (pending / "x64").mkdir(parents=True)
+ (pending / "x64" / "renpy.zip").write_bytes(b"new")
+ target = root / "packages"
+ real_replace = os.replace
+
+ def flaky(source: str, destination: str) -> None:
+ if Path(source) == pending:
+ raise PermissionError("sharing violation")
+ real_replace(source, destination)
+
+ with mock.patch("packaging_ops.os.replace", side_effect=flaky):
+ with self.assertRaises(PermissionError):
+ packaging_ops._publish(pending, target, root)
+ self.assertFalse(target.exists())
+ self.assertTrue((pending / "x64" / "renpy.zip").is_file())
+
+ def test_audit_refuses_a_real_leaf_symlink_without_touching_its_target(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ outside = Path(temporary) / "outside.txt"
+ outside.write_text("sentinel", encoding="utf-8")
+ directory = fixture.output / "audit" / "x64" / "renpy"
+ directory.mkdir(parents=True)
+ if not symlink(directory / "headers.txt", outside):
+ self.skipTest("host does not support symlink creation")
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ with self.assertRaisesRegex(PackagingError, "reparse point"):
+ audit_binaries(
+ fixture.repository, ("x64",), fixture.output, modules=("renpy",),
+ tools=tools, runner=runner,
+ )
+ self.assertEqual(outside.read_text(encoding="utf-8"), "sentinel")
+ self.assertEqual(runner.calls, [])
+
+ def test_audit_refuses_a_mocked_reparse_leaf(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ directory = fixture.output / "audit" / "x64" / "renpy"
+ directory.mkdir(parents=True)
+ (directory / "headers.txt").write_text("plain", encoding="utf-8")
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ with mock.patch(
+ "packaging_ops._is_reparse", side_effect=lambda path: path.name == "headers.txt"
+ ):
+ with self.assertRaisesRegex(PackagingError, "reparse point"):
+ audit_binaries(
+ fixture.repository, ("x64",), fixture.output, modules=("renpy",),
+ tools=tools, runner=runner,
+ )
+ self.assertEqual(runner.calls, [])
+ self.assertEqual((directory / "headers.txt").read_text(encoding="utf-8"), "plain")
+
+ def test_audit_rejects_a_linked_binskim_report_before_writing(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ outside = Path(temporary) / "outside.sarif"
+ outside.write_text("sentinel", encoding="utf-8")
+ directory = fixture.output / "audit" / "x64" / "renpy"
+ directory.mkdir(parents=True)
+ if not symlink(directory / "binskim.sarif", outside):
+ self.skipTest("host does not support symlink creation")
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ with self.assertRaisesRegex(PackagingError, "reparse point"):
+ audit_binaries(
+ fixture.repository, ("x64",), fixture.output, modules=("renpy",),
+ tools=tools, runner=runner,
+ )
+ self.assertEqual(outside.read_text(encoding="utf-8"), "sentinel")
+ self.assertEqual(_writers(runner.calls), [])
+
+ def test_audit_rejects_a_directory_binskim_report_before_writing(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ directory = fixture.output / "audit" / "x64" / "renpy"
+ (directory / "binskim.sarif").mkdir(parents=True)
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ with self.assertRaisesRegex(PackagingError, "leaf is a directory"):
+ audit_binaries(
+ fixture.repository, ("x64",), fixture.output, modules=("renpy",),
+ tools=tools, runner=runner,
+ )
+ self.assertEqual(_writers(runner.calls), [])
+
+ def test_audit_removes_a_stale_binskim_report_before_writing(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ directory = fixture.output / "audit" / "x64" / "renpy"
+ directory.mkdir(parents=True)
+ stale = directory / "binskim.sarif"
+ stale.write_text("stale", encoding="utf-8")
+ tools = fixture.tools()
+ observed: dict[str, bool] = {}
+
+ class Observing(ScriptedRunner):
+ def __call__(self, argv, *, cwd=None, env=None, stdout=None) -> None:
+ if len(argv) > 3 and argv[3] == "run-binskim":
+ observed["present"] = stale.exists()
+ super().__call__(argv, cwd=cwd, env=env, stdout=stdout)
+
+ runner = Observing(tools, fixture.inventory())
+ audit_binaries(
+ fixture.repository, ("x64",), fixture.output, modules=("renpy",),
+ tools=tools, runner=runner,
+ )
+ self.assertFalse(observed.get("present", True))
+ self.assertFalse(stale.exists())
+
+
+class InventoryTests(unittest.TestCase):
+ def test_item_paths_reads_identities_relative_to_the_repository(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ source = touch(repository, "src/renpy.cpp")
+ header = touch(repository, "src/extractor.h")
+ outside = touch(repository.parent, "outside.h")
+ text = json.dumps({
+ "Items": {
+ "ClCompile": [{"Identity": str(source)}, str(header)],
+ "ClInclude": [{"Identity": str(outside)}],
+ }
+ })
+ self.assertEqual(
+ packaging_ops._item_paths(text, repository),
+ ("src/extractor.h", "src/renpy.cpp"),
+ )
+
+ def test_item_paths_fails_closed_on_malformed_or_missing_inputs(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ touch(repository, "src/renpy.cpp")
+ cases = (
+ ("no json here", "no item inventory"),
+ ("{not json", "is not JSON"),
+ (json.dumps({"Properties": {}}), "has no Items"),
+ (json.dumps({"Items": {"ClCompile": {}}}), "is not a list"),
+ (json.dumps({"Items": {"ClCompile": [{}]}}), "non-string identity"),
+ (json.dumps({"Items": {"ClCompile": [{"Identity": str(repository / 'gone.cpp')}]}}),
+ "is missing"),
+ (json.dumps({"Items": {"ClCompile": []}}), "no project items"),
+ )
+ for text, message in cases:
+ with self.subTest(message=message), self.assertRaisesRegex(PackagingError, message):
+ packaging_ops._item_paths(text, repository)
+
+ def test_project_inventory_queries_msbuild_once(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ scratch = fixture.output / "staging"
+ scratch.mkdir()
+ items = packaging_ops._project_inventory(fixture.repository, "renpy", tools, runner, scratch)
+ self.assertEqual(
+ items,
+ (
+ "src/modules/extractor.h",
+ "src/modules/renpy/renpy.cpp",
+ ),
+ )
+ argv = runner.calls[0].argv
+ self.assertEqual(argv[0], str(tools.msbuild))
+ self.assertIn(f"-p:VcpkgRoot={tools.vcpkg_root}", argv)
+
+ with self.assertRaisesRegex(PackagingError, "project is missing"):
+ packaging_ops._project_inventory(fixture.repository, "missing", tools, runner, scratch)
+
+
+class ContentInputTests(unittest.TestCase):
+ def test_content_inputs_cover_selected_tree_shared_and_conservative_headers(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ inputs = module_content_inputs(
+ fixture.repository, "renpy",
+ ("src/modules/renpy/renpy.cpp", "src/modules/extractor.h"),
+ )
+ self.assertIn("src/modules/renpy/renpy.cpp", inputs)
+ self.assertIn("src/modules/renpy/observer_user.ini", inputs)
+ self.assertIn("src/modules/renpy/renpy_private.h", inputs)
+ self.assertIn("src/modules/renpy/ChangeLog", inputs)
+ self.assertIn("src/api.h", inputs)
+ self.assertIn("src/common/shared.h", inputs)
+ self.assertIn("src/core/bounded.h", inputs)
+ self.assertIn("src/modules/extractor.h", inputs)
+ self.assertIn("LICENSE.txt", inputs)
+ self.assertIn("licenses/rpatool.txt", inputs)
+ self.assertNotIn("src/modules/rpgmaker/rpgmaker.cpp", inputs)
+ self.assertNotIn("src/modules/renpy/VERSION", inputs)
+
+ # A repository without the optional shared tree must still resolve every other input.
+ shutil.rmtree(fixture.repository / "src" / "common")
+ without_common = module_content_inputs(
+ fixture.repository, "renpy", ("src/modules/renpy/renpy.cpp",)
+ )
+ self.assertNotIn("src/common/shared.h", without_common)
+ self.assertIn("src/core/bounded.h", without_common)
+
+ def test_content_inputs_include_the_project_and_shared_build_props(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ inputs = module_content_inputs(
+ fixture.repository, "renpy", ("src/modules/renpy/renpy.cpp",)
+ )
+ self.assertIn("build/projects/renpy.vcxproj", inputs)
+ for name in (
+ "build/ObserverProjectConfigurations.props",
+ "build/ObserverConfiguration.props",
+ "build/ObserverProject.props",
+ "build/ObserverModuleVersion.props",
+ ):
+ with self.subTest(name=name):
+ self.assertIn(name, inputs)
+
+ def test_shared_props_and_project_files_move_only_the_selected_identity(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+
+ def identities() -> dict[str, str]:
+ return {
+ module: packaging_ops.content_identity(
+ fixture.repository, module_content_inputs(fixture.repository, module, ())
+ )
+ for module in MODULES
+ }
+
+ baseline = identities()
+ (fixture.repository / "build/ObserverProject.props").write_text(
+ "changed\n", encoding="utf-8"
+ )
+ shared_props = identities()
+ (fixture.repository / "build/projects/renpy.vcxproj").write_text(
+ "changed\n", encoding="utf-8"
+ )
+ project = identities()
+ (fixture.repository / "src/modules/rpgmaker/rpgmaker_private.h").write_text(
+ "#pragma once\n// moved\n", encoding="utf-8"
+ )
+ sibling = identities()
+ (fixture.repository / "src/modules/rpgmaker/VERSION").write_text(
+ "9.9.9\n", encoding="utf-8"
+ )
+ version = identities()
+ (fixture.repository / "src/modules/renpy/ChangeLog").write_text(
+ "Version 3.1.0\n-------------\n * renamed note\n", encoding="utf-8"
+ )
+ changelog = identities()
+
+ self.assertEqual(set(baseline), set(MODULES))
+ # A shared project definition changes every module's compiled bytes.
+ self.assertTrue(all(baseline[module] != shared_props[module] for module in MODULES))
+ # Only the selected project definition moves that module's identity.
+ self.assertNotEqual(shared_props["renpy"], project["renpy"])
+ self.assertEqual(shared_props["rpgmaker"], project["rpgmaker"])
+ # A sibling module's production tree moves only that module.
+ self.assertNotEqual(project["rpgmaker"], sibling["rpgmaker"])
+ self.assertEqual(project["renpy"], sibling["renpy"])
+ # VERSION is never part of the content identity.
+ self.assertEqual(sibling, version)
+ # The module's own ChangeLog moves only that module's identity.
+ self.assertNotEqual(version["renpy"], changelog["renpy"])
+ self.assertEqual(version["rpgmaker"], changelog["rpgmaker"])
+ self.assertEqual(version["zanzarah"], changelog["zanzarah"])
+
+
+class AuditTests(unittest.TestCase):
+ def test_audit_runs_every_release_gate_in_order(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ evidence = audit_binaries(
+ fixture.repository, ("x64",), fixture.output, modules=("renpy",), tools=tools,
+ runner=runner,
+ )
+ binary = fixture.repository / "out/native/bin/x64/Release/renpy.so"
+ directory = fixture.output / "audit" / "x64" / "renpy"
+ headers, dependents, exports = (
+ directory / "headers.txt", directory / "dependents.txt", directory / "exports.txt"
+ )
+ self.assertEqual(
+ evidence,
+ (headers, dependents, exports, directory / "binskim.sarif"),
+ )
+ self.assertEqual(
+ [call.argv for call in runner.calls],
+ [
+ (str(tools.dumpbin), "/headers", str(binary)),
+ (str(tools.dumpbin), "/dependents", str(binary)),
+ (str(tools.dumpbin), "/exports", str(binary)),
+ (*python("core.binary_audit"), "pe", "x64",
+ str(headers), str(dependents), str(exports)),
+ (*python("core.binary_audit"), "run-binskim", str(tools.binskim), str(binary)),
+ (*python("core.binary_audit"), "binskim", str(directory / "binskim.sarif")),
+ (*python("core.module_version"), "verify", VERSIONS["renpy"], str(binary)),
+ ],
+ )
+ self.assertTrue(runner.calls[0].captured)
+ self.assertEqual(runner.calls[3].env, None)
+ self.assertEqual(runner.calls[4].env, {"OBSERVER_OUT_DIR": str(directory)})
+ self.assertEqual(runner.calls[4].cwd, packaging_ops.BUILD_ROOT)
+ self.assertEqual(headers.read_text(encoding="utf-8"), " 8664 machine (x64)\n")
+
+ def test_audit_includes_the_x64_leak_probe_only(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary), architectures=("x64", "arm64"))
+ touch(fixture.repository, "out/native/bin/x64/Release/leak-probe.exe")
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ audit_binaries(
+ fixture.repository, ("x64", "arm64"), fixture.output,
+ modules=("renpy",), include_leak_probe=True, tools=tools, runner=runner,
+ )
+ probe_calls = [call for call in runner.calls if call.argv[0] == str(tools.dumpbin)]
+ probe = [call for call in probe_calls if "leak-probe.exe" in call.argv[2]]
+ self.assertEqual(len(probe), 3)
+ self.assertTrue(all("x64" in call.argv[2] for call in probe))
+ self.assertFalse(any("arm64" in call.argv[2] for call in probe))
+
+ def test_audit_binds_the_executable_kind_to_the_leak_probe_only(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ touch(fixture.repository, "out/native/bin/x64/Release/leak-probe.exe")
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ audit_binaries(
+ fixture.repository, ("x64",), fixture.output,
+ modules=("renpy",), include_leak_probe=True, tools=tools, runner=runner,
+ )
+ pe_calls = [
+ call.argv for call in runner.calls
+ if call.argv[:3] == python("core.binary_audit") and call.argv[3] == "pe"
+ ]
+ probe = next(argv for argv in pe_calls if "leak-probe" in " ".join(argv))
+ module = next(argv for argv in pe_calls if "leak-probe" not in " ".join(argv))
+ self.assertEqual(
+ probe[:6], (*python("core.binary_audit"), "pe", "--executable", "x64")
+ )
+ self.assertEqual(module[:5], (*python("core.binary_audit"), "pe", "x64"))
+ self.assertNotIn("--executable", module)
+ # The real validator ran over the faithful empty probe export table...
+ self.assertNotIn("LoadSubModule", Path(probe[-1]).read_text(encoding="utf-8"))
+ # ...while the shipped module contract still rejects an empty export table.
+ with self.assertRaisesRegex(AuditError, "unexpected exports"):
+ binary_audit.main(("pe", "x64", module[-3], module[-2], probe[-1]))
+
+ def test_audit_fails_closed_when_a_release_artifact_is_missing(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ (fixture.repository / "out/native/bin/x64/Release/renpy.so").unlink()
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ with self.assertRaisesRegex(PackagingError, "artifact is missing"):
+ audit_binaries(
+ fixture.repository, ("x64",), fixture.output, modules=("renpy",),
+ tools=tools, runner=runner,
+ )
+ self.assertEqual(runner.calls, [])
+
+
+class PackageTests(unittest.TestCase):
+ def invoke(self, fixture: Fixture, **options) -> tuple[PackageResult, ScriptedRunner]:
+ tools = fixture.tools()
+ runner = ScriptedRunner(tools, fixture.inventory())
+ result = package(
+ fixture.repository, options.pop("architectures", ("x64",)), fixture.output,
+ tools=tools, runner=runner, **options,
+ )
+ return result, runner
+
+ def test_package_emits_only_selected_assets_with_per_module_symbols(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ result, _runner = self.invoke(fixture)
+ packages = fixture.output / "packages" / "x64"
+ expected = {
+ "renpy-3.1.0-x64.zip", "renpy-3.1.0-x64-pdb.zip",
+ "rpgmaker-1.1.0-x64.zip", "rpgmaker-1.1.0-x64-pdb.zip",
+ "zanzarah-2.1.0-x64.zip", "zanzarah-2.1.0-x64-pdb.zip",
+ "observer-modules-x64-pdb.zip",
+ }
+ self.assertEqual({path.name for path in packages.iterdir()}, expected)
+ self.assertEqual({path.name for path in result.archives}, expected)
+ self.assertEqual(result.deferred, ())
+ self.assertTrue((fixture.output / "packages" / "packages.json").is_file())
+
+ with zipfile.ZipFile(packages / "renpy-3.1.0-x64.zip") as archive:
+ self.assertEqual(
+ archive.namelist(),
+ ["ChangeLog", "docs/license.txt",
+ *[f"docs/thirdparty/{name}" for name in LICENSES["renpy"]],
+ "observer_user.ini", "renpy.so"],
+ )
+ with zipfile.ZipFile(packages / "renpy-3.1.0-x64-pdb.zip") as archive:
+ self.assertEqual(archive.namelist(), ["renpy.pdb"])
+
+ document = json.loads(
+ (fixture.output / "packages" / "packages.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual([record["module"] for record in document["modules"]], list(MODULES))
+ self.assertEqual(
+ {record["module"]: record["version"] for record in document["modules"]}, VERSIONS
+ )
+ self.assertEqual({archive["name"] for archive in document["archives"]}, expected)
+ for record in document["modules"]:
+ self.assertRegex(record["content"], r"^[0-9a-f]{64}$")
+ self.assertFalse((fixture.output / "staging").exists())
+
+ def test_package_selects_one_module_and_skips_the_combined_symbols(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ result, _runner = self.invoke(fixture, modules=("zanzarah",))
+ packages = fixture.output / "packages" / "x64"
+ self.assertEqual(
+ {path.name for path in packages.iterdir()},
+ {"zanzarah-2.1.0-x64.zip", "zanzarah-2.1.0-x64-pdb.zip"},
+ )
+ document = json.loads(
+ (fixture.output / "packages" / "packages.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual([record["module"] for record in document["modules"]], ["zanzarah"])
+ self.assertEqual(
+ {archive["name"] for archive in document["archives"]},
+ {"zanzarah-2.1.0-x64.zip", "zanzarah-2.1.0-x64-pdb.zip"},
+ )
+ self.assertEqual({path.name for path in result.archives},
+ {"zanzarah-2.1.0-x64.zip", "zanzarah-2.1.0-x64-pdb.zip"})
+
+ def test_package_smokes_runnable_architectures_and_defers_the_rest(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary), architectures=("x64", "arm64"))
+ result, runner = self.invoke(
+ fixture, architectures=("x64", "arm64"), host="x64",
+ )
+ smoke = [call.argv for call in runner.calls if len(call.argv) > 3 and call.argv[3] == "smoke"]
+ tests = str(fixture.repository / "out/native/bin/x64/Release/tests.exe")
+ self.assertEqual(
+ result.deferred,
+ (DeferredGate("package-runtime", "arm64", "host cannot execute arm64 package-runtime"),),
+ )
+ self.assertEqual(len(smoke), len(MODULES))
+ for argv in smoke:
+ self.assertEqual(argv[:5], (*python("core.package"), "smoke", "x64"))
+ self.assertIn(argv[5], MODULES)
+ self.assertTrue(argv[6].endswith(".zip"))
+ self.assertEqual(argv[7], tests)
+
+ def test_package_requires_the_test_runner_for_runnable_smoke(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ (fixture.repository / "out/native/bin/x64/Release/tests.exe").unlink()
+ with self.assertRaisesRegex(PackagingError, "test runner is missing"):
+ self.invoke(fixture, host="x64")
+
+ def test_package_fails_closed_when_symbols_or_resources_are_missing(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ (fixture.repository / "out/native/bin/x64/Release/renpy.pdb").unlink()
+ with self.assertRaisesRegex(PackagingError, "symbols are missing"):
+ self.invoke(fixture)
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ (fixture.repository / "src/modules/renpy/observer_user.ini").unlink()
+ with self.assertRaises((PackagingError, FileNotFoundError, OSError)):
+ self.invoke(fixture)
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ (fixture.repository / "src/modules/renpy/VERSION").unlink()
+ with self.assertRaisesRegex(Exception, "unreadable"):
+ self.invoke(fixture)
+
+ def test_validate_zip_matches_staged_bytes(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ payload = root / "renpy.pdb"
+ payload.write_bytes(b"symbols")
+ archive = root / "one.zip"
+ packaging_ops._zip(archive, {"renpy.pdb": payload})
+ packaging_ops._validate_zip(archive, {"renpy.pdb": payload})
+
+ extra = root / "extra.zip"
+ packaging_ops._zip(extra, {"renpy.pdb": payload, "rpgmaker.pdb": payload})
+ with self.assertRaisesRegex(PackagingError, "does not match"):
+ packaging_ops._validate_zip(extra, {"renpy.pdb": payload})
+
+ changed = root / "changed.zip"
+ packaging_ops._zip(changed, {"renpy.pdb": payload})
+ tampered = root / "other.pdb"
+ tampered.write_bytes(b"different")
+ with self.assertRaisesRegex(PackagingError, "does not match"):
+ packaging_ops._validate_zip(changed, {"renpy.pdb": tampered})
+
+ duplicate = root / "duplicate.zip"
+ with self.assertWarns(UserWarning), zipfile.ZipFile(duplicate, "w") as bundle:
+ bundle.writestr("renpy.pdb", b"symbols")
+ bundle.writestr("renpy.pdb", b"symbols")
+ with self.assertRaisesRegex(PackagingError, "duplicate entries"):
+ packaging_ops._validate_zip(duplicate, {"renpy.pdb": payload})
+
+ def test_package_audits_and_publishes_the_same_snapshot(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ tools = fixture.tools()
+ source = fixture.repository / "out/native/bin/x64/Release/renpy.so"
+ original = source.read_bytes()
+ runner = MutatingRunner(
+ tools, fixture.inventory(), target="renpy.so",
+ swap=lambda: source.write_bytes(b"swapped-after-audit"),
+ )
+ package(
+ fixture.repository, ("x64",), fixture.output, modules=("renpy",),
+ tools=tools, runner=runner,
+ )
+ published = fixture.output / "packages" / "x64" / "renpy-3.1.0-x64.zip"
+ with zipfile.ZipFile(published) as archive:
+ self.assertEqual(archive.read("renpy.so"), original)
+ # The repository copy really moved after the audit began.
+ self.assertEqual(source.read_bytes(), b"swapped-after-audit")
+ self.assertFalse(any("/t:Build" in " ".join(call.argv) for call in runner.calls))
+
+ def test_package_replaces_a_previous_selected_set(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ self.invoke(fixture)
+ packages = fixture.output / "packages" / "x64"
+ self.assertIn("observer-modules-x64-pdb.zip", {item.name for item in packages.iterdir()})
+
+ self.invoke(fixture, modules=("renpy",))
+ self.assertEqual(
+ {item.name for item in packages.iterdir()},
+ {"renpy-3.1.0-x64.zip", "renpy-3.1.0-x64-pdb.zip"},
+ )
+ document = json.loads(
+ (fixture.output / "packages" / "packages.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual([record["module"] for record in document["modules"]], ["renpy"])
+ self.assertEqual(
+ {archive["name"] for archive in document["archives"]},
+ {"renpy-3.1.0-x64.zip", "renpy-3.1.0-x64-pdb.zip"},
+ )
+
+ def test_package_rerun_drops_superseded_version_and_architecture_assets(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary), architectures=("x64", "arm64"))
+ self.invoke(fixture, architectures=("x64",), modules=("renpy",))
+ (fixture.repository / "src/modules/renpy/VERSION").write_text(
+ "3.2.0\n", encoding="utf-8"
+ )
+ (fixture.repository / "src/modules/renpy/ChangeLog").write_text(
+ "Version 3.2.0\n-------------\n * renpy 3.2.0 release.\n", encoding="utf-8"
+ )
+ self.invoke(fixture, architectures=("x64", "arm64"), modules=("renpy",))
+ for architecture in ("x64", "arm64"):
+ self.assertEqual(
+ {item.name for item in (fixture.output / "packages" / architecture).iterdir()},
+ {f"renpy-3.2.0-{architecture}.zip",
+ f"renpy-3.2.0-{architecture}-pdb.zip"},
+ )
+ document = json.loads(
+ (fixture.output / "packages" / "packages.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual({record["version"] for record in document["modules"]}, {"3.2.0"})
+ self.assertEqual(
+ {archive["name"] for archive in document["archives"]},
+ {
+ "renpy-3.2.0-x64.zip", "renpy-3.2.0-x64-pdb.zip",
+ "renpy-3.2.0-arm64.zip", "renpy-3.2.0-arm64-pdb.zip",
+ },
+ )
+
+ def test_package_failed_rerun_preserves_the_published_release(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ fixture = Fixture(Path(temporary))
+ self.invoke(fixture)
+ packages = fixture.output / "packages"
+ before = (packages / "packages.json").read_text(encoding="utf-8")
+ before_names = sorted(item.name for item in packages.iterdir())
+ (fixture.repository / "out/native/bin/x64/Release/renpy.pdb").unlink()
+ with self.assertRaisesRegex(PackagingError, "symbols are missing"):
+ self.invoke(fixture)
+ self.assertEqual((packages / "packages.json").read_text(encoding="utf-8"), before)
+ self.assertEqual(sorted(item.name for item in packages.iterdir()), before_names)
+ self.assertFalse((fixture.output / "staging").exists())
+
+
+class ToolsTests(unittest.TestCase):
+ def test_locate_packaging_tools_composes_native_and_quality_tools(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ msbuild, vcpkg = root / "MSBuild.exe", root / "vcpkg/vcpkg.exe"
+ dumpbin, binskim = root / "dumpbin.exe", root / "BinSkim.exe"
+ native_tools = NativeTools(msbuild=msbuild, vcpkg=vcpkg)
+ with mock.patch("packaging_ops.locate_tools", return_value=native_tools) as locate, \
+ mock.patch("packaging_ops.discover_msvc_toolchain", return_value="chain"), \
+ mock.patch("packaging_ops.resolve_dumpbin",
+ return_value=types.SimpleNamespace(path=dumpbin)), \
+ mock.patch("packaging_ops.resolve_binskim",
+ return_value=types.SimpleNamespace(path=binskim)):
+ tools = packaging_ops.locate_packaging_tools(environ={"VCPKG_ROOT": str(root)})
+ locate.assert_called_once_with(environ={"VCPKG_ROOT": str(root)})
+ self.assertEqual(
+ (tools.msbuild, tools.vcpkg_root, tools.dumpbin, tools.binskim),
+ (msbuild, vcpkg.parent, dumpbin, binskim),
+ )
+
+ def test_default_runner_forwards_argv_cwd_env_and_stdout(self) -> None:
+ with mock.patch("packaging_ops.subprocess.run") as run:
+ packaging_ops._default_runner(
+ (Path("tool.exe"),), cwd=Path("here"), env={"A": "1"}, stdout=None
+ )
+ run.assert_called_once_with(
+ ["tool.exe"], cwd="here", env={**os.environ, "A": "1"}, check=True, stdout=None,
+ )
+ with mock.patch("packaging_ops.subprocess.run") as run:
+ packaging_ops._default_runner((Path("tool.exe"),))
+ run.assert_called_once_with(["tool.exe"], cwd=None, env=None, check=True, stdout=None)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_python_coverage.py b/build/tests/test_python_coverage.py
new file mode 100644
index 0000000..e02b655
--- /dev/null
+++ b/build/tests/test_python_coverage.py
@@ -0,0 +1,130 @@
+"""Worker tests for the retained ``core.python_coverage`` gate.
+
+These assertions were preserved from ``test_python_coverage_graph.py`` when the
+retired ``graphs.python_coverage`` composition was dropped. They build a real
+temporary coverage project and run the actual ``core.python_coverage`` CLI, so
+the line/branch failure handling and evidence publication stay covered.
+"""
+
+from __future__ import annotations
+
+import json
+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.python_coverage import main as coverage_main # noqa: E402
+
+
+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())
+
+ 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_python_coverage_invocation.py b/build/tests/test_python_coverage_invocation.py
new file mode 100644
index 0000000..d53675a
--- /dev/null
+++ b/build/tests/test_python_coverage_invocation.py
@@ -0,0 +1,93 @@
+"""Focused regression for the coverage.py invocation route.
+
+The repository venv once carried a ``coverage.exe`` launcher pinned to a
+missing interpreter. The gate must instead run coverage through the *current*
+interpreter (``python -m coverage``), so the pinned project interpreter owns
+the run.
+"""
+
+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 import python_coverage # noqa: E402
+
+
+class CompletedProcess:
+ def __init__(self, stdout: str = "") -> None:
+ self.stdout = stdout
+ self.returncode = 0
+
+ def check_returncode(self) -> None:
+ return None
+
+
+class CoverageInvocationTests(unittest.TestCase):
+ def project(self, root: Path) -> Path:
+ (root / "core").mkdir(parents=True)
+ (root / "core/__init__.py").write_text("", encoding="utf-8")
+ (root / "pyproject.toml").write_text("[project]\nname='fixture'\n", encoding="utf-8")
+ return root
+
+ def _recording_run(self, work: Path, commands: list[list[str]]):
+ def run(command, **kwargs):
+ commands.append([str(item) for item in command])
+ if "run" in command:
+ (work / ".coverage").write_bytes(b"data")
+ return CompletedProcess("100%\n")
+
+ return run
+
+ def test_the_gate_runs_through_the_current_interpreter_module(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ project = self.project(root / "project")
+ work, output = root / "work", root / "output"
+ work.mkdir()
+ output.mkdir()
+ commands: list[list[str]] = []
+ environment = {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)}
+
+ with mock.patch.dict(os.environ, environment, clear=False):
+ with mock.patch.object(subprocess, "run", side_effect=self._recording_run(work, commands)):
+ report = python_coverage.run(project)
+
+ self.assertEqual(report, output / "coverage.txt")
+ self.assertTrue(commands)
+ for command in commands:
+ self.assertEqual(command[:3], [sys.executable, "-m", "coverage"])
+
+ def test_the_legacy_cli_keeps_its_compatibility_argument(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ project = self.project(root / "project")
+ launcher = project / "coverage.exe"
+ launcher.write_bytes(b"legacy-launcher")
+ work, output = root / "work", root / "output"
+ work.mkdir()
+ output.mkdir()
+ commands: list[list[str]] = []
+ environment = {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)}
+
+ with mock.patch.dict(os.environ, environment, clear=False):
+ with mock.patch.object(subprocess, "run", side_effect=self._recording_run(work, commands)):
+ self.assertEqual(0, python_coverage.main((str(launcher), str(project))))
+
+ self.assertTrue(commands)
+ for command in commands:
+ self.assertEqual(command[:3], [sys.executable, "-m", "coverage"])
+ self.assertNotIn(str(launcher), command)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_quality_tools.py b/build/tests/test_quality_tools.py
new file mode 100644
index 0000000..04b6ef1
--- /dev/null
+++ b/build/tests/test_quality_tools.py
@@ -0,0 +1,484 @@
+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
+ FUZZER_LIBRARIES,
+ PROFILE_LIBRARIES,
+ _LLVM_TOOLSETS,
+ QualityTools,
+ ResolvedDirectory,
+ ResolvedTool,
+ SanitizerRuntimes,
+ discover_quality_tools,
+ resolve_binskim,
+ resolve_dumpbin,
+ resolve_llvm,
+ resolve_asan_runtimes,
+ resolve_fuzzer_runtime,
+ resolve_fuzzer_runtimes,
+ resolve_profile_runtime,
+ resolve_profile_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 profile_fixture(
+ self,
+ toolchain: FakeToolchain,
+ llvm_version: str = "19",
+ architectures: tuple[str, ...] = ("x86", "x64"),
+ ) -> dict[str, Path]:
+ toolsets = {"x86": "VC/Tools/Llvm", "x64": "VC/Tools/Llvm/x64"}
+ libraries = {}
+ for architecture in architectures:
+ directory = (
+ toolchain.installation
+ / toolsets[architecture]
+ / f"lib/clang/{llvm_version}/lib/windows"
+ )
+ directory.mkdir(parents=True, exist_ok=True)
+ library = directory / PROFILE_LIBRARIES[architecture]
+ library.write_bytes(f"profile-{architecture}".encode())
+ libraries[architecture] = library
+ return libraries
+
+ def fuzzer_fixture(
+ self,
+ toolchain: FakeToolchain,
+ llvm_version: str = "19",
+ architectures: tuple[str, ...] = ("x86", "x64"),
+ ) -> dict[str, tuple[Path, ...]]:
+ toolsets = {"x86": "VC/Tools/Llvm", "x64": "VC/Tools/Llvm/x64"}
+ libraries = {}
+ for architecture in architectures:
+ directory = (
+ toolchain.installation
+ / toolsets[architecture]
+ / f"lib/clang/{llvm_version}/lib/windows"
+ )
+ directory.mkdir(parents=True, exist_ok=True)
+ created = []
+ for name in FUZZER_LIBRARIES[architecture]:
+ library = directory / name
+ library.write_bytes(f"fuzzer-{architecture}-{name}".encode())
+ created.append(library)
+ libraries[architecture] = tuple(created)
+ return libraries
+
+ 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_profile_runtimes_come_from_the_toolset_matching_each_architecture(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, _binskim, _program_files = self.fixture(Path(temporary))
+ libraries = self.profile_fixture(toolchain)
+ with mock.patch.object(Path, "read_bytes", side_effect=AssertionError("must stream")):
+ resolved = dict(resolve_profile_runtimes(toolchain)) # type: ignore[arg-type]
+ single = resolve_profile_runtime(toolchain, "x86") # type: ignore[arg-type]
+
+ self.assertEqual(tuple(PROFILE_LIBRARIES), ("x86", "x64"))
+ self.assertEqual(
+ PROFILE_LIBRARIES,
+ {"x86": "clang_rt.profile-i386.lib", "x64": "clang_rt.profile-x86_64.lib"},
+ )
+ self.assertEqual(tuple(resolved), ("x86", "x64"))
+ self.assertEqual(single, resolved["x86"])
+ self.assertNotEqual(resolved["x86"].path, resolved["x64"].path)
+ self.assertEqual(resolved["x64"].path.parents[4].name, "x64")
+ self.assertEqual(resolved["x86"].path.parents[4].name, "Llvm")
+ for architecture, library in libraries.items():
+ directory = resolved[architecture]
+ self.assertIsInstance(directory, ResolvedDirectory)
+ self.assertEqual(directory.path, library.parent.resolve())
+ self.assertEqual(tuple(tool.path for tool in directory.files), (library,))
+ identity = dict(directory.identity)
+ self.assertEqual(identity["path"], str(library.parent.resolve()))
+ self.assertEqual(identity[f"{library.name}.path"], str(library))
+ self.assertEqual(
+ identity[f"{library.name}.sha256"],
+ hashlib.sha256(f"profile-{architecture}".encode()).hexdigest(),
+ )
+
+ def test_profile_runtime_failures_name_the_exact_missing_architecture_input(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, _binskim, _program_files = self.fixture(Path(temporary))
+ libraries = self.profile_fixture(toolchain)
+ with self.assertRaisesRegex(
+ ValueError, "coverage has no profile runtime for arm64"
+ ):
+ resolve_profile_runtime(toolchain, "arm64") # type: ignore[arg-type]
+ libraries["x86"].unlink()
+ with self.assertRaisesRegex(
+ FileNotFoundError, "missing profile runtime clang_rt.profile-i386.lib"
+ ):
+ resolve_profile_runtimes(toolchain) # type: ignore[arg-type]
+ for directory in tuple(libraries["x86"].parents)[:4]:
+ directory.rmdir()
+ with self.assertRaisesRegex(
+ FileNotFoundError, "missing x86 profile runtime directory"
+ ):
+ resolve_profile_runtime(toolchain, "x86") # type: ignore[arg-type]
+
+ def test_every_resolvable_architecture_owns_a_toolset_directory(self) -> None:
+ # Both resolvers reject an unsupported architecture against their library table and then
+ # index the toolset table unguarded. A key added to one table only would turn the
+ # fail-closed ValueError into a KeyError.
+ self.assertEqual(_LLVM_TOOLSETS.keys(), PROFILE_LIBRARIES.keys())
+ self.assertEqual(_LLVM_TOOLSETS.keys(), FUZZER_LIBRARIES.keys())
+
+ def test_fuzzer_runtimes_come_from_the_toolset_matching_each_architecture(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, _binskim, _program_files = self.fixture(Path(temporary))
+ libraries = self.fuzzer_fixture(toolchain)
+ with mock.patch.object(Path, "read_bytes", side_effect=AssertionError("must stream")):
+ resolved = dict(resolve_fuzzer_runtimes(toolchain)) # type: ignore[arg-type]
+ single = resolve_fuzzer_runtime(toolchain, "x86") # type: ignore[arg-type]
+
+ self.assertEqual(tuple(FUZZER_LIBRARIES), ("x86", "x64"))
+ self.assertEqual(
+ FUZZER_LIBRARIES,
+ {
+ "x86": (
+ "clang_rt.fuzzer-i386.lib",
+ "clang_rt.asan-i386.lib",
+ "clang_rt.asan_cxx-i386.lib",
+ ),
+ "x64": (
+ "clang_rt.fuzzer-x86_64.lib",
+ "clang_rt.asan-x86_64.lib",
+ "clang_rt.asan_cxx-x86_64.lib",
+ ),
+ },
+ )
+ self.assertEqual(tuple(resolved), ("x86", "x64"))
+ self.assertEqual(single, resolved["x86"])
+ self.assertNotEqual(resolved["x86"].path, resolved["x64"].path)
+ self.assertEqual(resolved["x64"].path.parents[4].name, "x64")
+ self.assertEqual(resolved["x86"].path.parents[4].name, "Llvm")
+ for architecture, created in libraries.items():
+ directory = resolved[architecture]
+ self.assertIsInstance(directory, ResolvedDirectory)
+ self.assertEqual(directory.path, created[0].parent.resolve())
+ self.assertEqual(tuple(tool.path for tool in directory.files), created)
+ identity = dict(directory.identity)
+ self.assertEqual(identity["path"], str(created[0].parent.resolve()))
+ for library in created:
+ self.assertEqual(identity[f"{library.name}.path"], str(library))
+ self.assertEqual(
+ identity[f"{library.name}.sha256"],
+ hashlib.sha256(
+ f"fuzzer-{architecture}-{library.name}".encode()
+ ).hexdigest(),
+ )
+
+ def test_fuzzer_runtime_failures_name_the_exact_missing_architecture_input(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, _binskim, _program_files = self.fixture(Path(temporary))
+ libraries = self.fuzzer_fixture(toolchain)
+ with self.assertRaisesRegex(
+ ValueError, "fuzzing has no libFuzzer runtime for arm64"
+ ):
+ resolve_fuzzer_runtime(toolchain, "arm64") # type: ignore[arg-type]
+ libraries["x86"][0].unlink()
+ with self.assertRaisesRegex(
+ FileNotFoundError, "missing libFuzzer runtime clang_rt.fuzzer-i386.lib"
+ ):
+ resolve_fuzzer_runtimes(toolchain) # type: ignore[arg-type]
+ for library in libraries["x86"][1:]:
+ library.unlink()
+ for directory in tuple(libraries["x86"][0].parents)[:4]:
+ directory.rmdir()
+ with self.assertRaisesRegex(
+ FileNotFoundError, "missing x86 libFuzzer runtime directory"
+ ):
+ resolve_fuzzer_runtime(toolchain, "x86") # type: ignore[arg-type]
+
+ def test_profile_runtime_prefers_the_full_llvm_version_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ toolchain, _binskim, _program_files = self.fixture(Path(temporary))
+ self.profile_fixture(toolchain)
+ libraries = self.profile_fixture(toolchain, "19.1.5")
+ resolved = dict(resolve_profile_runtimes(toolchain)) # type: ignore[arg-type]
+
+ self.assertEqual(resolved["x64"].path, libraries["x64"].parent.resolve())
+
+ 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_release.py b/build/tests/test_release.py
new file mode 100644
index 0000000..ec1965b
--- /dev/null
+++ b/build/tests/test_release.py
@@ -0,0 +1,1330 @@
+from __future__ import annotations
+
+import hashlib
+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))
+
+import core.release as release_module # noqa: E402
+from core.module_version import MODULES # noqa: E402
+from core.release import ( # noqa: E402
+ ARCHITECTURES,
+ ReleaseError,
+ evaluate,
+ expected_archives,
+ load_manifest,
+ main as release_main,
+ merge_manifests,
+ parse_tag,
+ recorded_previous_tag,
+ resolve_previous,
+ select_module,
+ stable_release_tags,
+ validate_tag,
+ verify_assets,
+)
+
+
+VERSIONS = {"renpy": "3.1.0", "rpgmaker": "1.1.0", "zanzarah": "2.1.0"}
+
+
+def identity(seed: str) -> str:
+ return hashlib.sha256(seed.encode()).hexdigest()
+
+
+def asset_bytes(name: str) -> bytes:
+ return f"{name} payload".encode("utf-8")
+
+
+def asset_sha(name: str) -> str:
+ return hashlib.sha256(asset_bytes(name)).hexdigest()
+
+
+def module_archive(module: str, version: str, architecture: str) -> str:
+ return f"{module}-{version}-{architecture}.zip"
+
+
+def symbol_archive(module: str, version: str, architecture: str) -> str:
+ return f"{module}-{version}-{architecture}-pdb.zip"
+
+
+def module_archives(module: str, version: str, architecture: str) -> tuple[str, str]:
+ return (
+ module_archive(module, version, architecture),
+ symbol_archive(module, version, architecture),
+ )
+
+
+def manifest(architecture: str = "x64", **overrides: tuple[str, str]) -> dict[str, object]:
+ modules = []
+ archives = []
+ for module in MODULES:
+ version, content = overrides.get(module, (VERSIONS[module], identity(module)))
+ modules.append({"content": content, "module": module, "version": version})
+ for name in module_archives(module, version, architecture):
+ archives.append({"name": name, "sha256": asset_sha(name)})
+ return {"archives": sorted(archives, key=lambda entry: entry["name"]), "modules": modules}
+
+
+def selected_manifest(module: str, version: str, architecture: str, content: str) -> dict[str, object]:
+ """A per-architecture manifest that already carries only one selected module."""
+
+ names = module_archives(module, version, architecture)
+ return {
+ "archives": sorted(
+ ({"name": name, "sha256": asset_sha(name)} for name in names),
+ key=lambda entry: entry["name"],
+ ),
+ "modules": [{"content": content, "module": module, "version": version}],
+ }
+
+
+def write(root: Path, name: str, document: object) -> Path:
+ path = root / name
+ path.write_text(json.dumps(document), encoding="utf-8")
+ return path
+
+
+def changelog_entry(module: str, version: str) -> str:
+ """A minimal but valid human ChangeLog whose top entry names ``version``."""
+
+ return f"Version {version}\n-------------\n * {module} {version} release.\n"
+
+
+def make_repository(root: Path, versions: dict[str, str] | None = None) -> Path:
+ for module, version in (versions or VERSIONS).items():
+ version_path = root / "src" / "modules" / module / "VERSION"
+ version_path.parent.mkdir(parents=True, exist_ok=True)
+ version_path.write_text(f"{version}\n", encoding="utf-8")
+ (version_path.parent / "ChangeLog").write_text(
+ changelog_entry(module, version), encoding="utf-8"
+ )
+ return root
+
+
+def write_assets(directory: Path, names: list[str]) -> None:
+ directory.mkdir(parents=True, exist_ok=True)
+ for name in names:
+ (directory / name).write_bytes(asset_bytes(name))
+
+
+def release_record(tag: str, *, draft: bool = False, prerelease: bool = False) -> str:
+ """One line of the ``gh api`` JSON that the workflow feeds to the ``previous`` command."""
+
+ return json.dumps({"tag": tag, "draft": draft, "prerelease": prerelease})
+
+
+def release_report(module: str, version: str, *, previous: object) -> dict[str, object]:
+ """One module release report, as the immutable ``release.json`` of a publication."""
+
+ return {
+ "bootstrap": previous is None,
+ "modules": [
+ {
+ "content": identity(module),
+ "module": module,
+ "previous_version": previous,
+ "status": "bootstrap" if previous is None else "released",
+ "version": version,
+ }
+ ],
+ "note": "",
+ "title": f"{module} {version}",
+ }
+
+
+class MergeTests(unittest.TestCase):
+ def test_architecture_manifests_union_archives_and_agree_on_modules(self) -> None:
+ merged = merge_manifests((manifest("x64"), manifest("arm64"), manifest("x86")))
+
+ self.assertEqual(merged["modules"], manifest()["modules"])
+ self.assertEqual(len(merged["archives"]), 18)
+ self.assertEqual(
+ [entry["name"] for entry in merged["archives"]],
+ sorted(entry["name"] for entry in merged["archives"]),
+ )
+
+ def test_disagreeing_or_colliding_release_manifests_are_rejected(self) -> None:
+ with self.assertRaisesRegex(ReleaseError, "disagree about renpy"):
+ merge_manifests((manifest("x64"), manifest("arm64", renpy=("3.2.0", identity("renpy")))))
+ with self.assertRaisesRegex(ReleaseError, "duplicate release archive"):
+ merge_manifests((manifest("x64"), manifest("x64")))
+ with self.assertRaisesRegex(ReleaseError, "at least one release manifest"):
+ merge_manifests(())
+
+ def test_merging_selected_only_and_all_module_manifests_is_rejected(self) -> None:
+ with self.assertRaisesRegex(ReleaseError, "describe different module sets"):
+ merge_manifests(
+ (selected_manifest("renpy", "3.1.0", "x64", identity("renpy")), manifest("x64")),
+ expected=None,
+ )
+
+
+class ManifestValidationTests(unittest.TestCase):
+ def test_only_a_complete_well_formed_manifest_is_accepted(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ self.assertEqual(load_manifest(write(root, "good.json", manifest())), manifest())
+
+ partial = manifest()
+ partial["modules"] = partial["modules"][:2]
+ invalid = [
+ ([], "must be a JSON object"),
+ ({"modules": manifest()["modules"]}, "must be a JSON object"),
+ (partial, "expected module set"),
+ (
+ {
+ **manifest(),
+ "modules": [
+ {"content": identity("x"), "module": "renpy", "version": "1.0.0"}
+ ] * 3,
+ },
+ "expected module set",
+ ),
+ (
+ {
+ **manifest(),
+ "modules": [
+ *manifest()["modules"],
+ {"content": identity("x"), "module": "pickle", "version": "1.0.0"},
+ ],
+ },
+ "expected module set",
+ ),
+ ({**manifest(), "modules": ["renpy"]}, "module record must be an object"),
+ ({**manifest(), "modules": "renpy"}, "must be a JSON object"),
+ (
+ manifest(renpy=("3.1.0", "not-a-digest")),
+ "module content identity",
+ ),
+ (manifest(renpy=("3.1", identity("renpy"))), "must be X.Y.Z"),
+ ({**manifest(), "archives": [{"name": "a.zip"}]}, "archive record"),
+ (
+ {**manifest(), "archives": [{"name": "../escape.zip", "sha256": identity("a")}]},
+ "plain zip file name",
+ ),
+ (
+ {**manifest(), "archives": [{"name": "sub/dir.zip", "sha256": identity("a")}]},
+ "plain zip file name",
+ ),
+ (
+ {
+ **manifest(),
+ "archives": [
+ {"name": "a.zip", "sha256": identity("a")},
+ {"name": "a.zip", "sha256": identity("a")},
+ ],
+ },
+ "duplicate release archive",
+ ),
+ ]
+ for index, (document, message) in enumerate(invalid):
+ with self.subTest(index=index), self.assertRaisesRegex(ReleaseError, message):
+ load_manifest(write(root, f"bad-{index}.json", document))
+
+ broken = root / "broken.json"
+ broken.write_text("{not json", encoding="utf-8")
+ with self.assertRaisesRegex(ReleaseError, "unreadable release manifest"):
+ load_manifest(broken)
+ with self.assertRaisesRegex(ReleaseError, "unreadable release manifest"):
+ load_manifest(root / "absent.json")
+
+
+class GateTests(unittest.TestCase):
+ def statuses(self, current: dict[str, object], previous: dict[str, object] | None) -> dict[str, str]:
+ report = evaluate(current, previous)
+ return {record["module"]: record["status"] for record in report["modules"]}
+
+ def test_a_first_release_without_a_previous_manifest_is_recorded_as_bootstrap(self) -> None:
+ report = evaluate(manifest(), None)
+
+ self.assertTrue(report["bootstrap"])
+ self.assertEqual(
+ self.statuses(manifest(), None), {module: "bootstrap" for module in MODULES}
+ )
+ self.assertEqual(report["title"], "renpy 3.1.0, rpgmaker 1.1.0, zanzarah 2.1.0")
+ self.assertEqual([record["previous_version"] for record in report["modules"]], [None] * 3)
+ self.assertIn("no previous release manifest", report["note"])
+
+ def test_unchanged_and_correctly_bumped_modules_pass_the_gate(self) -> None:
+ previous = manifest()
+ current = manifest(renpy=("3.2.0", identity("renpy-next")))
+
+ report = evaluate(current, previous)
+
+ self.assertFalse(report["bootstrap"])
+ self.assertEqual(
+ {record["module"]: record["status"] for record in report["modules"]},
+ {"renpy": "released", "rpgmaker": "unchanged", "zanzarah": "unchanged"},
+ )
+ self.assertEqual(report["modules"][0]["previous_version"], "3.1.0")
+ self.assertEqual(report["title"], "renpy 3.2.0, rpgmaker 1.1.0, zanzarah 2.1.0")
+ self.assertEqual(report["note"], "")
+
+ def test_a_previous_release_may_outlive_a_retired_module(self) -> None:
+ previous = manifest()
+ previous["modules"] = [
+ *previous["modules"],
+ {"content": identity("pickle"), "module": "pickle", "version": "1.0.0"},
+ ]
+
+ # Published history is immutable, so a retired module in it must never block a release.
+ self.assertEqual(
+ self.statuses(manifest(), previous),
+ {module: "unchanged" for module in MODULES},
+ )
+ with self.assertRaisesRegex(ReleaseError, "expected module set"):
+ evaluate(previous, None)
+
+ def test_a_module_absent_from_the_previous_release_is_new(self) -> None:
+ previous = manifest()
+ previous["modules"] = [
+ record for record in previous["modules"] if record["module"] != "zanzarah"
+ ]
+
+ self.assertEqual(
+ self.statuses(manifest(), previous),
+ {"renpy": "unchanged", "rpgmaker": "unchanged", "zanzarah": "new"},
+ )
+
+ def test_changed_content_without_a_version_bump_fails_the_release(self) -> None:
+ previous = manifest()
+ current = manifest(rpgmaker=("1.1.0", identity("rpgmaker-next")))
+
+ with self.assertRaisesRegex(ReleaseError, "rpgmaker content changed without a version bump"):
+ evaluate(current, previous)
+
+ def test_an_unchanged_module_may_not_be_republished_under_a_new_version(self) -> None:
+ previous = manifest()
+ current = manifest(zanzarah=("2.2.0", identity("zanzarah")))
+
+ with self.assertRaisesRegex(ReleaseError, "zanzarah version bumped without a content change"):
+ evaluate(current, previous)
+
+ def test_a_version_may_never_move_backwards(self) -> None:
+ previous = manifest()
+ current = manifest(renpy=("3.0.9", identity("renpy-next")))
+
+ with self.assertRaisesRegex(ReleaseError, "renpy version 3.0.9 is older than 3.1.0"):
+ evaluate(current, previous)
+
+
+class ReleaseCommandTests(unittest.TestCase):
+ def run_gate(self, root: Path, name: str, *arguments: str) -> Path:
+ output = root / name
+ output.mkdir()
+ self.assertEqual(0, release_main(("gate", "--output", str(output), *arguments)))
+ return output
+
+ def test_gate_publishes_the_merged_manifest_and_its_decisions(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ current = [
+ str(write(root, f"packages-{architecture}.json", manifest(architecture)))
+ for architecture in ("x64", "arm64")
+ ]
+ previous = str(write(root, "previous.json", manifest("x64")))
+
+ bootstrap = self.run_gate(root, "bootstrap", *current)
+ self.assertTrue(json.loads((bootstrap / "release.json").read_text())["bootstrap"])
+ merged = json.loads((bootstrap / "packages.json").read_text(encoding="utf-8"))
+ self.assertEqual(len(merged["archives"]), 12)
+ self.assertEqual(merged["modules"], manifest()["modules"])
+ notes = (bootstrap / "notes.md").read_text(encoding="utf-8")
+ self.assertIn("| renpy | 3.1.0 | bootstrap |", notes)
+ self.assertIn("no previous release manifest", notes)
+
+ compared = self.run_gate(root, "compared", "--previous", previous, *current)
+ report = json.loads((compared / "release.json").read_text(encoding="utf-8"))
+ self.assertFalse(report["bootstrap"])
+ self.assertEqual(
+ {record["status"] for record in report["modules"]}, {"unchanged"}
+ )
+ notes = (compared / "notes.md").read_text(encoding="utf-8")
+ self.assertIn("| zanzarah | 2.1.0 | unchanged |", notes)
+ self.assertNotIn("no previous release manifest", notes)
+
+ regressed = str(write(root, "regressed.json", manifest("x64", renpy=("3.9.0", identity("old")))))
+ output = root / "failed"
+ output.mkdir()
+ with self.assertRaisesRegex(ReleaseError, "older than"):
+ release_main(("gate", "--output", str(output), "--previous", regressed, *current))
+ self.assertFalse((output / "release.json").exists())
+
+ with self.assertRaisesRegex(ReleaseError, "output directory"):
+ release_main(("gate", "--output", str(root / "absent"), *current))
+
+ def test_gate_command_writes_the_merged_release_manifest(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ current = str(write(root, "packages-x64.json", manifest("x64")))
+ output = root / "entrypoint"
+ output.mkdir()
+ self.assertEqual(0, release_main(("gate", "--output", str(output), current)))
+ self.assertTrue((output / "release.json").is_file())
+
+ with self.assertRaises(SystemExit):
+ release_main(())
+
+
+class TagTests(unittest.TestCase):
+ def test_canonical_module_tags_map_to_their_declared_version(self) -> None:
+ for module, version in VERSIONS.items():
+ with self.subTest(module=module), tempfile.TemporaryDirectory() as temporary:
+ repository = make_repository(Path(temporary))
+ self.assertEqual(parse_tag(f"{module}/v{version}"), (module, version))
+ self.assertEqual(validate_tag(repository, f"{module}/v{version}"), (module, version))
+
+ def test_unknown_malformed_and_mismatched_tags_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = make_repository(Path(temporary))
+ invalid = [
+ ("renpy", "release tag must be"),
+ ("renpy/v3.1", "version must be X.Y.Z"),
+ ("renpy/v3.1.0/v2", "version must be X.Y.Z"),
+ ("unknown/v1.0.0", "unknown release module"),
+ ]
+ for tag, message in invalid:
+ with self.subTest(tag=tag), self.assertRaisesRegex(ReleaseError, message):
+ parse_tag(tag)
+ with self.assertRaisesRegex(ReleaseError, "does not match"):
+ validate_tag(repository, "renpy/v9.9.9")
+
+ def test_a_missing_version_file_fails_validation(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ with self.assertRaisesRegex(ReleaseError, "unreadable"):
+ validate_tag(Path(temporary), "renpy/v3.1.0")
+
+ def test_a_missing_or_mismatched_change_log_fails_validation(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = make_repository(Path(temporary))
+ changelog = repository / "src/modules/renpy/ChangeLog"
+ changelog.unlink()
+ with self.assertRaisesRegex(ReleaseError, "ChangeLog"):
+ validate_tag(repository, "renpy/v3.1.0")
+
+ changelog.write_text(
+ changelog_entry("renpy", "3.0.0"), encoding="utf-8"
+ )
+ with self.assertRaisesRegex(ReleaseError, "ChangeLog"):
+ validate_tag(repository, "renpy/v3.1.0")
+
+ def test_tag_command_writes_the_selected_module_and_version(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = make_repository(root)
+ output = root / "github-output.txt"
+ output.write_text("", encoding="utf-8")
+
+ self.assertEqual(
+ 0,
+ release_main(
+ (
+ "tag",
+ "--repository",
+ str(repository),
+ "--tag",
+ "renpy/v3.1.0",
+ "--github-output",
+ str(output),
+ )
+ ),
+ )
+ self.assertEqual(output.read_text(encoding="utf-8"), "module=renpy\nversion=3.1.0\n")
+
+ self.assertEqual(
+ 0,
+ release_main(("tag", "--repository", str(repository), "--tag", "rpgmaker/v1.1.0")),
+ )
+
+
+class PreviousReleaseTests(unittest.TestCase):
+ def test_only_published_stable_release_records_feed_the_lineage(self) -> None:
+ lines = [
+ release_record("renpy/v3.2.0"),
+ release_record("renpy/v3.3.0", draft=True),
+ release_record("renpy/v3.4.0", prerelease=True),
+ release_record("renpy/v3.5.0", draft=True, prerelease=True),
+ "",
+ " ",
+ "not json",
+ '["renpy/v9.9.9"]',
+ '{"draft": false, "prerelease": false}',
+ '{"tag": 123, "draft": false, "prerelease": false}',
+ release_record("renpy/v3.3.1"),
+ ]
+
+ self.assertEqual(stable_release_tags(lines), ["renpy/v3.2.0", "renpy/v3.3.1"])
+
+ def test_a_new_release_compares_against_the_latest_published_head(self) -> None:
+ # A brand-new tag older than the published head resolves that head, so the version gate
+ # rejects the backwards release instead of silently bootstrapping it.
+ self.assertEqual(
+ resolve_previous("renpy/v3.1.0", ["renpy/v3.2.0", "renpy/v3.0.0"]),
+ "renpy/v3.2.0",
+ )
+
+ def test_a_new_release_with_only_lower_history_resolves_its_predecessor(self) -> None:
+ self.assertEqual(resolve_previous("renpy/v3.1.0", ["renpy/v3.0.0"]), "renpy/v3.0.0")
+
+ def test_no_published_same_module_release_is_a_genuine_bootstrap(self) -> None:
+ for releases in ([], ["rpgmaker/v1.0.0"], ["renpy/v3.1.0"]):
+ with self.subTest(releases=releases):
+ self.assertIsNone(resolve_previous("renpy/v3.1.0", releases))
+
+ def test_unrelated_and_unparseable_release_tags_are_ignored(self) -> None:
+ releases = ["nightly", "renpy/3.0.0", "renpy/v3.0.0"]
+
+ self.assertEqual(resolve_previous("renpy/v3.1.0", releases), "renpy/v3.0.0")
+
+ def test_a_new_publication_command_writes_the_resolved_plain_tag(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ releases = root / "releases.jsonl"
+ releases.write_text(
+ "\n".join(
+ [
+ release_record("renpy/v3.0.9", draft=True),
+ release_record("renpy/v3.0.8", prerelease=True),
+ release_record("renpy/v3.0.0"),
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ output = root / "previous.txt"
+
+ self.assertEqual(
+ 0,
+ release_main(
+ (
+ "previous",
+ "--tag",
+ "renpy/v3.1.0",
+ "--releases",
+ str(releases),
+ "--output",
+ str(output),
+ )
+ ),
+ )
+
+ # A new publication compares against the latest published stable predecessor; the
+ # draft and prerelease entries are never candidates and the bare tag is written.
+ self.assertEqual(output.read_text(encoding="utf-8"), "renpy/v3.0.0\n")
+
+ def test_a_new_publication_command_writes_an_empty_file_for_a_first_release(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ releases = root / "releases.jsonl"
+ releases.write_text("\n", encoding="utf-8")
+ output = root / "previous.txt"
+
+ self.assertEqual(
+ 0,
+ release_main(
+ (
+ "previous",
+ "--tag",
+ "renpy/v3.1.0",
+ "--releases",
+ str(releases),
+ "--output",
+ str(output),
+ )
+ ),
+ )
+
+ self.assertEqual(output.read_text(encoding="utf-8"), "\n")
+
+ def test_a_rerun_command_uses_the_recorded_predecessor_ignoring_history(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ report = write(
+ root, "release.json", release_report("renpy", "3.1.0", previous="3.0.5")
+ )
+ # History moved on afterwards; a rerun must never reconstruct from it but reuse the
+ # predecessor the original publication recorded in its immutable report.
+ releases = root / "releases.jsonl"
+ releases.write_text(
+ "\n".join(
+ [
+ release_record("renpy/v3.2.0"),
+ release_record("renpy/v3.1.0"),
+ release_record("renpy/v3.0.0"),
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ output = root / "previous.txt"
+
+ self.assertEqual(
+ 0,
+ release_main(
+ (
+ "previous",
+ "--tag",
+ "renpy/v3.1.0",
+ "--current-exists",
+ "--current-report",
+ str(report),
+ "--releases",
+ str(releases),
+ "--output",
+ str(output),
+ )
+ ),
+ )
+
+ self.assertEqual(output.read_text(encoding="utf-8"), "renpy/v3.0.5\n")
+
+ def test_a_bootstrap_rerun_ignores_a_later_inserted_history(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ report = write(
+ root, "release.json", release_report("renpy", "3.1.0", previous=None)
+ )
+ releases = root / "releases.jsonl"
+ releases.write_text(release_record("renpy/v3.0.5") + "\n", encoding="utf-8")
+ output = root / "previous.txt"
+
+ # 3.1.0 bootstrapped against no history; an older release published later must not
+ # turn the rerun into a comparison against that release.
+ self.assertEqual(
+ 0,
+ release_main(
+ (
+ "previous",
+ "--tag",
+ "renpy/v3.1.0",
+ "--current-exists",
+ "--current-report",
+ str(report),
+ "--releases",
+ str(releases),
+ "--output",
+ str(output),
+ )
+ ),
+ )
+
+ self.assertEqual(output.read_text(encoding="utf-8"), "\n")
+
+ def test_the_previous_command_requires_a_recorded_report_for_a_rerun(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ output = Path(temporary) / "previous.txt"
+ with self.assertRaisesRegex(ReleaseError, "requires --current-report"):
+ release_main(
+ (
+ "previous",
+ "--tag",
+ "renpy/v3.1.0",
+ "--current-exists",
+ "--output",
+ str(output),
+ )
+ )
+
+ def test_the_previous_command_requires_releases_for_a_new_publication(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ output = Path(temporary) / "previous.txt"
+ with self.assertRaisesRegex(ReleaseError, "requires --releases"):
+ release_main(("previous", "--tag", "renpy/v3.1.0", "--output", str(output)))
+
+ def test_the_previous_command_rejects_a_missing_or_malformed_report(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ broken = root / "broken.json"
+ broken.write_text("{not json", encoding="utf-8")
+ output = root / "previous.txt"
+ for report in (root / "absent.json", broken):
+ with self.subTest(report=report.name):
+ with self.assertRaisesRegex(
+ ReleaseError, "unreadable current release report"
+ ):
+ release_main(
+ (
+ "previous",
+ "--tag",
+ "renpy/v3.1.0",
+ "--current-exists",
+ "--current-report",
+ str(report),
+ "--output",
+ str(output),
+ )
+ )
+
+ def test_the_previous_command_rejects_a_report_without_the_current_flag(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ report = write(
+ root, "release.json", release_report("renpy", "3.1.0", previous="3.0.0")
+ )
+ output = root / "previous.txt"
+ with self.assertRaisesRegex(
+ ReleaseError, "--current-report requires --current-exists"
+ ):
+ release_main(
+ (
+ "previous",
+ "--tag",
+ "renpy/v3.1.0",
+ "--current-report",
+ str(report),
+ "--output",
+ str(output),
+ )
+ )
+
+
+class RecordedPreviousReportTests(unittest.TestCase):
+ def test_a_bootstrap_report_records_no_predecessor(self) -> None:
+ self.assertIsNone(
+ recorded_previous_tag("renpy/v3.1.0", release_report("renpy", "3.1.0", previous=None))
+ )
+
+ def test_a_report_records_its_older_predecessor_tag(self) -> None:
+ self.assertEqual(
+ recorded_previous_tag(
+ "renpy/v3.1.0", release_report("renpy", "3.1.0", previous="3.0.0")
+ ),
+ "renpy/v3.0.0",
+ )
+
+ def test_a_report_that_is_not_an_object_is_rejected(self) -> None:
+ for report in ([], "renpy", 7, None):
+ with self.subTest(report=report), self.assertRaisesRegex(
+ ReleaseError, "must be a JSON object"
+ ):
+ recorded_previous_tag("renpy/v3.1.0", report)
+
+ def test_a_report_without_a_module_list_is_rejected(self) -> None:
+ with self.assertRaisesRegex(ReleaseError, "must describe its modules"):
+ recorded_previous_tag("renpy/v3.1.0", {"modules": "renpy"})
+
+ def test_a_report_must_describe_exactly_the_selected_module(self) -> None:
+ duplicated = release_report("renpy", "3.1.0", previous=None)
+ duplicated["modules"] = duplicated["modules"] * 2
+ empty = release_report("renpy", "3.1.0", previous=None)
+ empty["modules"] = []
+ for report in (
+ empty,
+ release_report("rpgmaker", "1.1.0", previous=None),
+ duplicated,
+ ):
+ with self.subTest(report=report), self.assertRaisesRegex(
+ ReleaseError, "exactly one 'renpy' module"
+ ):
+ recorded_previous_tag("renpy/v3.1.0", report)
+
+ def test_a_report_version_must_match_the_tag(self) -> None:
+ with self.assertRaisesRegex(ReleaseError, "version does not match the tag"):
+ recorded_previous_tag(
+ "renpy/v3.1.0", release_report("renpy", "3.2.0", previous="3.1.0")
+ )
+
+ def test_a_report_without_a_previous_version_is_rejected(self) -> None:
+ report = release_report("renpy", "3.1.0", previous=None)
+ del report["modules"][0]["previous_version"]
+ with self.assertRaisesRegex(ReleaseError, "records no previous_version"):
+ recorded_previous_tag("renpy/v3.1.0", report)
+
+ def test_an_invalid_previous_version_is_rejected(self) -> None:
+ for previous in ("next", 3):
+ with self.subTest(previous=previous), self.assertRaisesRegex(
+ ReleaseError, "invalid previous_version"
+ ):
+ recorded_previous_tag(
+ "renpy/v3.1.0", release_report("renpy", "3.1.0", previous=previous)
+ )
+
+ def test_a_previous_version_that_is_not_older_is_rejected(self) -> None:
+ for previous in ("3.1.0", "3.2.0"):
+ with self.subTest(previous=previous), self.assertRaisesRegex(
+ ReleaseError, "is not older than"
+ ):
+ recorded_previous_tag(
+ "renpy/v3.1.0", release_report("renpy", "3.1.0", previous=previous)
+ )
+
+
+class ImplementationPathTests(unittest.TestCase):
+ def test_the_release_module_is_loaded_from_the_tested_build_tree(self) -> None:
+ # Guard: the suite must exercise the release implementation that ships beside it, never a
+ # stale copy kept in another staging tree.
+ self.assertEqual(
+ Path(release_module.__file__).resolve(),
+ (BUILD_ROOT / "core" / "release.py").resolve(),
+ )
+
+
+class SelectModuleTests(unittest.TestCase):
+ def test_selecting_a_module_keeps_only_its_records_and_archives(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ document = load_manifest(write(Path(temporary), "packages.json", manifest()))
+
+ selected = select_module(document, "renpy")
+
+ self.assertEqual([record["module"] for record in selected["modules"]], ["renpy"])
+ self.assertEqual(
+ [entry["name"] for entry in selected["archives"]],
+ sorted(module_archives("renpy", "3.1.0", "x64")),
+ )
+
+ def test_selecting_a_module_absent_from_the_manifest_fails(self) -> None:
+ document = {"archives": [], "modules": [{"content": identity("x"), "module": "rpgmaker", "version": "1.1.0"}]}
+
+ with self.assertRaisesRegex(ReleaseError, "has no 'renpy' record"):
+ select_module(document, "renpy")
+
+
+class VerifyAssetsTests(unittest.TestCase):
+ def test_assets_directory_must_exist(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ with self.assertRaisesRegex(ReleaseError, "assets directory must exist"):
+ verify_assets(Path(temporary) / "absent", [])
+
+ def test_assets_must_match_the_manifest_bytes_exactly(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ names = list(module_archives("renpy", "3.1.0", "x86"))
+ archives = [{"name": name, "sha256": asset_sha(name)} for name in names]
+ assets = root / "assets"
+ write_assets(assets, names)
+
+ self.assertIsNone(verify_assets(assets, archives))
+
+ (assets / "renpy-3.1.0-arm64.zip").write_bytes(asset_bytes("renpy-3.1.0-arm64.zip"))
+ with self.assertRaisesRegex(ReleaseError, "do not match the module archives"):
+ verify_assets(assets, archives)
+ (assets / "renpy-3.1.0-arm64.zip").unlink()
+
+ (assets / names[0]).unlink()
+ with self.assertRaisesRegex(ReleaseError, "do not match the module archives"):
+ verify_assets(assets, archives)
+ (assets / names[0]).write_bytes(b"tampered")
+ with self.assertRaisesRegex(ReleaseError, "digest mismatch"):
+ verify_assets(assets, archives)
+
+
+class ModuleReleaseTests(unittest.TestCase):
+ def manifest_paths(self, root: Path, *architectures: str, **overrides: tuple[str, str]) -> list[str]:
+ return [
+ str(write(root, f"packages-{architecture}.json", manifest(architecture, **overrides)))
+ for architecture in architectures
+ ]
+
+ def run_gate(self, root: Path, name: str, *arguments: str) -> Path:
+ output = root / name
+ output.mkdir()
+ self.assertEqual(0, release_main(("gate", "--output", str(output), *arguments)))
+ return output
+
+ def test_expected_archives_cover_every_architecture(self) -> None:
+ expected = expected_archives("renpy", "3.1.0")
+
+ self.assertEqual(len(expected), 2 * len(ARCHITECTURES))
+ self.assertIn(module_archive("renpy", "3.1.0", "arm64"), expected)
+ self.assertIn(symbol_archive("renpy", "3.1.0", "x86"), expected)
+
+ def test_module_gate_selects_one_module_and_verifies_its_assets(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ manifests = self.manifest_paths(root, *ARCHITECTURES)
+ assets = root / "assets"
+ names = [name for name in expected_archives("renpy", "3.1.0")]
+ write_assets(assets, names)
+
+ output = self.run_gate(
+ root,
+ "gate",
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ *manifests,
+ )
+
+ packages = json.loads((output / "packages.json").read_text(encoding="utf-8"))
+ self.assertEqual([record["module"] for record in packages["modules"]], ["renpy"])
+ self.assertEqual(
+ [entry["name"] for entry in packages["archives"]], sorted(names)
+ )
+ report = json.loads((output / "release.json").read_text(encoding="utf-8"))
+ self.assertTrue(report["bootstrap"])
+ notes = (output / "notes.md").read_text(encoding="utf-8")
+ self.assertIn("| renpy | 3.1.0 | bootstrap |", notes)
+ # The published body carries the module's latest human ChangeLog bullets.
+ self.assertIn("- renpy 3.1.0 release.", notes)
+
+ def test_module_gate_fails_closed_when_the_change_log_disagrees(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ (root / "src/modules/renpy/ChangeLog").write_text(
+ changelog_entry("renpy", "3.0.0"), encoding="utf-8"
+ )
+ manifests = self.manifest_paths(root, *ARCHITECTURES)
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ output = root / "gate"
+ output.mkdir()
+
+ with self.assertRaisesRegex(ReleaseError, "ChangeLog"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ *manifests,
+ )
+ )
+ self.assertFalse((output / "release.json").exists())
+
+ def test_module_gate_follows_only_the_selected_module_lineage(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary), {**VERSIONS, "renpy": "3.2.0"})
+ previous = str(write(root, "previous.json", manifest("x64")))
+ manifests = self.manifest_paths(root, *ARCHITECTURES, renpy=("3.2.0", identity("renpy-next")))
+ assets = root / "assets"
+ names = list(expected_archives("renpy", "3.2.0"))
+ write_assets(assets, names)
+
+ output = self.run_gate(
+ root,
+ "gate",
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.2.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ "--previous",
+ previous,
+ *manifests,
+ )
+
+ report = json.loads((output / "release.json").read_text(encoding="utf-8"))
+ self.assertFalse(report["bootstrap"])
+ self.assertEqual(
+ report["modules"],
+ [
+ {
+ "content": identity("renpy-next"),
+ "module": "renpy",
+ "previous_version": "3.1.0",
+ "status": "released",
+ "version": "3.2.0",
+ }
+ ],
+ )
+
+ def test_module_gate_rejects_a_content_change_without_a_bump(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ previous = str(write(root, "previous.json", manifest("x64")))
+ manifests = self.manifest_paths(root, *ARCHITECTURES, renpy=("3.1.0", identity("renpy-next")))
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ output = root / "gate"
+ output.mkdir()
+
+ with self.assertRaisesRegex(ReleaseError, "content changed without a version bump"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ "--previous",
+ previous,
+ *manifests,
+ )
+ )
+
+ def test_module_gate_rejects_a_new_release_behind_the_published_head(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ # The published head of the lineage is already newer than the tag being released, so
+ # the gate must reject it instead of bootstrapping a backwards release.
+ previous = str(
+ write(root, "previous.json", manifest("x64", renpy=("3.2.0", identity("renpy-head"))))
+ )
+ manifests = self.manifest_paths(root, *ARCHITECTURES)
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ output = root / "gate"
+ output.mkdir()
+
+ with self.assertRaisesRegex(ReleaseError, "older than 3.2.0"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ "--previous",
+ previous,
+ *manifests,
+ )
+ )
+
+ def test_module_gate_rerun_uses_the_stable_lower_predecessor(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ previous = str(
+ write(
+ root,
+ "previous.json",
+ selected_manifest("renpy", "3.0.9", "x64", identity("renpy-old")),
+ )
+ )
+ manifests = self.manifest_paths(root, *ARCHITECTURES, renpy=("3.1.0", identity("renpy-next")))
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+
+ output = self.run_module_gate(
+ root,
+ "gate",
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ "--previous",
+ previous,
+ *manifests,
+ )
+
+ report = json.loads((output / "release.json").read_text(encoding="utf-8"))
+ self.assertFalse(report["bootstrap"])
+ self.assertEqual(report["modules"][0]["previous_version"], "3.0.9")
+
+ def test_module_gate_requires_the_selector_flags(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ manifests = self.manifest_paths(root, "x64")
+ output = root / "gate"
+ output.mkdir()
+
+ with self.assertRaisesRegex(ReleaseError, "requires --tag, --repository, and --assets"):
+ release_main(("gate", "--output", str(output), "--module", "renpy", *manifests))
+
+ def test_module_gate_rejects_a_tag_that_selects_another_module(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ manifests = self.manifest_paths(root, "x64")
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ output = root / "gate"
+ output.mkdir()
+
+ with self.assertRaisesRegex(ReleaseError, "selects 'rpgmaker', not 'renpy'"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "rpgmaker/v1.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ *manifests,
+ )
+ )
+
+ def test_module_gate_rejects_a_tag_that_disagrees_with_the_package_version(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ manifests = self.manifest_paths(root, *ARCHITECTURES, renpy=("3.2.0", identity("renpy-next")))
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ output = root / "gate"
+ output.mkdir()
+
+ with self.assertRaisesRegex(ReleaseError, "does not match package version"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ *manifests,
+ )
+ )
+
+ def test_module_gate_requires_exactly_the_selected_archives(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ manifests = self.manifest_paths(root, *ARCHITECTURES)
+ trimmed = load_manifest(Path(manifests[0]))
+ trimmed["archives"] = [
+ entry for entry in trimmed["archives"] if entry["name"] != symbol_archive("renpy", "3.1.0", "x86")
+ ]
+ manifests[0] = str(write(root, "packages-x86.json", trimmed))
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ output = root / "gate"
+ output.mkdir()
+
+ with self.assertRaisesRegex(ReleaseError, "must be exactly its module and symbol packages"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ *manifests,
+ )
+ )
+
+ def test_module_gate_forbids_unselected_module_assets(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ manifests = self.manifest_paths(root, *ARCHITECTURES)
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ write_assets(assets, [module_archive("rpgmaker", "1.1.0", "x64")])
+ output = root / "gate"
+ output.mkdir()
+
+ with self.assertRaisesRegex(ReleaseError, "do not match the module archives"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ *manifests,
+ )
+ )
+
+ def selected_paths(self, root: Path, *, version: str = "3.1.0", content: str | None = None) -> list[str]:
+ return [
+ str(
+ write(
+ root,
+ f"packages-{architecture}.json",
+ selected_manifest("renpy", version, architecture, content or identity("renpy")),
+ )
+ )
+ for architecture in ARCHITECTURES
+ ]
+
+ def run_module_gate(self, root: Path, name: str, *arguments: str) -> Path:
+ output = root / name
+ output.mkdir()
+ self.assertEqual(0, release_main(("gate", "--output", str(output), *arguments)))
+ return output
+
+ def test_module_gate_accepts_selected_only_architecture_manifests(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ assets = root / "assets"
+ names = list(expected_archives("renpy", "3.1.0"))
+ write_assets(assets, names)
+
+ output = self.run_module_gate(
+ root,
+ "gate",
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ *self.selected_paths(root),
+ )
+
+ packages = json.loads((output / "packages.json").read_text(encoding="utf-8"))
+ self.assertEqual([record["module"] for record in packages["modules"]], ["renpy"])
+ self.assertEqual([entry["name"] for entry in packages["archives"]], sorted(names))
+
+ def test_module_gate_requires_all_three_architectures(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ output = root / "gate"
+ output.mkdir()
+ manifests = [
+ str(write(root, f"packages-{arch}.json", selected_manifest("renpy", "3.1.0", arch, identity("renpy"))))
+ for arch in ("x86", "x64")
+ ]
+
+ with self.assertRaisesRegex(ReleaseError, "must be exactly its module and symbol packages"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ *manifests,
+ )
+ )
+
+ def test_module_gate_rejects_mixed_selected_and_all_module_manifests(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ output = root / "gate"
+ output.mkdir()
+ mixed = [
+ str(write(root, "packages-x64.json", manifest("x64"))),
+ str(write(root, "packages-x86.json", selected_manifest("renpy", "3.1.0", "x86", identity("renpy")))),
+ ]
+
+ with self.assertRaisesRegex(ReleaseError, "describe different module sets"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ *mixed,
+ )
+ )
+
+ def test_module_gate_rejects_a_previous_manifest_without_the_selected_module(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary))
+ previous = str(
+ write(root, "previous.json", selected_manifest("rpgmaker", "1.1.0", "x64", identity("rpgmaker")))
+ )
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.1.0")))
+ output = root / "gate"
+ output.mkdir()
+
+ with self.assertRaisesRegex(ReleaseError, "previous release manifest does not describe 'renpy'"):
+ release_main(
+ (
+ "gate",
+ "--output",
+ str(output),
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.1.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ "--previous",
+ previous,
+ *self.manifest_paths(root, *ARCHITECTURES),
+ )
+ )
+
+ def test_module_gate_accepts_a_selected_only_previous_manifest(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = make_repository(Path(temporary), {**VERSIONS, "renpy": "3.2.0"})
+ previous = str(
+ write(root, "previous.json", selected_manifest("renpy", "3.1.0", "x64", identity("renpy")))
+ )
+ assets = root / "assets"
+ write_assets(assets, list(expected_archives("renpy", "3.2.0")))
+
+ output = self.run_module_gate(
+ root,
+ "gate",
+ "--module",
+ "renpy",
+ "--tag",
+ "renpy/v3.2.0",
+ "--repository",
+ str(root),
+ "--assets",
+ str(assets),
+ "--previous",
+ previous,
+ *self.manifest_paths(root, *ARCHITECTURES, renpy=("3.2.0", identity("renpy-next"))),
+ )
+
+ report = json.loads((output / "release.json").read_text(encoding="utf-8"))
+ self.assertFalse(report["bootstrap"])
+ self.assertEqual(report["modules"][0]["previous_version"], "3.1.0")
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_result_export.py b/build/tests/test_result_export.py
new file mode 100644
index 0000000..787980a
--- /dev/null
+++ b/build/tests/test_result_export.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+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))
+
+import core.result_export as result_export # noqa: E402
+from core.result_export import ( # noqa: E402
+ DEFERRED,
+ FAILED,
+ PASSED,
+ Operation,
+ ResultExportError,
+ export_results,
+)
+
+
+def file(path: Path, text: str = "x") -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ return path
+
+
+class ResultExportTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._temporary = tempfile.TemporaryDirectory()
+ self.root = Path(self._temporary.name)
+ self.addCleanup(self._temporary.cleanup)
+
+ def test_successful_export_publishes_reports_logs_packages_and_manifest(self) -> None:
+ report = file(self.root / "src" / "analysis.sarif", "{}")
+ package = file(self.root / "src" / "renpy-3.1.0-x64.zip", "zip")
+ manifest = file(self.root / "src" / "packages.json", "{}")
+ destination = self.root / "evidence"
+
+ published = export_results(
+ destination,
+ "verify-arch",
+ (
+ Operation("source", PASSED, reports=(report,)),
+ Operation("package", DEFERRED, detail="blocked by coverage"),
+ ),
+ packages=(package, manifest),
+ )
+
+ self.assertEqual(published, destination)
+ document = json.loads((destination / "manifest.json").read_text(encoding="utf-8"))
+ self.assertEqual(document["schema"], 1)
+ self.assertEqual(document["command"], "verify-arch")
+ self.assertEqual(document["status"], "success")
+ self.assertEqual(document["passed"], ["source"])
+ self.assertEqual(document["failed"], [])
+ self.assertEqual(document["deferred"], [{"name": "package", "detail": "blocked by coverage"}])
+ self.assertEqual(
+ (destination / "reports" / "source" / "analysis.sarif").read_text(encoding="utf-8"),
+ "{}",
+ )
+ self.assertTrue((destination / "packages" / "renpy-3.1.0-x64.zip").is_file())
+ self.assertTrue((destination / "packages" / "packages.json").is_file())
+ self.assertTrue((destination / "logs").is_dir())
+ self.assertEqual(document["reports"][0]["path"], "reports/source/analysis.sarif")
+ self.assertEqual(len(document["reports"][0]["sha256"]), 64)
+ self.assertGreater(document["reports"][0]["size"], 0)
+
+ def test_failed_export_writes_the_failure_log_and_marks_the_command_failed(self) -> None:
+ destination = self.root / "evidence"
+ published = export_results(
+ destination,
+ "verify",
+ (Operation("coverage", FAILED, detail="gate failed", log="traceback\nboom\n"),),
+ )
+
+ document = json.loads((published / "manifest.json").read_text(encoding="utf-8"))
+ self.assertEqual(document["status"], "failed")
+ self.assertEqual(document["failed"], ["coverage"])
+ self.assertEqual(
+ (published / "logs" / "coverage.log").read_text(encoding="utf-8"),
+ "traceback\nboom\n",
+ )
+ self.assertEqual(document["logs"][0]["path"], "logs/coverage.log")
+
+ def test_an_existing_destination_is_refused(self) -> None:
+ destination = self.root / "evidence"
+ destination.mkdir()
+ with self.assertRaisesRegex(ResultExportError, "already exists"):
+ export_results(destination, "verify", ())
+
+ def test_a_missing_destination_parent_is_refused(self) -> None:
+ with self.assertRaisesRegex(ResultExportError, "parent must be an existing directory"):
+ export_results(self.root / "missing" / "evidence", "verify", ())
+
+ def test_a_reparse_ancestor_is_refused(self) -> None:
+ destination = self.root / "child" / "evidence"
+ (self.root / "child").mkdir()
+ with (
+ mock.patch.object(result_export, "_is_reparse", side_effect=lambda path: Path(path) == destination.parent),
+ self.assertRaisesRegex(ResultExportError, "reparse point in export destination"),
+ ):
+ export_results(destination, "verify", ())
+
+ def test_invalid_command_and_operations_are_refused(self) -> None:
+ for command in ("Verify", "", "bad name"):
+ with self.subTest(command=command), self.assertRaisesRegex(ResultExportError, "command must"):
+ export_results(self.root / "e", command, ())
+
+ cases = (
+ ((Operation("bad name", PASSED),), "invalid operation name"),
+ ((Operation("a", PASSED), Operation("a", PASSED)), "duplicate operation"),
+ ((Operation("a", "unknown"),), "invalid operation status"),
+ )
+ for operations, message in cases:
+ with self.subTest(message=message), self.assertRaisesRegex(ResultExportError, message):
+ export_results(self.root / "e", "verify", operations)
+
+ def test_missing_reparse_and_non_file_evidence_are_refused(self) -> None:
+ report = file(self.root / "src" / "report.sarif", "{}")
+ with self.assertRaisesRegex(ResultExportError, "claimed evidence is missing"):
+ export_results(self.root / "e1", "verify", (Operation("a", PASSED, reports=(self.root / "no",)),))
+ with self.assertRaisesRegex(ResultExportError, "not a regular file"):
+ export_results(self.root / "e2", "verify", (Operation("a", PASSED, reports=(self.root,)),))
+ with (
+ mock.patch.object(result_export, "_is_reparse", side_effect=lambda path: Path(path) == report),
+ self.assertRaisesRegex(ResultExportError, "reparse points are forbidden in evidence"),
+ ):
+ export_results(self.root / "e3", "verify", (Operation("a", PASSED, reports=(report,)),))
+
+ def test_same_basenames_in_different_directories_export_distinctly(self) -> None:
+ debug = file(self.root / "tests" / "x64" / "Debug" / "tests.xml", "")
+ release = file(self.root / "tests" / "x64" / "Release" / "tests.xml", "")
+ destination = self.root / "evidence"
+ published = export_results(
+ destination, "verify-arch",
+ (Operation("tests-x64", PASSED, reports=(debug, release)),),
+ )
+ document = json.loads((published / "manifest.json").read_text(encoding="utf-8"))
+ self.assertEqual(
+ [record["path"] for record in document["reports"]],
+ ["reports/tests-x64/Debug/tests.xml", "reports/tests-x64/Release/tests.xml"],
+ )
+ self.assertEqual(
+ (published / "reports" / "tests-x64" / "Debug" / "tests.xml").read_text(
+ encoding="utf-8"
+ ),
+ "",
+ )
+ self.assertEqual(
+ (published / "reports" / "tests-x64" / "Release" / "tests.xml").read_text(
+ encoding="utf-8"
+ ),
+ "",
+ )
+
+ def test_multiarch_same_basename_reports_keep_their_architecture(self) -> None:
+ x86 = file(self.root / "out" / "x86" / "analysis.sarif", "x86")
+ x64 = file(self.root / "out" / "x64" / "analysis.sarif", "x64")
+ published = export_results(
+ self.root / "evidence", "verify-arch",
+ (Operation("compiler-analysis", PASSED, reports=(x86, x64)),),
+ )
+ document = json.loads((published / "manifest.json").read_text(encoding="utf-8"))
+ self.assertEqual(
+ [record["path"] for record in document["reports"]],
+ [
+ "reports/compiler-analysis/x86/analysis.sarif",
+ "reports/compiler-analysis/x64/analysis.sarif",
+ ],
+ )
+
+ def test_a_repeated_report_path_is_refused(self) -> None:
+ report = file(self.root / "one" / "same.txt", "1")
+ with self.assertRaisesRegex(ResultExportError, "duplicate evidence name"):
+ export_results(
+ self.root / "e", "verify",
+ (Operation("a", PASSED, reports=(report, report)),),
+ )
+
+ def test_a_passed_operation_may_carry_its_command_log(self) -> None:
+ destination = self.root / "evidence"
+ published = export_results(
+ destination, "verify",
+ (Operation("tests-x64", PASSED, log="suite output\n"),),
+ )
+ document = json.loads((published / "manifest.json").read_text(encoding="utf-8"))
+ self.assertEqual(document["status"], "success")
+ self.assertEqual(document["logs"][0]["path"], "logs/tests-x64.log")
+ self.assertEqual(
+ (published / "logs" / "tests-x64.log").read_text(encoding="utf-8"),
+ "suite output\n",
+ )
+
+ def test_a_reparse_ancestor_of_claimed_evidence_is_refused(self) -> None:
+ ancestor = self.root / "src" / "nested"
+ report = file(ancestor / "report.sarif", "{}")
+ with (
+ mock.patch.object(
+ result_export, "_is_reparse", side_effect=lambda path: Path(path) == ancestor
+ ),
+ self.assertRaisesRegex(ResultExportError, "reparse points are forbidden in evidence"),
+ ):
+ export_results(self.root / "e", "verify", (Operation("a", PASSED, reports=(report,)),))
+
+ def test_a_copy_failure_is_wrapped(self) -> None:
+ report = file(self.root / "src" / "report.sarif", "{}")
+ with (
+ mock.patch.object(result_export.shutil, "copyfile", side_effect=OSError("locked")),
+ self.assertRaisesRegex(ResultExportError, "could not publish evidence export"),
+ ):
+ export_results(self.root / "e", "verify", (Operation("a", PASSED, reports=(report,)),))
+
+ def test_missing_lstat_of_a_destination_reports_absence(self) -> None:
+ self.assertIsNone(result_export._lstat(self.root / "absent"))
+ self.assertIsNotNone(result_export._lstat(self.root))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_sanitizer_gate.py b/build/tests/test_sanitizer_gate.py
new file mode 100644
index 0000000..befca0d
--- /dev/null
+++ b/build/tests/test_sanitizer_gate.py
@@ -0,0 +1,52 @@
+"""Standalone worker tests for the ``core.sanitizer`` log gate.
+
+These assertions were preserved from ``test_sanitizer_graph.py`` when the retired
+``graphs.sanitizer`` composition was dropped. They pin the per-runtime clean-log
+requirement and the CLI's lack of a relaxation switch, so the gate stays covered.
+"""
+
+from __future__ import annotations
+
+from contextlib import redirect_stderr
+import io
+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.sanitizer import SanitizerError, main as sanitizer_main, require_clean_log # noqa: E402
+
+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(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"))
+
+
+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..f08d282
--- /dev/null
+++ b/build/tests/test_sarif.py
@@ -0,0 +1,326 @@
+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())
+
+ def test_read_errors_retain_the_report_path_and_cause(self) -> None:
+ malformed = self.root / "malformed.sarif"
+ malformed.write_text("{", encoding="utf-8")
+ missing = self.root / "missing.sarif"
+
+ for path in (malformed, missing):
+ with self.subTest(path=path), self.assertRaises(SarifError) as raised:
+ normalize_msvc(path, self.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:
+ for name, results in (("mapping", {}), ("scalar", ["warning"])):
+ report = self.root / f"{name}.sarif"
+ write_json(report, {"version": "2.1.0", "runs": [{"results": results}]})
+ with self.subTest(results=results), self.assertRaisesRegex(
+ SarifError, "results must be a list of objects"
+ ):
+ require_clean((report,))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_source_checks.py b/build/tests/test_source_checks.py
new file mode 100644
index 0000000..e9c7f7a
--- /dev/null
+++ b/build/tests/test_source_checks.py
@@ -0,0 +1,1028 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from importlib import util as importlib_util
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tempfile
+from types import SimpleNamespace
+import unittest
+from unittest import mock
+
+
+HERE = Path(__file__).resolve().parent
+SUPPORT_ROOT = HERE.parent
+if str(SUPPORT_ROOT) not in sys.path:
+ sys.path.insert(0, str(SUPPORT_ROOT))
+
+# Load exactly the module under test from an explicit path. A RED harness can pin a frozen copy
+# through OBSERVER_SOURCE_MODULE; the default is this tree's sibling, and the assertion fails
+# loudly if anything else (for example a stale repository copy) would be imported instead.
+MODULE_UNDER_TEST = Path(
+ os.environ.get("OBSERVER_SOURCE_MODULE", SUPPORT_ROOT / "source_checks.py")
+).resolve()
+PSSA_SETTINGS = SUPPORT_ROOT / "PSScriptAnalyzerSettings.psd1"
+
+import native # noqa: E402
+
+
+def _load_module_under_test():
+ spec = importlib_util.spec_from_file_location("source_checks", MODULE_UNDER_TEST)
+ assert spec is not None and spec.loader is not None
+ module = importlib_util.module_from_spec(spec)
+ sys.modules["source_checks"] = module
+ spec.loader.exec_module(module)
+ loaded = Path(module.__file__).resolve()
+ if loaded != MODULE_UNDER_TEST:
+ raise AssertionError(f"loaded {loaded}, expected {MODULE_UNDER_TEST}")
+ return module
+
+
+source_checks = _load_module_under_test()
+
+
+_SARIF = {"version": "2.1.0", "runs": [{"results": []}]}
+
+
+def executable(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.touch()
+ return path
+
+
+@dataclass(frozen=True)
+class FakeTools:
+ pwsh: Path
+ clang_format: Path
+ cppcheck: Path
+ psscriptanalyzer: Path
+ llvm_dir: Path
+ native: object
+
+
+@dataclass
+class RecordedCall:
+ argv: tuple[str, ...]
+ cwd: Path | None
+ env: dict[str, str] | None
+
+
+class StubRunner:
+ """Record every invocation and materialise the reports the real tools would write."""
+
+ def __init__(self, *, cppcheck: bool = True, msvc: bool = True, tidy: bool = True,
+ failure: type[BaseException] | None = None) -> None:
+ self.calls: list[RecordedCall] = []
+ self.cppcheck = cppcheck
+ self.msvc = msvc
+ self.tidy = tidy
+ self.failure = failure
+
+ def __call__(self, argv, cwd=None, env=None) -> None:
+ recorded = tuple(str(item) for item in argv)
+ self.calls.append(RecordedCall(
+ recorded,
+ None if cwd is None else Path(cwd),
+ None if env is None else dict(env),
+ ))
+ if self.failure is not None:
+ raise self.failure
+ self.materialise(recorded)
+
+ def materialise(self, argv: tuple[str, ...]) -> None:
+ options = {}
+ for argument in argv:
+ if argument.startswith("/p:") and "=" in argument:
+ key, _, value = argument[3:].partition("=")
+ options[key] = value
+ if "-File" in argv and argv[argv.index("-File") + 1].endswith("psscriptanalyzer.ps1"):
+ self.write(Path(argv[argv.index("-Output") + 1]), "[]")
+ if self.cppcheck:
+ for argument in argv:
+ if argument.startswith("--output-file="):
+ self.write(Path(argument.split("=", 1)[1]), json.dumps(_SARIF))
+ if self.msvc and "ObserverAnalysisReportPath" in options:
+ self.write(Path(options["ObserverAnalysisReportPath"]), json.dumps(_SARIF))
+ if self.tidy and "ClangTidyLogFile" in options:
+ self.write(Path(options["IntDir"]) / options["ClangTidyLogFile"], "")
+
+ @staticmethod
+ def write(path: Path, content: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+
+
+class ToolsMixin:
+ def tools(self, root: Path) -> FakeTools:
+ directory = root / "tools"
+ (directory / "llvm").mkdir(parents=True, exist_ok=True)
+ return FakeTools(
+ pwsh=executable(directory / "pwsh.exe"),
+ clang_format=executable(directory / "clang-format.exe"),
+ cppcheck=executable(directory / "cppcheck.exe"),
+ psscriptanalyzer=executable(directory / "PSScriptAnalyzer.psd1"),
+ llvm_dir=directory / "llvm",
+ native=native.NativeTools(
+ msbuild=executable(directory / "MSBuild.exe"),
+ vcpkg=executable(directory / "vcpkg" / "vcpkg.exe"),
+ ),
+ )
+
+
+class ValidationTests(ToolsMixin, unittest.TestCase):
+ def test_unsupported_empty_and_duplicate_architectures_are_rejected_without_running_a_tool(
+ self,
+ ) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = root / "repo"
+ repository.mkdir()
+ tools = self.tools(root)
+ operations = (source_checks.source_checks, source_checks.compiler_analysis)
+ cases = (
+ ((), "at least one architecture"),
+ (("mips",), "unsupported architecture"),
+ (("x64", "x64"), "must be unique"),
+ )
+ for operation in operations:
+ for architectures, message in cases:
+ with self.subTest(operation=operation.__name__, architectures=architectures):
+ runner = StubRunner()
+ with self.assertRaisesRegex(ValueError, message):
+ operation(
+ repository, architectures, root / "out",
+ runner=runner, tools=tools,
+ )
+ self.assertEqual(runner.calls, [])
+
+ def test_a_job_count_must_be_a_positive_integer(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = root / "repo"
+ repository.mkdir()
+ tools = self.tools(root)
+ for operation in (source_checks.source_checks, source_checks.compiler_analysis):
+ with self.subTest(operation=operation.__name__):
+ runner = StubRunner()
+ with self.assertRaisesRegex(ValueError, "jobs"):
+ operation(
+ repository, ("x64",), root / "out",
+ jobs=0, runner=runner, tools=tools,
+ )
+ self.assertEqual(runner.calls, [])
+
+
+class SourceChecksTests(ToolsMixin, unittest.TestCase):
+ def repository(self, root: Path) -> Path:
+ files = {
+ "src/modules/renpy/pickle.cpp": '#include "pickle.h"\n',
+ "src/modules/renpy/pickle.h": "#pragma once\n",
+ "build.ps1": "#requires -Version 7.4\n",
+ "build/checks/example.ps1": "Set-StrictMode -Version Latest\n",
+ "build/tests/example.Tests.ps1": "#requires -Version 7.4\n",
+ "build/PSScriptAnalyzerSettings.psd1": "@{}\n",
+ "out/ignored.ps1": "Set-StrictMode -Version Latest\n",
+ "build/.venv/ignored.ps1": "Set-StrictMode -Version Latest\n",
+ "notes.txt": "scratch\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 test_source_checks_restores_analyzes_formats_pssa_contracts_and_gates(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo").resolve()
+ tools = self.tools(root)
+ output = root / "out"
+ resolved = output.resolve()
+ runner = StubRunner()
+
+ reports = source_checks.source_checks(
+ repository, ("x86", "x64"), output, jobs=3, runner=runner, tools=tools,
+ )
+
+ # The pinned third-party tree is restored before any analyzer that reads it.
+ self.assertEqual(
+ tuple(call.argv for call in runner.calls[:2]),
+ tuple(
+ native.restore_command(repository, architecture, tools.native)
+ for architecture in ("x86", "x64")
+ ),
+ )
+ self.assertTrue(all(call.cwd == repository for call in runner.calls[:2]))
+
+ # One clang-format command over every owned C++ source.
+ formatted = next(
+ call for call in runner.calls if call.argv[0] == str(tools.clang_format)
+ )
+ cpp_sources = sorted(
+ str(path)
+ for path in (repository / "src").rglob("*")
+ if path.is_file() and path.suffix in {".cpp", ".h", ".hpp"}
+ )
+ self.assertEqual(
+ formatted.argv,
+ (str(tools.clang_format), "--dry-run", "--Werror", *cpp_sources),
+ )
+ self.assertEqual(formatted.cwd, repository)
+
+ # Cppcheck keeps the per-architecture platform, define, third-party include and gate
+ # inputs, and its SARIF run is stamped for the merge.
+ cppcheck_calls = [
+ call for call in runner.calls if call.argv[0] == str(tools.cppcheck)
+ ]
+
+ def expected_cppcheck(architecture: str, platform: str, define: str):
+ triplet = native.triplet(architecture)
+ include = native.restore_root(repository, architecture) / triplet / "include"
+ return (
+ str(tools.cppcheck),
+ str(repository / "src"),
+ "--std=c++23",
+ f"--platform={platform}",
+ "-DWIN32=1",
+ "-D_WIN32=1",
+ "-DUNICODE=1",
+ "-D_UNICODE=1",
+ f"-D{define}",
+ f"-I{repository / 'src'}",
+ f"-I{include}",
+ "--enable=warning,style,performance,portability",
+ "--check-level=exhaustive",
+ "--inconclusive",
+ "--inline-suppr",
+ "--suppress=missingIncludeSystem",
+ "--suppress=uninitMemberVarNoCtor:src/api.h",
+ f"--suppress=*:*\\{triplet}\\include\\*",
+ "--suppress=functionStatic",
+ f"--relative-paths={repository}",
+ "--output-format=sarif",
+ f"--output-file={resolved / f'cppcheck-{architecture}.sarif'}",
+ f"--cppcheck-build-dir={resolved / f'cppcheck-{architecture}-build'}",
+ )
+
+ self.assertEqual(len(cppcheck_calls), 2)
+ self.assertEqual(cppcheck_calls[0].argv, expected_cppcheck("x86", "win32W", "_M_IX86=600"))
+ self.assertEqual(cppcheck_calls[1].argv, expected_cppcheck("x64", "win64", "_M_X64=100"))
+ self.assertEqual(cppcheck_calls[0].cwd, repository)
+ stamped = json.loads((resolved / "cppcheck-x86.sarif").read_text(encoding="utf-8"))
+ self.assertEqual(
+ stamped["runs"][0]["automationDetails"]["id"], "cppcheck/x86/0/"
+ )
+
+ # One PSScriptAnalyzer script over the whole owned PowerShell list, rendered scripts
+ # and transient build state excluded.
+ pssa = next(
+ call
+ for call in runner.calls
+ if call.argv[0] == str(tools.pwsh)
+ and "-File" in call.argv
+ and call.argv[call.argv.index("-File") + 1].endswith("psscriptanalyzer.ps1")
+ )
+ self.assertIn(
+ str(repository / "build" / "PSScriptAnalyzerSettings.psd1"), pssa.argv
+ )
+ self.assertEqual(
+ pssa.argv[-3:],
+ (
+ str(repository / "build.ps1"),
+ str(repository / "build" / "checks" / "example.ps1"),
+ str(repository / "build" / "tests" / "example.Tests.ps1"),
+ ),
+ )
+
+ # Every repository contract runs, and nothing under ``out`` or ``.venv`` is scanned.
+ contract = next(
+ call
+ for call in runner.calls
+ if len(call.argv) == 6
+ and call.argv[5] == str(repository / "build" / "tests" / "example.Tests.ps1")
+ )
+ self.assertEqual(
+ contract.argv,
+ (
+ str(tools.pwsh),
+ "-NoLogo",
+ "-NoProfile",
+ "-NonInteractive",
+ "-File",
+ str(repository / "build" / "tests" / "example.Tests.ps1"),
+ ),
+ )
+ scanned = pssa.argv[pssa.argv.index("-Output") + 2:]
+ self.assertNotIn(str(repository / "out" / "ignored.ps1"), scanned)
+ self.assertNotIn(str(repository / "build" / ".venv" / "ignored.ps1"), scanned)
+
+ # The findings are normalized, merged and gated through the shared core.sarif CLI.
+ merge = next(call for call in runner.calls if call.argv[3] == "merge")
+ self.assertEqual(merge.argv[:4], (sys.executable, "-m", "core.sarif", "merge"))
+ self.assertEqual(
+ merge.argv[4:-2],
+ (
+ str(resolved / "cppcheck-x86.sarif"),
+ str(resolved / "cppcheck-x64.sarif"),
+ str(resolved / "psscriptanalyzer.sarif"),
+ ),
+ )
+ self.assertEqual(merge.argv[-2:], ("--output-name", "analysis.sarif"))
+ self.assertEqual(merge.cwd, repository / "build")
+ self.assertEqual(merge.env["OBSERVER_OUT_DIR"], str(resolved))
+ gate = next(call for call in runner.calls if call.argv[3] == "gate")
+ self.assertEqual(
+ gate.argv,
+ (sys.executable, "-m", "core.sarif", "gate", str(resolved / "analysis.sarif")),
+ )
+ self.assertEqual(reports, (resolved / "analysis.sarif",))
+
+ def test_source_checks_skip_format_and_pssa_when_no_sources_exist(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = root / "repo"
+ repository.mkdir()
+ tools = self.tools(root)
+ output = root / "out"
+ runner = StubRunner()
+
+ reports = source_checks.source_checks(
+ repository, ("x64",), output, runner=runner, tools=tools,
+ )
+
+ self.assertFalse(
+ any(call.argv[0] == str(tools.clang_format) for call in runner.calls)
+ )
+ self.assertFalse(any(call.argv[0] == str(tools.pwsh) for call in runner.calls))
+ self.assertTrue(any(call.argv[0] == str(tools.cppcheck) for call in runner.calls))
+ self.assertEqual(reports, (output.resolve() / "analysis.sarif",))
+
+ def test_source_checks_fail_when_cppcheck_produces_no_report(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ tools = self.tools(root)
+
+ with self.assertRaisesRegex(FileNotFoundError, "Cppcheck did not produce"):
+ source_checks.source_checks(
+ repository, ("x64",), root / "out",
+ runner=StubRunner(cppcheck=False), tools=tools,
+ )
+
+ def test_source_checks_reject_a_cppcheck_report_without_runs(self) -> None:
+ class BrokenRunner(StubRunner):
+ def materialise(self, argv: tuple[str, ...]) -> None:
+ for argument in argv:
+ if argument.startswith("--output-file="):
+ self.write(
+ Path(argument.split("=", 1)[1]),
+ json.dumps({"version": "2.1.0", "runs": {}}),
+ )
+
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ tools = self.tools(root)
+
+ with self.assertRaisesRegex(ValueError, "no runs"):
+ source_checks.source_checks(
+ repository, ("x64",), root / "out",
+ runner=BrokenRunner(), tools=tools,
+ )
+
+ def test_the_operation_defaults_to_the_located_tools_and_the_native_runner(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ tools = self.tools(root)
+ execute = StubRunner()
+
+ with (
+ mock.patch.object(source_checks, "locate_tools", return_value=tools) as locate,
+ mock.patch.object(source_checks.native, "run_tool", execute),
+ ):
+ reports = source_checks.source_checks(
+ repository, ("x64",), root / "out", jobs=2
+ )
+
+ locate.assert_called_once_with()
+ self.assertTrue(execute.calls)
+ self.assertEqual(reports, ((root / "out").resolve() / "analysis.sarif",))
+
+ def test_source_checks_create_the_cppcheck_build_directory_before_running(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ tools = self.tools(root)
+ observed: dict[str, bool] = {}
+
+ class Observing(StubRunner):
+ def __call__(self, argv, cwd=None, env=None) -> None:
+ for argument in argv:
+ if argument.startswith("--cppcheck-build-dir="):
+ observed["exists"] = Path(argument.split("=", 1)[1]).is_dir()
+ super().__call__(argv, cwd=cwd, env=env)
+
+ source_checks.source_checks(
+ repository, ("x64",), root / "out", runner=Observing(), tools=tools,
+ )
+ self.assertTrue(observed.get("exists", False))
+
+
+class CompilerAnalysisTests(ToolsMixin, unittest.TestCase):
+ def repository(self, root: Path) -> Path:
+ files = {
+ "src/modules/renpy/pickle.cpp": '#include "pickle.h"\n',
+ "src/modules/renpy/pickle.h": "#pragma once\n",
+ "build/projects/renpy.vcxproj": "\n",
+ "build/projects/rpgmaker.vcxproj": "\n",
+ "build/projects/leak-probe.vcxproj": "\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 test_compiler_analysis_analyzes_every_project_and_gates_each_architecture(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo").resolve()
+ tools = self.tools(root)
+ output = root / "out"
+ resolved = output.resolve()
+ runner = StubRunner()
+
+ reports = source_checks.compiler_analysis(
+ repository, ("x64",), output, jobs=5, runner=runner, tools=tools,
+ )
+
+ self.assertEqual(reports, (resolved / "x64" / "analysis.sarif",))
+ self.assertEqual(
+ runner.calls[0].argv,
+ native.restore_command(repository, "x64", tools.native),
+ )
+
+ # Every production project is compiled for the analyzer: once for MSVC /analyze and
+ # once for clang-tidy, into isolated output variants.
+ msbuild_calls = [
+ call for call in runner.calls if call.argv[0] == str(tools.native.msbuild)
+ ]
+ projects = ("leak-probe", "renpy", "rpgmaker")
+ self.assertEqual(
+ [call.argv[1] for call in msbuild_calls],
+ [
+ str(repository / "build" / "projects" / f"{name}.vcxproj")
+ for name in projects
+ for _backend in ("msvc", "tidy")
+ ],
+ )
+
+ def expected_msvc(name: str, configuration: str):
+ variant = resolved / "x64" / "raw" / "msvc" / name
+ return (
+ str(tools.native.msbuild),
+ str(repository / "build" / "projects" / f"{name}.vcxproj"),
+ "/nologo",
+ "/m:5",
+ "/nr:false",
+ "/t:Rebuild",
+ "/p:BuildProjectReferences=false",
+ f"/p:Configuration={configuration}",
+ "/p:Platform=x64",
+ f"/p:OutDir={variant}{os.sep}",
+ f"/p:IntDir={variant / 'obj'}{os.sep}",
+ "/p:ObserverCompileAnalysis=true",
+ f"/p:VcpkgRoot={tools.native.vcpkg_root}",
+ f"/p:VcpkgInstalledDir={native.restore_root(repository, 'x64')}{os.sep}",
+ "/p:ObserverRunCodeAnalysis=true",
+ "/p:RunCodeAnalysis=true",
+ "/p:EnableMicrosoftCodeAnalysis=true",
+ "/p:ObserverEnableClangTidy=false",
+ f"/p:ObserverAnalysisReportName={name}",
+ f"/p:ObserverAnalysisReportPath={variant / f'{name}.sarif'}",
+ )
+
+ def expected_tidy(name: str, configuration: str):
+ variant = resolved / "x64" / "raw" / "tidy" / name
+ return (
+ str(tools.native.msbuild),
+ str(repository / "build" / "projects" / f"{name}.vcxproj"),
+ "/nologo",
+ "/m:5",
+ "/nr:false",
+ "/t:Rebuild",
+ "/p:BuildProjectReferences=false",
+ f"/p:Configuration={configuration}",
+ "/p:Platform=x64",
+ f"/p:OutDir={variant}{os.sep}",
+ f"/p:IntDir={variant / 'obj'}{os.sep}",
+ "/p:ObserverCompileAnalysis=true",
+ f"/p:VcpkgRoot={tools.native.vcpkg_root}",
+ f"/p:VcpkgInstalledDir={native.restore_root(repository, 'x64')}{os.sep}",
+ "/p:ObserverRunCodeAnalysis=true",
+ "/p:RunCodeAnalysis=true",
+ "/p:EnableMicrosoftCodeAnalysis=false",
+ "/p:ObserverEnableClangTidy=true",
+ f"/p:LLVMInstallDir={tools.llvm_dir}",
+ f"/p:ClangTidyLogFile={name}.ClangTidy.log",
+ )
+
+ self.assertEqual(msbuild_calls[0].argv, expected_msvc("leak-probe", "Release"))
+ self.assertEqual(msbuild_calls[1].argv, expected_tidy("leak-probe", "Release"))
+ self.assertEqual(msbuild_calls[2].argv, expected_msvc("renpy", "Debug"))
+ self.assertEqual(msbuild_calls[3].argv, expected_tidy("renpy", "Debug"))
+ self.assertTrue(all(call.cwd == repository for call in msbuild_calls))
+
+ # Raw MSVC SARIF and clang-tidy logs are normalized then merged and gated per arch.
+ normalize = next(call for call in runner.calls if call.argv[3] == "normalize-msvc")
+ self.assertEqual(
+ normalize.argv,
+ (
+ sys.executable,
+ "-m",
+ "core.sarif",
+ "normalize-msvc",
+ str(resolved / "x64" / "raw" / "msvc" / "leak-probe" / "leak-probe.sarif"),
+ "msvc-analyze/x64/leak-probe/",
+ "--output-name",
+ "x64/msvc/leak-probe.sarif",
+ ),
+ )
+ convert = next(call for call in runner.calls if call.argv[3] == "convert-tidy")
+ self.assertEqual(
+ convert.argv,
+ (
+ sys.executable,
+ "-m",
+ "core.sarif",
+ "convert-tidy",
+ str(repository),
+ str(resolved / "x64" / "raw" / "tidy" / "leak-probe" / "obj"),
+ "clang-tidy/x64/leak-probe/",
+ "--output-name",
+ "x64/tidy/leak-probe.sarif",
+ ),
+ )
+ merge = next(call for call in runner.calls if call.argv[3] == "merge")
+ self.assertEqual(merge.argv[:4], (sys.executable, "-m", "core.sarif", "merge"))
+ self.assertEqual(
+ merge.argv[4:-2],
+ tuple(
+ str(resolved / "x64" / backend / f"{name}.sarif")
+ for name in projects
+ for backend in ("msvc", "tidy")
+ ),
+ )
+ self.assertEqual(merge.argv[-2:], ("--output-name", "x64/analysis.sarif"))
+ self.assertEqual(merge.env["OBSERVER_OUT_DIR"], str(resolved))
+ gate = next(call for call in runner.calls if call.argv[3] == "gate")
+ self.assertEqual(
+ gate.argv,
+ (sys.executable, "-m", "core.sarif", "gate", str(resolved / "x64" / "analysis.sarif")),
+ )
+
+ def test_compiler_analysis_skips_the_leak_probe_for_non_x64(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo").resolve()
+ tools = self.tools(root)
+ output = root / "out"
+ runner = StubRunner()
+
+ reports = source_checks.compiler_analysis(
+ repository, ("x86",), output, runner=runner, tools=tools,
+ )
+
+ self.assertEqual(reports, (output.resolve() / "x86" / "analysis.sarif",))
+ compiled = [
+ call.argv[1]
+ for call in runner.calls
+ if call.argv[0] == str(tools.native.msbuild)
+ ]
+ self.assertEqual(
+ compiled,
+ [
+ str(repository / "build" / "projects" / f"{name}.vcxproj")
+ for name in ("renpy", "rpgmaker")
+ for _backend in ("msvc", "tidy")
+ ],
+ )
+ self.assertFalse(any("leak-probe" in argument for argument in compiled))
+
+ def test_compiler_analysis_fail_when_msvc_produces_no_report(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ tools = self.tools(root)
+
+ with self.assertRaisesRegex(FileNotFoundError, "MSVC analysis did not produce"):
+ source_checks.compiler_analysis(
+ repository, ("x64",), root / "out",
+ runner=StubRunner(msvc=False), tools=tools,
+ )
+
+ def test_compiler_analysis_fail_when_clang_tidy_produces_no_log(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ tools = self.tools(root)
+
+ with self.assertRaisesRegex(FileNotFoundError, "clang-tidy did not produce"):
+ source_checks.compiler_analysis(
+ repository, ("x64",), root / "out",
+ runner=StubRunner(tidy=False), tools=tools,
+ )
+
+ def test_compiler_analysis_compiles_fuzz_projects_without_linking(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo").resolve()
+ fuzz = repository / "build" / "projects" / "fuzz-renpy.vcxproj"
+ fuzz.write_text(
+ "\n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ "\n",
+ encoding="utf-8",
+ )
+ tools = self.tools(root)
+ runner = StubRunner()
+
+ source_checks.compiler_analysis(
+ repository, ("x64",), root / "out", runner=runner, tools=tools,
+ )
+
+ fuzz_calls = [
+ call for call in runner.calls
+ if call.argv[0] == str(tools.native.msbuild) and call.argv[1] == str(fuzz)
+ ]
+ self.assertEqual(len(fuzz_calls), 2)
+ for call in fuzz_calls:
+ self.assertIn("/t:ClCompile", call.argv)
+ self.assertNotIn("/t:Rebuild", call.argv)
+ # A compile-only target would otherwise reuse a stale incremental analysis.
+ self.assertIn("/p:ForceRebuild=true", call.argv)
+
+ def test_compiler_analysis_defaults_to_the_located_analysis_tools(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repository = self.repository(root / "repo")
+ tools = self.tools(root)
+ execute = StubRunner()
+
+ with (
+ mock.patch.object(
+ source_checks, "locate_analysis_tools", return_value=tools
+ ) as locate,
+ mock.patch.object(source_checks.native, "run_tool", execute),
+ ):
+ reports = source_checks.compiler_analysis(
+ repository, ("x64",), root / "out", jobs=2
+ )
+
+ locate.assert_called_once_with()
+ self.assertTrue(execute.calls)
+ self.assertEqual(reports, ((root / "out").resolve() / "x64" / "analysis.sarif",))
+
+
+class LocateToolsTests(unittest.TestCase):
+ def test_locate_tools_resolves_standalone_source_tools_without_llvm(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ pwsh = executable(root / "pwsh.exe")
+ clang_format = executable(root / "clang-format.exe")
+ cppcheck = executable(root / "cppcheck.exe")
+ pssa = executable(root / "PSScriptAnalyzer.psd1")
+ native_tools = native.NativeTools(
+ msbuild=executable(root / "MSBuild.exe"),
+ vcpkg=executable(root / "vcpkg" / "vcpkg.exe"),
+ )
+ found = {
+ "pwsh": str(pwsh),
+ "clang-format": str(clang_format),
+ "cppcheck": str(cppcheck),
+ }
+ completed = SimpleNamespace(stdout=str(pssa) + "\n")
+ with (
+ mock.patch.object(source_checks.shutil, "which", side_effect=found.get),
+ mock.patch.object(
+ source_checks.subprocess, "run", return_value=completed
+ ) as run,
+ mock.patch.object(
+ source_checks.native, "locate_tools", return_value=native_tools
+ ) as locate,
+ mock.patch.object(source_checks, "discover_msvc_toolchain") as toolchain,
+ ):
+ tools = source_checks.locate_tools()
+
+ toolchain.assert_not_called()
+ locate.assert_called_once_with()
+ 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.native, native_tools)
+ self.assertIsNone(tools.llvm_dir)
+ self.assertEqual(run.call_args.args[0][0], str(pwsh.resolve()))
+
+ def test_locate_analysis_tools_adds_the_llvm_toolchain(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ base = source_checks.CheckTools(
+ pwsh=root / "pwsh.exe",
+ clang_format=root / "clang-format.exe",
+ cppcheck=root / "cppcheck.exe",
+ psscriptanalyzer=root / "PSScriptAnalyzer.psd1",
+ native=native.NativeTools(
+ msbuild=root / "MSBuild.exe", vcpkg=root / "vcpkg" / "vcpkg.exe"
+ ),
+ )
+ toolchain = SimpleNamespace(llvm_dir=root / "llvm")
+ with (
+ mock.patch.object(
+ source_checks, "discover_msvc_toolchain", return_value=toolchain
+ ) as discover,
+ mock.patch.object(source_checks, "locate_tools", return_value=base),
+ ):
+ tools = source_checks.locate_analysis_tools()
+
+ discover.assert_called_once_with()
+ self.assertEqual(tools.llvm_dir, root / "llvm")
+ self.assertEqual(tools.pwsh, base.pwsh)
+
+ def test_a_missing_standalone_tool_is_reported(self) -> None:
+ with mock.patch.object(source_checks.shutil, "which", return_value=None):
+ with self.assertRaisesRegex(FileNotFoundError, "not found on PATH"):
+ source_checks._on_path("clang-format")
+
+ def test_an_empty_psscriptanalyzer_query_is_reported(self) -> None:
+ completed = SimpleNamespace(stdout="\n")
+ with mock.patch.object(source_checks.subprocess, "run", return_value=completed):
+ with self.assertRaisesRegex(FileNotFoundError, "PSScriptAnalyzer was not found"):
+ source_checks._psscriptanalyzer_path(Path("pwsh.exe"))
+
+ def test_locate_tools_falls_back_to_the_visual_studio_clang_format(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ vs = root / "VS"
+ msbuild = executable(vs / "MSBuild" / "Current" / "Bin" / "amd64" / "MSBuild.exe")
+ formatter = executable(vs / "VC" / "Tools" / "Llvm" / "x64" / "bin" / "clang-format.exe")
+ pwsh = executable(root / "pwsh.exe")
+ cppcheck = executable(root / "cppcheck.exe")
+ pssa = executable(root / "PSScriptAnalyzer.psd1")
+ native_tools = native.NativeTools(
+ msbuild=msbuild, vcpkg=executable(root / "vcpkg" / "vcpkg.exe")
+ )
+ # clang-format is deliberately absent from PATH; the VS formatter is the fallback.
+ found = {"pwsh": str(pwsh), "cppcheck": str(cppcheck)}
+ completed = SimpleNamespace(stdout=str(pssa) + "\n")
+ with (
+ mock.patch.object(source_checks.shutil, "which", side_effect=found.get),
+ mock.patch.object(source_checks.subprocess, "run", return_value=completed),
+ mock.patch.object(
+ source_checks.native, "locate_tools", return_value=native_tools
+ ),
+ mock.patch.object(source_checks, "discover_msvc_toolchain") as toolchain,
+ ):
+ tools = source_checks.locate_tools()
+
+ toolchain.assert_not_called()
+ self.assertEqual(tools.clang_format, formatter.resolve())
+ self.assertIsNone(tools.llvm_dir)
+
+ def test_locate_tools_reports_a_formatter_missing_from_path_and_visual_studio(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ msbuild = executable(root / "VS" / "MSBuild" / "Current" / "Bin" / "MSBuild.exe")
+ pwsh = executable(root / "pwsh.exe")
+ cppcheck = executable(root / "cppcheck.exe")
+ pssa = executable(root / "PSScriptAnalyzer.psd1")
+ native_tools = native.NativeTools(
+ msbuild=msbuild, vcpkg=executable(root / "vcpkg" / "vcpkg.exe")
+ )
+ found = {"pwsh": str(pwsh), "cppcheck": str(cppcheck)}
+ completed = SimpleNamespace(stdout=str(pssa) + "\n")
+ with (
+ mock.patch.object(source_checks.shutil, "which", side_effect=found.get),
+ mock.patch.object(source_checks.subprocess, "run", return_value=completed),
+ mock.patch.object(
+ source_checks.native, "locate_tools", return_value=native_tools
+ ),
+ ):
+ with self.assertRaisesRegex(FileNotFoundError, "clang-format"):
+ source_checks.locate_tools()
+
+
+class PssaConversionTests(unittest.TestCase):
+ def test_findings_convert_to_sarif_with_severity_mapping_and_grouping(self) -> None:
+ repository = Path("C:/repo")
+ sources = (repository / "a.ps1", repository / "b.ps1")
+ # The collector emits raw PSScriptAnalyzer records; Python maps the raw field names.
+ findings = [
+ {"RuleName": "PSUseSingularNouns", "Severity": "Warning", "Message": "w",
+ "Line": 3, "Column": 5, "ScriptPath": str(repository / "a.ps1")},
+ {"RuleName": "PSAvoidUsingWriteHost", "Severity": "Error", "Message": "e",
+ "Line": 1, "Column": 1, "ScriptPath": str(repository / "a.ps1")},
+ {"RuleName": "PSUseShouldProcessForStateChangingFunctions", "Severity": "Information",
+ "Message": "i", "Line": 9, "Column": 2, "ScriptPath": str(repository / "c.ps1")},
+ ]
+ runs = source_checks._pssa_runs(repository, sources, findings)
+ self.assertEqual(
+ [run["automationDetails"]["id"] for run in runs],
+ ["psscriptanalyzer/a.ps1/", "psscriptanalyzer/b.ps1/", "psscriptanalyzer/c.ps1/"],
+ )
+ self.assertEqual([item["level"] for item in runs[0]["results"]], ["warning", "error"])
+ self.assertEqual(runs[1]["results"], [])
+ self.assertEqual(runs[2]["results"][0]["level"], "note")
+ self.assertEqual(
+ runs[0]["results"][0]["locations"][0]["physicalLocation"]["region"],
+ {"startLine": 3, "startColumn": 5},
+ )
+ self.assertEqual(runs[0]["tool"]["driver"]["name"], "PSScriptAnalyzer")
+
+ def test_findings_must_be_a_list(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ path = Path(temporary) / "findings.json"
+ path.write_text("{}", encoding="utf-8")
+ with self.assertRaisesRegex(ValueError, "must be a list"):
+ source_checks._read_findings(path)
+
+ def test_a_finding_outside_the_repository_is_rejected(self) -> None:
+ with self.assertRaisesRegex(ValueError, "outside the repository"):
+ source_checks._uri(Path("C:/repo"), Path("D:/other/x.ps1"))
+
+
+_BRANCH_COUNT_QUERY = """
+$tokens = $null
+$errors = $null
+$ast = [System.Management.Automation.Language.Parser]::ParseFile(
+ $env:OBSERVER_COLLECTOR, [ref]$tokens, [ref]$errors)
+$branches = $ast.FindAll({
+ param($node)
+ $node -is [System.Management.Automation.Language.IfStatementAst] -or
+ $node -is [System.Management.Automation.Language.ForEachStatementAst] -or
+ $node -is [System.Management.Automation.Language.ForStatementAst] -or
+ $node -is [System.Management.Automation.Language.WhileStatementAst] -or
+ $node -is [System.Management.Automation.Language.SwitchStatementAst] -or
+ $node -is [System.Management.Automation.Language.TryStatementAst]
+}, $true).Count
+"BRANCHES=$branches"
+"STATEMENTS=$($ast.EndBlock.Statements.Count)"
+"""
+
+
+class CollectorTests(unittest.TestCase):
+ """Run the real branchless PSScriptAnalyzer collector through the installed tooling."""
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.pwsh = shutil.which("pwsh")
+ cls.collector = (
+ Path(source_checks.__file__).resolve().parent / "checks" / "psscriptanalyzer.ps1"
+ )
+ cls.module: str | None = None
+ if cls.pwsh is not None:
+ probe = subprocess.run(
+ [cls.pwsh, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
+ source_checks._PSSA_QUERY],
+ capture_output=True, text=True,
+ )
+ cls.module = probe.stdout.strip() or None
+
+ def setUp(self) -> None:
+ if self.pwsh is None or self.module is None:
+ self.skipTest("pwsh with PSScriptAnalyzer is not installed")
+
+ def _collect(self, sources: tuple[Path, ...]) -> list[dict[str, object]]:
+ with tempfile.TemporaryDirectory() as temporary:
+ output = Path(temporary) / "findings.json"
+ result = subprocess.run(
+ [
+ self.pwsh, "-NoLogo", "-NoProfile", "-NonInteractive",
+ "-File", str(self.collector),
+ "-Module", str(self.module),
+ "-Settings", str(PSSA_SETTINGS),
+ "-Output", str(output),
+ *(str(source) for source in sources),
+ ],
+ capture_output=True, text=True,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertTrue(output.is_file(), result.stderr)
+ document = json.loads(output.read_text(encoding="utf-8-sig"))
+ self.assertIsInstance(document, list)
+ return document
+
+ def test_an_empty_source_list_produces_an_empty_list(self) -> None:
+ self.assertEqual(self._collect(()), [])
+
+ def test_a_single_source_emits_raw_analyzer_records(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ source = Path(temporary) / "single.ps1"
+ source.write_text("function Get-Things { }\n", encoding="utf-8")
+
+ findings = self._collect((source,))
+
+ self.assertTrue(findings)
+ for finding in findings:
+ for key in ("RuleName", "Severity", "Message", "Line", "Column", "ScriptPath"):
+ self.assertIn(key, finding)
+ self.assertIsInstance(finding["Severity"], str)
+ self.assertEqual(finding["ScriptPath"], str(source))
+ self.assertIn("PSUseSingularNouns", [finding["RuleName"] for finding in findings])
+
+ def test_multiple_sources_map_through_the_python_sarif_conversion(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ repository = Path(temporary)
+ first = repository / "first.ps1"
+ second = repository / "second.ps1"
+ first.write_text("function Get-Things { }\n", encoding="utf-8")
+ second.write_text("function Get-Boxes { }\n", encoding="utf-8")
+
+ findings = self._collect((first, second))
+ runs = source_checks._pssa_runs(repository, (first, second), findings)
+
+ self.assertEqual(
+ [run["automationDetails"]["id"] for run in runs],
+ ["psscriptanalyzer/first.ps1/", "psscriptanalyzer/second.ps1/"],
+ )
+ self.assertTrue(all(run["results"] for run in runs))
+ self.assertEqual(runs[0]["results"][0]["level"], "warning")
+ self.assertEqual(
+ runs[0]["results"][0]["locations"][0]["physicalLocation"]["region"]["startColumn"],
+ 10,
+ )
+
+ def test_a_missing_analyzer_module_is_a_failure(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ output = Path(temporary) / "findings.json"
+ result = subprocess.run(
+ [
+ self.pwsh, "-NoLogo", "-NoProfile", "-NonInteractive",
+ "-File", str(self.collector),
+ "-Module", str(Path(temporary) / "missing.psd1"),
+ "-Settings", str(PSSA_SETTINGS),
+ "-Output", str(output),
+ ],
+ capture_output=True, text=True,
+ )
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertFalse(output.is_file())
+
+ def test_the_remaining_collector_has_no_conditional_branches(self) -> None:
+ # The collector has zero conditional or loop statements. Measured with Set-PSBreakpoint
+ # line hits over the empty/single/multiple/failure fixture, the behavior tests execute all
+ # 5/5 of its statements (out/sol-team/interop-evidence/ps-executed-coverage.txt); this test
+ # keeps the zero-branch half of that guarantee in the suite.
+ result = subprocess.run(
+ [
+ self.pwsh, "-NoLogo", "-NoProfile", "-NonInteractive",
+ "-Command", _BRANCH_COUNT_QUERY,
+ ],
+ capture_output=True, text=True,
+ env={**os.environ, "OBSERVER_COLLECTOR": str(self.collector)},
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("BRANCHES=0", result.stdout)
+
+
+class ScriptTests(unittest.TestCase):
+ def test_the_psscriptanalyzer_check_script_is_parseable_powershell(self) -> None:
+ script = (
+ Path(source_checks.__file__).resolve().parent / "checks" / "psscriptanalyzer.ps1"
+ )
+ self.assertTrue(script.is_file(), script)
+ 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=script.read_text(encoding="utf-8"),
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
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_toolchain.py b/build/tests/test_toolchain.py
new file mode 100644
index 0000000..99dda99
--- /dev/null
+++ b/build/tests/test_toolchain.py
@@ -0,0 +1,277 @@
+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 ( # noqa: E402
+ _command_environment,
+ _existing,
+ _output,
+ discover_msvc_toolchain,
+)
+
+
+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")])
+
+ def test_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)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/build/tests/test_windows_job.py b/build/tests/test_windows_job.py
new file mode 100644
index 0000000..9fb698c
--- /dev/null
+++ b/build/tests/test_windows_job.py
@@ -0,0 +1,199 @@
+from __future__ import annotations
+
+import os
+import unittest
+from unittest.mock import patch
+
+import win32job
+import win32process
+
+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
+ )
+ JOB_OBJECT_LIMIT_PRIORITY_CLASS = win32job.JOB_OBJECT_LIMIT_PRIORITY_CLASS
+
+ 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_below_normal_priority_is_applied_to_the_whole_job(self) -> None:
+ api = FakeWin32Job()
+
+ with patch("core.windows_job.win32job", api):
+ WindowsJob(priority_class=win32process.BELOW_NORMAL_PRIORITY_CLASS).close()
+
+ limits = api.information["BasicLimitInformation"]
+ self.assertTrue(limits["LimitFlags"] & win32job.JOB_OBJECT_LIMIT_PRIORITY_CLASS)
+ self.assertEqual(
+ limits["PriorityClass"], win32process.BELOW_NORMAL_PRIORITY_CLASS
+ )
+
+ def test_job_leaves_priority_alone_unless_one_is_requested(self) -> None:
+ api = FakeWin32Job()
+
+ with patch("core.windows_job.win32job", api):
+ WindowsJob().close()
+
+ limits = api.information["BasicLimitInformation"]
+ self.assertFalse(limits["LimitFlags"] & win32job.JOB_OBJECT_LIMIT_PRIORITY_CLASS)
+ self.assertNotIn("PriorityClass", limits)
+
+ 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_is_configured_to_kill_its_descendant_tree(self) -> None:
+ job = WindowsJob()
+ try:
+ # The private handle is the only observation point for state the constructor
+ # applied to a real kernel object; every other test here runs against a fake.
+ information = win32job.QueryInformationJobObject(
+ job._handle, win32job.JobObjectExtendedLimitInformation
+ )
+ finally:
+ job.close()
+
+ self.assertTrue(
+ information["BasicLimitInformation"]["LimitFlags"]
+ & win32job.JOB_OBJECT_LIMIT_KILL_ON_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..5c1ee32
--- /dev/null
+++ b/build/tests/test_windows_process.py
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import subprocess
+import sys
+import unittest
+from unittest import mock
+
+
+BUILD_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BUILD_ROOT))
+
+import core.windows_process as windows_process # noqa: E402
+from core.windows_process import WindowsProcessRunner # noqa: E402
+
+
+class _Job:
+ def __init__(self, *, assign_error=None, close_error=None, terminate_error=None) -> None:
+ self._assign_error = assign_error
+ self._close_error = close_error
+ self._terminate_error = terminate_error
+ self.assigned: list[int] = []
+ self.closed = 0
+ self.terminated = 0
+
+ def assign_process(self, handle: int) -> None:
+ if self._assign_error is not None:
+ raise self._assign_error
+ self.assigned.append(handle)
+
+ def close(self) -> None:
+ self.closed += 1
+ if self._close_error is not None:
+ raise self._close_error
+
+ def terminate(self) -> None:
+ self.terminated += 1
+ if self._terminate_error is not None:
+ raise self._terminate_error
+
+
+class _Process:
+ def __init__(self, *, output: str | None = "", code: int = 0, handle: int = 11,
+ resume_error=None) -> None:
+ self._handle = handle
+ self._output = output
+ self._code = code
+ self._resume_error = resume_error
+ self.resumed = 0
+ self.killed = 0
+
+ def resume(self) -> None:
+ self.resumed += 1
+ if self._resume_error is not None:
+ raise self._resume_error
+
+ def communicate(self):
+ return (self._output, None)
+
+ def wait(self) -> int:
+ return self._code
+
+ def kill(self) -> None:
+ self.killed += 1
+
+
+class RunnerTests(unittest.TestCase):
+ def run_with(self, *, job: _Job, process: object, argv=("tool.exe", "a"),
+ cwd=Path("cwd"), env=None, stdout=None, spawn_error=None):
+ with (
+ mock.patch.object(windows_process, "WindowsJob", return_value=job),
+ mock.patch.object(windows_process.psutil, "Popen") as popen,
+ ):
+ if spawn_error is not None:
+ popen.side_effect = spawn_error
+ else:
+ popen.return_value = process
+ result = WindowsProcessRunner(priority_class=64).run(argv, cwd, env, stdout=stdout)
+ return result, popen
+
+ def test_run_assigns_the_job_and_captures_output(self) -> None:
+ job = _Job()
+ process = _Process(output="hello", code=0)
+ result, popen = self.run_with(job=job, process=process, env={"K": "V"})
+ self.assertEqual((result.args, result.returncode, result.stdout),
+ (["tool.exe", "a"], 0, "hello"))
+ self.assertEqual(job.assigned, [11])
+ self.assertEqual(process.resumed, 1)
+ self.assertEqual(job.closed, 1)
+ self.assertIs(popen.call_args.kwargs["stdout"], subprocess.PIPE)
+ self.assertEqual(popen.call_args.kwargs["env"]["K"], "V")
+ self.assertEqual(popen.call_args.kwargs["env"]["PATH"], os.environ["PATH"])
+
+ def test_run_returns_a_nonzero_exit_code(self) -> None:
+ result, _ = self.run_with(job=_Job(), process=_Process(output="", code=3))
+ self.assertEqual(result.returncode, 3)
+
+ def test_a_supplied_stdout_stream_is_forwarded(self) -> None:
+ stream = mock.MagicMock()
+ result, popen = self.run_with(job=_Job(), process=_Process(output=None), stdout=stream)
+ self.assertIs(popen.call_args.kwargs["stdout"], stream)
+ self.assertIsNone(popen.call_args.kwargs["stderr"])
+ self.assertIsNone(result.stdout)
+
+ def test_environment_defaults_to_the_process_environment(self) -> None:
+ _, popen = self.run_with(job=_Job(), process=_Process(), env=None)
+ self.assertEqual(popen.call_args.kwargs["env"], dict(os.environ))
+
+ def test_priority_class_is_passed_to_the_job(self) -> None:
+ with mock.patch.object(windows_process, "WindowsJob") as factory, \
+ mock.patch.object(windows_process.psutil, "Popen", return_value=_Process()):
+ WindowsProcessRunner(priority_class=128).run(["tool.exe"])
+ factory.assert_called_once_with(priority_class=128)
+
+ def test_an_assignment_failure_closes_the_job_and_kills_the_process(self) -> None:
+ job = _Job(assign_error=RuntimeError("assign"))
+ process = _Process()
+ with self.assertRaisesRegex(RuntimeError, "assign"):
+ self.run_with(job=job, process=process)
+ self.assertEqual(job.closed, 1)
+ self.assertEqual(process.killed, 1)
+
+ def test_a_resume_failure_stops_the_owned_tree_without_a_kill(self) -> None:
+ job = _Job()
+ process = _Process(resume_error=RuntimeError("resume"))
+ with self.assertRaisesRegex(RuntimeError, "resume"):
+ self.run_with(job=job, process=process)
+ self.assertEqual(job.closed, 1)
+ self.assertEqual(process.killed, 0)
+
+ def test_a_close_failure_falls_back_to_job_termination(self) -> None:
+ job = _Job(close_error=RuntimeError("close"))
+ process = _Process()
+ with self.assertRaisesRegex(RuntimeError, "close"):
+ self.run_with(job=job, process=process)
+ self.assertEqual(job.terminated, 1)
+ self.assertEqual(process.killed, 0)
+
+ def test_a_close_and_terminate_failure_hard_kills_the_process(self) -> None:
+ job = _Job(close_error=RuntimeError("close"), terminate_error=RuntimeError("terminate"))
+ process = _Process()
+ with self.assertRaisesRegex(RuntimeError, "close") as raised:
+ self.run_with(job=job, process=process)
+ self.assertEqual(process.killed, 1)
+ self.assertTrue(any("terminate" in note for note in raised.exception.__notes__))
+
+ def test_an_assign_and_close_failure_notes_the_cleanup_error(self) -> None:
+ job = _Job(assign_error=RuntimeError("assign"), close_error=RuntimeError("close"))
+ with self.assertRaisesRegex(RuntimeError, "assign") as raised:
+ self.run_with(job=job, process=_Process())
+ self.assertTrue(any("close" in note for note in raised.exception.__notes__))
+
+ def test_a_spawn_failure_still_closes_the_job(self) -> None:
+ job = _Job()
+ with self.assertRaisesRegex(OSError, "cannot spawn"):
+ self.run_with(job=job, process=None, spawn_error=OSError("cannot spawn"))
+ self.assertEqual(job.closed, 1)
+
+
+class RealProcessTests(unittest.TestCase):
+ def test_the_runner_executes_a_real_child(self) -> None:
+ result = WindowsProcessRunner().run(
+ [sys.executable, "-c", "print('observer-probe')"],
+ )
+ self.assertEqual(result.returncode, 0)
+ self.assertIn("observer-probe", result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py
new file mode 100644
index 0000000..219a388
--- /dev/null
+++ b/build/tests/test_workflow_contract.py
@@ -0,0 +1,607 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import textwrap
+import tomllib
+import unittest
+
+
+REPOSITORY = Path(__file__).parents[2]
+PYTHON = sys.executable
+POWERSHELL = shutil.which("pwsh") or shutil.which("powershell")
+
+
+def run_block(workflow: str, step_name: str) -> str:
+ """Return the dedented ``run: |`` body of one named step, for a real execution fixture."""
+
+ lines = workflow.splitlines()
+ marker = f"- name: {step_name}"
+ for index, line in enumerate(lines):
+ if line.strip() == marker:
+ break
+ else:
+ raise AssertionError(f"workflow step not found: {step_name}")
+ for run in range(index + 1, len(lines)):
+ head = lines[run]
+ if head.strip() == "run: |":
+ break
+ else:
+ raise AssertionError(f"workflow step has no run block: {step_name}")
+ base = len(head) - len(head.lstrip(" "))
+ body: list[str] = []
+ for line in lines[run + 1:]:
+ if line.strip() == "":
+ body.append("")
+ continue
+ indent = len(line) - len(line.lstrip(" "))
+ if indent <= base:
+ break
+ body.append(line[base + 2:])
+ return "\n".join(body) + "\n"
+
+
+def write_fake_tools(bindirectory: Path) -> None:
+ """Install fake ``gh`` and ``uv`` shims so a workflow snippet can run with no network."""
+
+ (bindirectory / "fake_gh.py").write_text(
+ textwrap.dedent(
+ """
+ import json, os, sys
+
+ argv = sys.argv[1:]
+ with open(os.environ["FAKE_GH_LOG"], "a", encoding="utf-8") as handle:
+ handle.write(json.dumps(argv) + "\\n")
+ joined = " ".join(argv)
+ if "releases/tags/" in joined:
+ sys.stdout.write(os.environ.get("FAKE_GH_PROBE_OUT", ""))
+ sys.stderr.write(os.environ.get("FAKE_GH_PROBE_ERR", ""))
+ raise SystemExit(int(os.environ.get("FAKE_GH_PROBE_EXIT", "0")))
+ if "releases?" in joined:
+ sys.stdout.write(os.environ.get("FAKE_GH_LIST_OUT", ""))
+ raise SystemExit(int(os.environ.get("FAKE_GH_LIST_EXIT", "0")))
+ if argv[:2] == ["release", "download"]:
+ code = int(os.environ.get("FAKE_GH_DOWNLOAD_EXIT", "0"))
+ files = json.loads(os.environ.get("FAKE_GH_DOWNLOAD_FILES", "{}"))
+ if code == 0 and "--pattern" in argv:
+ pattern = argv[argv.index("--pattern") + 1]
+ if pattern in files:
+ directory = argv[argv.index("--dir") + 1]
+ os.makedirs(directory, exist_ok=True)
+ with open(os.path.join(directory, pattern), "w", encoding="utf-8") as handle:
+ handle.write(files[pattern])
+ raise SystemExit(code)
+ raise SystemExit(0)
+ """
+ ).lstrip(),
+ encoding="utf-8",
+ )
+ (bindirectory / "fake_uv.py").write_text(
+ textwrap.dedent(
+ """
+ import os, sys
+
+ argv = sys.argv[1:]
+ directory = argv[argv.index("--directory") + 1] if "--directory" in argv else "."
+ rest = argv[argv.index("core.release") + 1:]
+ os.chdir(os.path.abspath(directory))
+ sys.path.insert(0, os.getcwd())
+ from core.release import main
+
+ raise SystemExit(main(rest))
+ """
+ ).lstrip(),
+ encoding="utf-8",
+ )
+ (bindirectory / "gh.cmd").write_text(
+ f'@echo off\r\n"{PYTHON}" "%~dp0fake_gh.py" %*\r\n', encoding="utf-8"
+ )
+ (bindirectory / "uv.cmd").write_text(
+ f'@echo off\r\n"{PYTHON}" "%~dp0fake_uv.py" %*\r\n', encoding="utf-8"
+ )
+
+
+class WorkflowContractTests(unittest.TestCase):
+ def workflow(self) -> str:
+ return (REPOSITORY / ".github/workflows/main.yml").read_text(encoding="utf-8")
+
+ def test_main_ci_verifies_sources_and_architectures_through_the_public_entry_point(self) -> None:
+ workflow = self.workflow()
+
+ 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", "arm64")],
+ )
+
+ # The full doctor inventory demands every tool (including BinSkim and UMDH) and so cannot
+ # run on the source or arm64 rows; each verify operation must discover what it needs.
+ self.assertNotIn("./build.ps1 doctor", workflow)
+ self.assertEqual(workflow.count("./build.ps1 verify-source"), 1)
+ self.assertEqual(workflow.count("./build.ps1 verify-arch"), 1)
+ # The local default de-prioritises the build for an interactive desktop; a hosted
+ # runner has none to protect and must ask for the normal priority class explicitly.
+ self.assertEqual(workflow.count("-Priority normal"), 2)
+ self.assertNotIn("-PruneCas", workflow)
+ self.assertNotIn("continue-on-error", workflow)
+
+ # The content-addressed out/cas cache is gone; only the vcpkg binary cache remains.
+ self.assertNotIn("path: out/cas", workflow)
+ self.assertNotIn("actions/cache/restore@", workflow)
+ self.assertNotIn("actions/cache/save@", workflow)
+ self.assertNotIn("id: restore-build-cas", workflow)
+ self.assertNotIn("cas-v1-", workflow)
+ self.assertIn("Cache vcpkg binaries", workflow)
+ self.assertIn("uses: actions/cache@", workflow)
+
+ # Every quality gate stays behind the host-routing verify-arch command, never a
+ # duplicated per-operation workflow step.
+ 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)
+
+ 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.assertIn("if-no-files-found: error", workflow)
+ self.assertNotIn("if-no-files-found: ignore", workflow)
+ # GitHub's upload-artifact silently drops hidden files (including the coverage
+ # data file reports/**/.coverage) unless the step opts in. Bind that opt-in to the
+ # evidence upload step itself, so every declared report and log reaches the artifact.
+ evidence_upload = next(
+ block
+ for block in workflow.split("- name: ")
+ if block.startswith("Upload verification evidence")
+ )
+ self.assertIn("name: evidence-${{ matrix.job }}", evidence_upload)
+ self.assertIn("include-hidden-files: true", evidence_upload)
+ self.assertIn(
+ "if: success() && github.event_name == 'push' && matrix.job != 'source'", workflow
+ )
+ self.assertIn("/packages", workflow)
+ self.assertIn("permissions:\n contents: read", 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/upload-artifact": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a",
+ "actions/download-artifact": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c",
+ },
+ )
+ 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)
+ # Both CI jobs pin uv 0.13.0, a release verified to provision Python 3.14.7.
+ self.assertEqual(workflow.count('version: "0.13.0"'), 2)
+ pyproject = tomllib.loads(
+ (REPOSITORY / "build/pyproject.toml").read_text(encoding="utf-8")
+ )
+ self.assertEqual(pyproject["project"]["requires-python"], "==3.14.7")
+ 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)
+
+ # The source row installs only the source-check tools; the binary tools belong to the
+ # architecture rows, and the debugging tools are provisioned on x64 alone.
+ self.assertIn("choco install cppcheck --version=2.19.0", workflow)
+ self.assertIn("C:\\Program Files\\Cppcheck", workflow)
+ self.assertIn("Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0", workflow)
+ self.assertEqual(workflow.count("nuget install Microsoft.CodeAnalysis.BinSkim"), 1)
+ self.assertIn("if: matrix.job != 'source'", 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("OptionId.WindowsDesktopDebuggers", 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',", 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)
+
+ self.assertNotIn("upload-sarif", workflow)
+ self.assertNotIn("codeql-action", workflow)
+
+ def test_a_module_tag_release_promotes_verified_packages_behind_the_version_gate(self) -> None:
+ workflow = self.workflow()
+
+ # A module release is a manually pushed tag that names exactly one module lineage.
+ self.assertIn("tags: ['renpy/v*', 'rpgmaker/v*', 'zanzarah/v*']", workflow)
+ self.assertIn(" release:\n", workflow)
+ self.assertIn("needs: verification", workflow)
+ self.assertIn("if: startsWith(github.ref, 'refs/tags/renpy/v')", workflow)
+ self.assertEqual(workflow.count("contents: write"), 1)
+ self.assertEqual(workflow.count("uses: actions/download-artifact@"), 1)
+
+ # The tag and the version gate are public build-tool commands, never workflow logic.
+ self.assertEqual(workflow.count("python -m core.release tag"), 1)
+ self.assertEqual(workflow.count("python -m core.release gate"), 1)
+ self.assertIn("uv run --directory build --frozen --no-sync", workflow)
+ self.assertIn("--module", workflow)
+ self.assertIn("--tag", workflow)
+ self.assertIn("--assets", workflow)
+ self.assertIn("--previous", workflow)
+ self.assertIn("packages.json", workflow)
+ self.assertIn("release.json", workflow)
+ self.assertIn("notes.md", workflow)
+ # The published body is the gate's human notes, never auto-generated commit notes.
+ self.assertIn("--notes-file", workflow)
+ self.assertNotIn("--generate-notes", workflow)
+
+ # `uv run --directory build` changes the working directory, so a relative `--repository .`
+ # would validate build/src; every release command must pass an absolute workspace path.
+ self.assertEqual(workflow.count('--repository "$env:GITHUB_WORKSPACE"'), 2)
+ self.assertNotIn("--repository .", workflow)
+
+ # A fresh publication of the same module must serialize before the lineage lookup, the
+ # gate and the publish even when another module's release runs concurrently. A job-level
+ # concurrency group cannot read step outputs, so it keys on the module in github.ref_name
+ # and never cancels an already-running publication.
+ self.assertEqual(workflow.count("cancel-in-progress: false"), 1)
+ self.assertIn("group: release-${{ startsWith(github.ref_name, 'renpy/')", workflow)
+ self.assertIn("startsWith(github.ref_name, 'rpgmaker/')", workflow)
+ self.assertIn("|| 'zanzarah' }}", workflow)
+
+ # The previous release is looked up by the same module lineage, never global latest, and
+ # the search must page through every release instead of silently resetting older history.
+ self.assertIn("python -m core.release previous", workflow)
+ self.assertIn("gh api --paginate", workflow)
+ self.assertNotIn("limit 200", workflow)
+ # A rerun never reconstructs the lineage from the mutable history: it downloads the
+ # immutable release.json published with the tag and reuses the predecessor recorded there,
+ # while a new publication still lists and filters the published stable history.
+ self.assertIn("--current-report", workflow)
+ self.assertIn("--current-exists --current-report", workflow)
+ self.assertIn("--pattern 'release.json'", workflow)
+ self.assertIn("releases.jsonl", workflow)
+ # Draft and prerelease entries are emitted and filtered by the lineage helper, so a
+ # published stable lineage is never disturbed by an unpublished draft.
+ self.assertIn(".draft", workflow)
+ self.assertIn(".prerelease", workflow)
+ self.assertIn("gh release download", workflow)
+ self.assertIn("gh release create", workflow)
+ self.assertIn("steps.tag.outputs.module", workflow)
+ self.assertIn("$env:GITHUB_REF_NAME", workflow)
+ self.assertIn("GH_TOKEN: ${{ github.token }}", workflow)
+ self.assertEqual(workflow.count("GH_TOKEN: ${{ github.token }}"), 2)
+ # Unknown listing/download failures must fail instead of looking like an absent release.
+ self.assertIn("Unable to list", workflow)
+ # The current tag's existence is queried exactly once, before lineage resolution; only a
+ # confirmed 404 means it is absent, and the publish step reuses that recorded result.
+ self.assertEqual(workflow.count("releases/tags/$tag"), 1)
+ self.assertIn("HTTP 404", workflow)
+ self.assertIn("steps.previous.outputs.exists", workflow)
+ # Idempotent reruns compare published bytes instead of blindly replacing assets.
+ self.assertIn("Get-FileHash", workflow)
+ self.assertNotIn("VERSION", workflow)
+
+
+def release_record(tag: str, *, draft: bool = False, prerelease: bool = False) -> str:
+ return json.dumps({"tag": tag, "draft": draft, "prerelease": prerelease})
+
+
+@unittest.skipUnless(POWERSHELL is not None, "PowerShell is required for the workflow fixture")
+class WorkflowPreviousStepFixtureTests(unittest.TestCase):
+ """Execute the real previous-release step with fake ``gh``/``uv`` and no network."""
+
+ STEP_NAME = "Resolve the previous release of the same module"
+
+ def setUp(self) -> None:
+ self.workflow = (REPOSITORY / ".github/workflows/main.yml").read_text(encoding="utf-8")
+ self._temporary = tempfile.TemporaryDirectory()
+ self.root = Path(self._temporary.name)
+ self.bindirectory = self.root / "bin"
+ self.bindirectory.mkdir()
+ write_fake_tools(self.bindirectory)
+ self.step = self.root / "previous-step.ps1"
+ self.step.write_text(run_block(self.workflow, self.STEP_NAME), encoding="utf-8")
+ self.log = self.root / "gh.log"
+ self.output = self.root / "github-output.txt"
+ self.output.write_text("", encoding="utf-8")
+ (self.root / "runner").mkdir()
+
+ def tearDown(self) -> None:
+ self._temporary.cleanup()
+
+ def run_previous_step(
+ self,
+ *,
+ probe_exit: int,
+ probe_out: str = "",
+ probe_err: str = "",
+ list_out: str = "",
+ list_exit: int = 0,
+ download_exit: int = 0,
+ download_files: "dict[str, str] | None" = None,
+ tag: str = "renpy/v3.1.0",
+ ) -> "subprocess.CompletedProcess[str]":
+ env = dict(os.environ)
+ env["PATH"] = str(self.bindirectory) + os.pathsep + env["PATH"]
+ env["FAKE_GH_LOG"] = str(self.log)
+ env["FAKE_GH_PROBE_EXIT"] = str(probe_exit)
+ env["FAKE_GH_PROBE_OUT"] = probe_out
+ env["FAKE_GH_PROBE_ERR"] = probe_err
+ env["FAKE_GH_LIST_OUT"] = list_out
+ env["FAKE_GH_LIST_EXIT"] = str(list_exit)
+ env["FAKE_GH_DOWNLOAD_EXIT"] = str(download_exit)
+ env["FAKE_GH_DOWNLOAD_FILES"] = json.dumps(download_files or {})
+ env["GITHUB_REF_NAME"] = tag
+ env["GITHUB_OUTPUT"] = str(self.output)
+ env["RUNNER_TEMP"] = str(self.root / "runner")
+ env["GITHUB_WORKSPACE"] = str(REPOSITORY)
+ return subprocess.run(
+ [POWERSHELL, "-NoProfile", "-NonInteractive", "-File", str(self.step)],
+ cwd=str(REPOSITORY),
+ capture_output=True,
+ text=True,
+ env=env,
+ timeout=180,
+ )
+
+ def gh_calls(self) -> list[list[str]]:
+ if not self.log.is_file():
+ return []
+ return [
+ json.loads(line)
+ for line in self.log.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+
+ def downloads(self) -> list[str]:
+ return [call[2] for call in self.gh_calls() if call[:2] == ["release", "download"]]
+
+ def patterns(self) -> list[str]:
+ return [
+ call[call.index("--pattern") + 1]
+ for call in self.gh_calls()
+ if call[:2] == ["release", "download"] and "--pattern" in call
+ ]
+
+ def list_calls(self) -> list[list[str]]:
+ return [call for call in self.gh_calls() if "releases?" in " ".join(call)]
+
+ def outputs(self) -> dict[str, str]:
+ values: dict[str, str] = {}
+ for line in self.output.read_text(encoding="utf-8").splitlines():
+ key, _, value = line.partition("=")
+ values[key] = value
+ return values
+
+ @staticmethod
+ def report(previous: str | None) -> str:
+ return json.dumps(
+ {
+ "bootstrap": previous is None,
+ "modules": [
+ {
+ "content": "a" * 64,
+ "module": "renpy",
+ "previous_version": previous,
+ "status": "bootstrap" if previous is None else "released",
+ "version": "3.1.0",
+ }
+ ],
+ "note": "",
+ "title": "renpy 3.1.0",
+ }
+ )
+
+ def test_a_new_release_lists_the_history_and_downloads_the_published_head(self) -> None:
+ listing = (
+ "\n".join(
+ [
+ release_record("renpy/v3.2.0"),
+ release_record("renpy/v3.3.0", draft=True),
+ release_record("renpy/v3.1.0"),
+ release_record("renpy/v3.0.0"),
+ ]
+ )
+ + "\n"
+ )
+ result = self.run_previous_step(
+ probe_exit=1,
+ probe_out='{\n "message": "Not Found",\n "status": "404"\n}\ngh: Not Found (HTTP 404)\n',
+ list_out=listing,
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ # A new publication resolves the latest published stable head and downloads its manifest;
+ # the immutable report is only ever fetched for a rerun.
+ self.assertEqual(self.downloads(), ["renpy/v3.2.0"])
+ self.assertEqual(self.patterns(), ["packages.json"])
+ self.assertTrue(self.list_calls())
+ # The bare resolved tag is handed to gh; a field wrapper never leaks into the argument.
+ arguments = " ".join(item for call in self.gh_calls() for item in call)
+ self.assertNotIn("previous=", arguments)
+ outputs = self.outputs()
+ self.assertEqual(outputs["exists"], "false")
+ self.assertTrue(outputs["manifest"].endswith("packages.json"))
+
+ def test_a_rerun_downloads_the_immutable_report_and_its_recorded_predecessor(self) -> None:
+ # The history would resolve a different predecessor; the recorded report must win and the
+ # history must not even be listed.
+ result = self.run_previous_step(
+ probe_exit=0,
+ probe_out="renpy/v3.1.0\n",
+ list_out=release_record("renpy/v3.2.0") + "\n",
+ download_files={"release.json": self.report("3.0.5"), "packages.json": "{}\n"},
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(self.downloads(), ["renpy/v3.1.0", "renpy/v3.0.5"])
+ self.assertEqual(self.patterns(), ["release.json", "packages.json"])
+ self.assertEqual(self.list_calls(), [])
+ self.assertEqual(self.outputs()["exists"], "true")
+
+ def test_a_bootstrap_rerun_records_no_predecessor_and_downloads_nothing_else(self) -> None:
+ result = self.run_previous_step(
+ probe_exit=0,
+ probe_out="renpy/v3.1.0\n",
+ download_files={"release.json": self.report(None)},
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(self.downloads(), ["renpy/v3.1.0"])
+ outputs = self.outputs()
+ self.assertEqual(outputs["exists"], "true")
+ self.assertEqual(outputs["manifest"], "")
+
+ def test_a_first_release_without_history_downloads_nothing(self) -> None:
+ result = self.run_previous_step(
+ probe_exit=1, probe_out="gh: Not Found (HTTP 404)\n", list_out=""
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(self.downloads(), [])
+ outputs = self.outputs()
+ self.assertEqual(outputs["exists"], "false")
+ self.assertEqual(outputs["manifest"], "")
+
+ def test_a_confirmed_404_with_a_json_body_is_absence(self) -> None:
+ result = self.run_previous_step(
+ probe_exit=1,
+ probe_out='{"message": "Not Found"}\ngh: Not Found (HTTP 404)\n',
+ list_out="",
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(self.downloads(), [])
+
+ def test_a_missing_published_report_is_fatal(self) -> None:
+ result = self.run_previous_step(probe_exit=0, probe_out="renpy/v3.1.0\n")
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("Unable to resolve the previous release", result.stderr)
+ self.assertEqual(self.downloads(), ["renpy/v3.1.0"])
+
+ def test_a_failed_report_download_is_fatal(self) -> None:
+ result = self.run_previous_step(
+ probe_exit=0, probe_out="renpy/v3.1.0\n", download_exit=1
+ )
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("readable release.json", result.stderr)
+ self.assertEqual(self.downloads(), ["renpy/v3.1.0"])
+
+ def test_a_failed_predecessor_manifest_download_is_fatal(self) -> None:
+ result = self.run_previous_step(
+ probe_exit=1,
+ probe_out="gh: Not Found (HTTP 404)\n",
+ list_out=release_record("renpy/v3.0.0") + "\n",
+ download_exit=1,
+ )
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("readable packages.json", result.stderr)
+ self.assertEqual(self.downloads(), ["renpy/v3.0.0"])
+
+ def test_an_unknown_probe_failure_is_fatal(self) -> None:
+ result = self.run_previous_step(
+ probe_exit=1, probe_out="gh: Server Error (HTTP 500)\n", list_out=""
+ )
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("Unable to determine", result.stderr)
+ self.assertEqual(self.downloads(), [])
+
+ def test_a_listing_failure_is_fatal(self) -> None:
+ result = self.run_previous_step(
+ probe_exit=1, probe_out="gh: Not Found (HTTP 404)\n", list_exit=1
+ )
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("Unable to list", result.stderr)
+ self.assertEqual(self.downloads(), [])
+
+
+class RepositoryMetadataTests(unittest.TestCase):
+ """The repository's own manifests, readme and human ChangeLogs stay consistent."""
+
+ MODULES = ("renpy", "rpgmaker", "zanzarah")
+
+ def test_manifest_license_matches_the_gpl_project_and_readme(self) -> None:
+ manifest = json.loads((REPOSITORY / "vcpkg.json").read_text(encoding="utf-8"))
+ readme = (REPOSITORY / "README.md").read_text(encoding="utf-8")
+
+ # The manifest declares the project's own GPLv3 grant, not the Observer dependency's LGPL.
+ self.assertEqual(manifest["license"], "GPL-3.0-only")
+ self.assertIn("License-GPLv3", readme)
+ # The zanzapak notice is GPL-2.0-or-later; the readme must not call it GPL-3.0.
+ self.assertIn("[GPL-2.0-or-later](licenses/zanzapak.txt)", readme)
+ self.assertNotIn("[GPL-3.0](licenses/zanzapak.txt)", readme)
+
+ def test_every_module_owns_a_change_log_matching_its_declared_version(self) -> None:
+ for module in self.MODULES:
+ version = (
+ REPOSITORY / "src" / "modules" / module / "VERSION"
+ ).read_text(encoding="utf-8").strip()
+ with self.subTest(module=module):
+ changelog = (
+ REPOSITORY / "src" / "modules" / module / "ChangeLog"
+ ).read_text(encoding="utf-8")
+ lines = changelog.splitlines()
+ self.assertEqual(lines[0], f"Version {version}")
+ self.assertTrue(set(lines[1]) == {"-"})
+ self.assertTrue(any(line.lstrip().startswith(("*", "+")) for line in lines[2:]))
+ self.assertIn(
+ f"{module}/ChangeLog", (REPOSITORY / "README.md").read_text(encoding="utf-8")
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/build/uv.lock b/build/uv.lock
new file mode 100644
index 0000000..d781c7e
--- /dev/null
+++ b/build/uv.lock
@@ -0,0 +1,102 @@
+version = 1
+revision = 5
+requires-python = "==3.14.7"
+
+[[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 = "observer-build"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "coverage" },
+ { name = "filelock" },
+ { 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 = "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..faf3b22
--- /dev/null
+++ b/docs/build-system.md
@@ -0,0 +1,150 @@
+# Build system
+
+## What this is
+
+A console-first, Windows-only build over MSBuild and pinned manifest-mode vcpkg. There is no graph, cache, scheduler,
+Jinja renderer or typed-result protocol: `build/main.py` is the argument contract, `build/native.py` drives vcpkg and
+MSBuild directly, and the remaining families (`build/source_checks.py`, `build/diagnostics.py`,
+`build/packaging_ops.py`) are plain functions that shell out to the retained `core` validators. MSBuild owns project
+evaluation, dependencies, scheduling and incremental object state under the stable `out/native` tree; vcpkg owns
+dependency resolution.
+
+Philosophy: one operation runs one real tool through one explicit output directory, and the tool's exit code (or a
+`core` validator's gate) is the verdict. A missing prerequisite fails closed; a check is never weakened to make a run
+pass. Whatever this document does not state is derivable from the code, and the code wins.
+
+Release contract, non-negotiable: Windows-only native C++23 through MSVC `cl.exe`; x86, x64, ARM64; MSVC runtime and all
+third-party libraries linked statically (`/MT`, static vcpkg triplets); a release archive carries no redistributable
+runtime and no non-system DLL; CMake is not part of this tree (vcpkg ports may use it internally); no `/clr`. Each
+shipped module (`renpy`, `rpgmaker`, `zanzarah`) owns an independent semantic version in `src/modules//VERSION`
+and its own `renpy/vX.Y.Z` release lineage; there is no repository release version.
+
+## Map
+
+- `build.ps1` / `build.cmd` — entry point: pinned `uv` environment, rejoins array-valued arguments with commas so an
+ unquoted `x86,x64` survives PowerShell parsing, forwards argv. `OBSERVER_BUILD_PRINT_ARGV=1` prints argv, exits 97.
+- `build/main.py` — the argument and command contract and the plain operation dispatch: subcommands, options, three
+ architecture rejections, the desktop-priority class, and the keep-going verification runner.
+- `build/native.py` — vcpkg restore and MSBuild build/test against the stable `out/native` tree; MSBuild is the only
+ scheduler.
+- `build/source_checks.py` — clang-format, Cppcheck, PSScriptAnalyzer, the `*.Tests.ps1` contracts, MSVC `/analyze` and
+ clang-tidy, normalized and gated through `core.sarif`.
+- `build/diagnostics.py` — LLVM coverage, ASan/UBSan, libFuzzer and UMDH leaks, gated through `core.cpp_coverage`,
+ `core.sanitizer` and `core.leak`.
+- `build/packaging_ops.py` — dumpbin/BinSkim/PE-version audit, deterministic staging and ZIP creation, smoke tests, and
+ the release manifest, over the stable `out/native/bin//Release` binaries.
+- `build/core/` — the retained validators and tool locators: `cpp_coverage`, `sanitizer`, `sarif`, `binary_audit`,
+ `package`, `module_version`, `release`, `changelog`, `leak`, `host`, `toolchain`, `quality_tools`, `source_tools`, `doctor`,
+ `python_coverage`, the direct `clean` and `result_export` helpers, and the small `windows_process` job-owned runner
+ (`windows_job` holds the kill-on-close limit).
+- `build/projects/*.vcxproj` + `build/*.props` are MSBuild's half; `.github/workflows/main.yml` is a thin client.
+
+## Invariants
+
+- **MSBuild owns incremental state.** Each operation asks MSBuild for one project or one `Observer.proj`, with an
+ explicit `ArtifactsRoot`, and lets it decide what to rebuild. Python owns no object cache and no dependency graph.
+- **Explicit output directories.** The native tree is `out/native`; every other operation writes under an explicit
+ directory the caller owns (`out/reports/`, or the `-ExportDir` destination). A `core` validator that still
+ confines itself to an output root is reached with a scoped `OBSERVER_OUT_DIR`/`OBSERVER_BUILD_DIR`.
+- **100% lines and branches, in both gates**: first-party C++ under the Coverage configuration, and the build system's
+ own python (`build/pyproject.toml`, `fail_under = 100`, `branch = true`). The only python escape hatch is a literal
+ trailing `# pragma: no cover`; `exclude_also` is empty and `tests/test_coverage_config.py` drives coverage.py's parser
+ to require every excluded line to carry it, a `__main__` guard body being the sole permitted widening.
+- **Keep-going.** The verification runner executes independent operations, collects every failure and blocks only a
+ dependent operation (packaging follows a failed Release build, test or audit), then exits nonzero.
+- **Publishing is atomic and fresh.** `core/result_export.py` copies the current reports, failure logs and flat
+ `packages/` into a new destination; it refuses an existing destination, a reparse hop, and any missing claimed file,
+ and never publishes stale previous success or package evidence on a failure.
+- **Local cleanup only.** `core/clean.py` removes exactly `out/native`, `out/reports` and `out/packages`, refusing a
+ reparse point anywhere in an owned tree, and leaves `out/sol-team` and any unrelated directory untouched.
+- **Desktop priority and descendant control.** `-Priority below-normal` (the default) sets the process priority class
+ and the kill-on-close job limit, so every child compiler, linker and MSBuild node inherits it and dies with the job
+ even on cancellation; a hosted runner has no desktop to protect and asks for `-Priority normal`.
+- **A commit changing any input of a module's content identity must bump that module's VERSION in the same commit**,
+ enforced at release time by `core/release.py`, not by review. `content_identity` raises on an input named `VERSION`
+ rather than skipping it: content identity answers whether shipped bytes changed, which a version literal cannot.
+
+## Deliberate decisions that look like bugs
+
+- `Fuzz|Win32` compiles with `/clang:-mllvm /clang:-asan-stack-dynamic-alloca=0` (`ObserverFuzz.props:24`). Never
+ substitute `-asan-stack=0`: it clears the same spurious i386 frame report but gives up real overflow detection.
+- Coverage loads the three shipped `.so` files as `llvm-cov --object` with `tests.exe` as the profile executable, so the
+ gate measures the DLLs FAR Manager actually loads, not the test binary.
+- Coverage is `/MTd`, not `/MT` (`ObserverProject.props:59-60`). Leak checking is an operation over `Release|x64`, not a
+ seventh configuration.
+- ARM64 coverage, fuzzing and ASan are refused rather than composed: no toolset ships an ARM64 profile runtime, and the
+ cross runtime for the module DLLs is explicitly deferred instead of approximated.
+- Only the timed x64 fuzz run consumes a wall-clock budget; x86 fuzzing is a replay-only gate over the same targets and
+ is the only place 32-bit `size_t` overflow thresholds execute at all.
+- Each leak scenario runs a preflight, a capture-and-judge measurement and a gate, because window diffs can only run in
+ the session that captured them while a failing scenario must still export evidence. The gate fails on a repeated
+ allocation stack above `-LeakToleranceBytes` across consecutive windows or on sustained total growth beyond twice it
+ (`core/leak.py:301-304`), never on a literal zero-byte difference.
+- Archive bytes, metadata, paths, counts and offsets are untrusted input: the format parsers live in a platform-neutral
+ core with no Observer or Win32 dependency, impose explicit resource and iteration bounds, and validate before use.
+
+## Running things
+
+```powershell
+.\build.ps1 build -Arch x86,x64,arm64 -Config Release
+.\build.ps1 verify-arch -Arch x64 -ExportDir C:\evidence
+.\build.ps1 verify-source # python tests + 100% gate, clang-format, Cppcheck, PSScriptAnalyzer, contracts
+.\build.ps1 fuzz -Arch x86,x64 -FuzzTarget all -FuzzSeconds 60
+.\build.ps1 clean -CleanMode reports
+```
+
+- Subcommands and every option live in `main.py`; `-Arch`/`-Config`/`-Module` take comma lists or `all`. Exactly three
+ architecture rejections: `test-leaks` demands `x64` alone, `fuzz` only x86/x64, `verify-arch` exactly one.
+- `-ExportDir` is accepted by `package`, `verify-source`, `verify-arch` and `verify`, and exports on success and failure
+ alike once the operations have run. The destination must be new.
+- `verify_route` (`core/host.py`) intersects the request with host capability, then routes coverage x64, UBSan x64, ASan
+ x86+x64, fuzz x86+x64; x64 specialists, leaks included, follow UBSan. Unroutable gates are reported deferred and the
+ remaining independent gates still run.
+- `verify-source` is flat: the source-format/PSSA/contract checks plus the python gate. `verify-arch` adds compiler
+ analysis, the Debug and Release DLL suites, the routed diagnostics, the binary audit and packaging; `verify` combines
+ both across the requested architectures.
+- The python gate runs, from `build/`: `coverage run --rcfile pyproject.toml -m unittest discover -s tests
+ -p test_*.py`, then `coverage report --fail-under=100` (`core/python_coverage.py`).
+- Release gate over the manifests `verify-arch` produced: `python -m core.release gate --module --tag
+ --repository --assets --output [--previous ] ...`, or the plain
+ all-module form without `--module`. It fails on changed content without a bump, a bump without changed content and a
+ backwards version; with no previous manifest it bootstraps explicitly.
+- The tag's version must also match the module's top `src/modules//ChangeLog` entry, and a module release's
+ published notes are that human entry (`core.changelog.latest_notes`) rather than a generated commit list. The
+ selective module archive also carries the raw `ChangeLog` at its root beside the module, registration ini, project
+ `license.txt` and the dependency notices for that module.
+
+## CI
+
+Four verification jobs — `source` (`verify-source`) and `x86`/`x64`/`arm64` (`verify-arch -Arch …`) — plus a `release`
+job on `renpy/vX.Y.Z`, `rpgmaker/vX.Y.Z` or `zanzarah/vX.Y.Z` tags. Triggers: `pull_request` on `master`, `push` on
+`master` and the module tag prefixes; no `workflow_dispatch`, no schedule, no ARM64-native runner, and ARM64 runtime
+tests are reported deferred rather than executed. Every mandatory check stays runnable from a local console through the
+same public entry points. The `source` row installs only the source-check tools, the architecture rows provision BinSkim
+by operation, and the x64 row pins UMDH from the Windows 10 SDK 2004 line (a documented capture defect in Windows 11
+SDKs); only the native vcpkg binary cache remains. Python owns the version, content and asset validation policy
+(`src/modules//VERSION` and `core.release`); the workflow owns only the GitHub-specific orchestration over those
+public entry points — uploading the verification evidence, probing the module's existing tag/asset lineage, running the
+release gate and publishing the verified assets. Pull requests use the same gates with shorter bounded-work parameters.
+
+## Open gaps
+
+Each is a missing gate or a false promise, none an accepted deviation. Closed items live in git history; TDD applies:
+the failing test that states the observable requirement comes first.
+
+**№1 — the memory-budget stress gate does not exist.** Nothing in `build/` or `src/` measures peak private bytes or
+working set; the only memory-shaped control is libFuzzer's own `-rss_limit_mb=1024`. The UMDH gate compares allocation
+growth across windows (`core/leak.py:301-304`), a different property — a parser buffering a whole entry and releasing it
+every round passes it. Needed: a gate failing when peak private bytes scale with archive or entry size instead of the
+streaming buffer, finishing deterministically against repository-owned fixtures.
+
+**№6 — analyzer versions are neither pinned nor provisioned consistently.** `doctor` enforces only the python version
+(`core/doctor.py:32-38`) and formats the remaining probe strings without comparing them. The workflow pins Cppcheck,
+PSScriptAnalyzer, BinSkim and the x64 UMDH MSI by version or SHA-256, but never LLVM: `clang-cl`, `clang-tidy`,
+`clang-format`, `llvm-cov` come from the hosted image's VS install (`core/toolchain.py`), and the runtime locator only
+checks presence. Pin the versions `doctor` accepts and provision LLVM explicitly.
+
+**ARM64 runtime deferral.** The ARM64 module DLLs, packages and PDBs are built, audited and gated for PE/BinSkim/version,
+but their runtime tests, coverage, fuzz and leak gates are reported deferred because the hosted verification host and no
+local x64 host can execute them. Publishing an ARM64 archive without an executed runtime gate stays an explicit,
+reported deferral rather than a silent pass.
diff --git a/docs/critical-software-methodology.md b/docs/critical-software-methodology.md
new file mode 100644
index 0000000..edfd72c
--- /dev/null
+++ b/docs/critical-software-methodology.md
@@ -0,0 +1,167 @@
+# 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. The build system's own Python holds the same gate with one narrow, audited escape hatch —
+ an explicit per-line `# pragma: no cover` for internal-invariant guards no public surface can reach; see the pragma
+ policy in `docs/build-system.md` (`Dependencies, toolchain, and static-analysis policy`). 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/ChangeLog b/src/modules/renpy/ChangeLog
new file mode 100644
index 0000000..b9d3f2a
--- /dev/null
+++ b/src/modules/renpy/ChangeLog
@@ -0,0 +1,4 @@
+Version 3.1.0
+-------------
+ * Ship a self-contained MSVC module for x86, x64 and ARM64 with no third-party runtime DLLs.
+ + Publish versioned per-architecture packages that carry the module, its Observer registration ini, the license files and this ChangeLog.
diff --git a/src/modules/renpy/VERSION b/src/modules/renpy/VERSION
new file mode 100644
index 0000000..fd2a018
--- /dev/null
+++ b/src/modules/renpy/VERSION
@@ -0,0 +1 @@
+3.1.0
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..5edc401 100644
--- a/src/modules/renpy/renpy.cpp
+++ b/src/modules/renpy/renpy.cpp
@@ -1,10 +1,14 @@
+#include "../../core/compression/zlib_codec.h"
+#include "../../core/io/bounded_stream.h"
#include "../extractor.h"
#include "pickle.h"
#include
#include
+#include
-#include
+// build/ObserverModuleVersion.props injects the version macros from src/modules/renpy/VERSION,
+// the single source of truth. A project that does not import that file fails to compile here.
namespace extractor
{
@@ -12,11 +16,12 @@ namespace extractor
{
return {
{0x9486718f, 0x8f0a, 0x4de7, {0x98, 0x80, 0x01, 0x14, 0x6b, 0x33, 0x6d, 0x6b}},
- 3, 0,
+ OBSERVER_MODULE_VERSION_MAJOR,
+ OBSERVER_MODULE_VERSION_MINOR,
};
}
- std::vector extractor::get_signature() noexcept
+ std::vector extractor::get_signature()
{
const std::string str = "RPA-";
std::vector signature(str.size());
@@ -24,18 +29,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 +51,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 +137,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/ChangeLog b/src/modules/rpgmaker/ChangeLog
new file mode 100644
index 0000000..dbe9f98
--- /dev/null
+++ b/src/modules/rpgmaker/ChangeLog
@@ -0,0 +1,4 @@
+Version 1.1.0
+-------------
+ * Ship a self-contained MSVC module for x86, x64 and ARM64 with no third-party runtime DLLs.
+ + Publish versioned per-architecture packages that carry the module, its Observer registration ini, the license files and this ChangeLog.
diff --git a/src/modules/rpgmaker/VERSION b/src/modules/rpgmaker/VERSION
new file mode 100644
index 0000000..9084fa2
--- /dev/null
+++ b/src/modules/rpgmaker/VERSION
@@ -0,0 +1 @@
+1.1.0
diff --git a/src/modules/rpgmaker/rpgmaker.cpp b/src/modules/rpgmaker/rpgmaker.cpp
index af6baf0..2b4cc45 100644
--- a/src/modules/rpgmaker/rpgmaker.cpp
+++ b/src/modules/rpgmaker/rpgmaker.cpp
@@ -1,18 +1,24 @@
+#include "../../core/archive_limits.h"
+#include "../../core/io/bounded_stream.h"
#include "../extractor.h"
#include
+// build/ObserverModuleVersion.props injects the version macros from src/modules/rpgmaker/VERSION,
+// the single source of truth. A project that does not import that file fails to compile here.
+
namespace extractor
{
version_info get_version_info() noexcept
{
return {
{0xc4674077, 0x464a, 0x425b, {0x89, 0x80, 0x9e, 0x14, 0xe8, 0x16, 0x49, 0x00}},
- 1, 0,
+ OBSERVER_MODULE_VERSION_MAJOR,
+ OBSERVER_MODULE_VERSION_MINOR,
};
}
- std::vector extractor::get_signature() noexcept
+ std::vector extractor::get_signature()
{
const std::string str = "RGSSAD";
std::vector signature(str.size());
@@ -22,40 +28,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)
- {
- 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)
+ 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(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 = input.read_trivial() ^ magic;
+ const uint32_t file_magic = input.read_trivial() ^ magic;
+ const uint32_t name_len = input.read_trivial() ^ magic;
- 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 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 +84,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 +106,4 @@ namespace extractor
return magic;
}
-}
+} // namespace extractor
diff --git a/src/modules/version.rc b/src/modules/version.rc
new file mode 100644
index 0000000..b3f17ae
--- /dev/null
+++ b/src/modules/version.rc
@@ -0,0 +1,50 @@
+// Shared VERSIONINFO resource for every Observer extractor module.
+//
+// build/ObserverModuleVersion.props injects the macros below from src/modules//VERSION,
+// which is the single source of truth for that module's release version. The package gate reads
+// the fixed version information back out of the linked binary, so a module that fails to carry
+// this resource, or carries a stale one, cannot be packaged.
+
+#include
+
+#if !defined(OBSERVER_MODULE_NAME) || !defined(OBSERVER_MODULE_FILE) || !defined(OBSERVER_MODULE_VERSION)
+#error "Observer module identity macros must be supplied from the module VERSION file"
+#endif
+
+#if !defined(OBSERVER_MODULE_VERSION_MAJOR) || !defined(OBSERVER_MODULE_VERSION_MINOR) || !defined(OBSERVER_MODULE_VERSION_PATCH)
+#error "Observer module version macros must be supplied from the module VERSION file"
+#endif
+
+// Every stringized macro is a single whitespace-free token sequence, so no adjacent string
+// literal concatenation is required inside the resource script.
+#define OBSERVER_STRINGIZE_IMPL(value) #value
+#define OBSERVER_STRINGIZE(value) OBSERVER_STRINGIZE_IMPL(value)
+
+VS_VERSION_INFO VERSIONINFO
+FILEVERSION OBSERVER_MODULE_VERSION_MAJOR, OBSERVER_MODULE_VERSION_MINOR, OBSERVER_MODULE_VERSION_PATCH, 0
+PRODUCTVERSION OBSERVER_MODULE_VERSION_MAJOR, OBSERVER_MODULE_VERSION_MINOR, OBSERVER_MODULE_VERSION_PATCH, 0
+FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
+FILEFLAGS 0x0L
+FILEOS VOS_NT_WINDOWS32
+FILETYPE VFT_DLL
+FILESUBTYPE VFT2_UNKNOWN
+BEGIN
+ BLOCK "StringFileInfo"
+ BEGIN
+ BLOCK "040904B0"
+ BEGIN
+ VALUE "CompanyName", "Refaim"
+ VALUE "FileDescription", "Observer extractor module"
+ VALUE "FileVersion", OBSERVER_STRINGIZE(OBSERVER_MODULE_VERSION)
+ VALUE "InternalName", OBSERVER_STRINGIZE(OBSERVER_MODULE_NAME)
+ VALUE "LegalCopyright", "Licensed under the GNU General Public License v3"
+ VALUE "OriginalFilename", OBSERVER_STRINGIZE(OBSERVER_MODULE_FILE)
+ VALUE "ProductName", "Refaim's Observer Modules"
+ VALUE "ProductVersion", OBSERVER_STRINGIZE(OBSERVER_MODULE_VERSION)
+ END
+ END
+ BLOCK "VarFileInfo"
+ BEGIN
+ VALUE "Translation", 0x409, 1200
+ END
+END
diff --git a/src/modules/zanzarah/ChangeLog b/src/modules/zanzarah/ChangeLog
new file mode 100644
index 0000000..89ddb1a
--- /dev/null
+++ b/src/modules/zanzarah/ChangeLog
@@ -0,0 +1,4 @@
+Version 2.1.0
+-------------
+ * Ship a self-contained MSVC module for x86, x64 and ARM64 with no third-party runtime DLLs.
+ + Publish versioned per-architecture packages that carry the module, its Observer registration ini, the license files and this ChangeLog.
diff --git a/src/modules/zanzarah/VERSION b/src/modules/zanzarah/VERSION
new file mode 100644
index 0000000..7ec1d6d
--- /dev/null
+++ b/src/modules/zanzarah/VERSION
@@ -0,0 +1 @@
+2.1.0
diff --git a/src/modules/zanzarah/zanzarah.cpp b/src/modules/zanzarah/zanzarah.cpp
index e4cb88f..9ad1d76 100644
--- a/src/modules/zanzarah/zanzarah.cpp
+++ b/src/modules/zanzarah/zanzarah.cpp
@@ -1,38 +1,39 @@
+#include "../../core/archive_limits.h"
+#include "../../core/io/bounded_stream.h"
#include "../extractor.h"
#include
#include
+// build/ObserverModuleVersion.props injects the version macros from src/modules/zanzarah/VERSION,
+// the single source of truth. A project that does not import that file fails to compile here.
+
namespace extractor
{
version_info get_version_info() noexcept
{
return {
{0x86e7e4c3, 0xbc44, 0x4e8e, {0x90, 0xaf, 0xbd, 0xbd, 0x1c, 0xb6, 0x1a, 0x83}},
- 2, 0,
+ OBSERVER_MODULE_VERSION_MAJOR,
+ OBSERVER_MODULE_VERSION_MINOR,
};
}
- 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