merge: retain main CI planning and artifact checks

This commit is contained in:
64579525 2026-10-13 19:37:50 +08:00
commit de2ab343f1
136 changed files with 34298 additions and 798 deletions

View File

@ -119,6 +119,8 @@ loaded bytes, file mappings, relocations and the entry against the immutable
snapshot. Keep `mismatch` and `unsupported` explicit. Verified import covers the
reported static image, not runtime DOS or PC-98 hardware behavior.
For Ghidra decompilation, `procedure_pseudo_code` also accepts `procedure: "file-offset:0x..."`; CLI `rea decompile <path> --file-offset=0x... --provider ghidra` resolves the source byte offset internally. An unresolved 0x-prefixed numeric procedure selector also falls back to a unique file mapping. Existing symbols and analysis addresses win; multiple mappings fail as ambiguous. Preserve the returned file-offset and address locations and raw selection observations. ELF virtual addresses are distinct from file offsets; never assume an arbitrary offset plus image base is an analysis address.
Use `read_bytes` for initialized analysis memory and `address_to_file_offset` to
anchor one observed address to original file bytes. Loader fixups can change a
word; a source offset does not imply byte equality. Complete function body ranges

View File

@ -1,14 +1,24 @@
name: Portable build cache
description: Reuse Turbo build outputs without serializing verification jobs.
description: Restore portable Turbo build outputs from the current run or cross-run cache.
inputs:
save:
description: Save newly built outputs after the job succeeds.
default: "false"
build_cache_artifact:
description: Reuse portable Turbo outputs uploaded by the current workflow run.
default: ""
runs:
using: composite
steps:
- name: Download portable build outputs from this workflow run
if: inputs.build_cache_artifact != ''
uses: actions/download-artifact@v4
with:
name: ${{ inputs.build_cache_artifact }}
path: .turbo/cache/
- name: Resolve portable build task hashes
id: plan
if: inputs.build_cache_artifact == ''
shell: bash
env:
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }}
@ -28,14 +38,14 @@ runs:
console.log(`prefix=${process.env.BUILD_CACHE_PREFIX}-`);
' >> "$GITHUB_OUTPUT"
- name: Restore portable build outputs
if: inputs.save != 'true'
if: inputs.build_cache_artifact == '' && inputs.save != 'true'
uses: actions/cache/restore@v4
with:
path: .turbo/cache/
key: ${{ steps.plan.outputs.key }}
restore-keys: ${{ steps.plan.outputs.prefix }}
- name: Restore and save portable build outputs
if: inputs.save == 'true'
if: inputs.build_cache_artifact == '' && inputs.save == 'true'
uses: actions/cache@v4
with:
path: .turbo/cache/

18
.github/workflows/ci-full-pr.yml vendored Normal file
View File

@ -0,0 +1,18 @@
name: Full PR qualification
on:
pull_request:
branches: [main, "release/*"]
types: [labeled]
permissions:
contents: read
actions: read
jobs:
qualification:
name: Qualify labeled PR revision
if: github.event.label.name == 'ci:full'
uses: ./.github/workflows/ci.yml
with:
source_sha: ${{ github.sha }}

46
.github/workflows/ci-timings.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: CI timings
on:
schedule:
- cron: "17 * * * *"
workflow_dispatch:
concurrency:
group: ci-timings-${{ github.repository }}
cancel-in-progress: false
permissions:
contents: read
actions: read
jobs:
report:
name: Collect recent completed workflow attempts
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Consume only API data and execute batch code from the trusted default branch.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Record queue, execution, and matched step timing
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
run: node scripts/ci/job-timings-batch.mjs
- name: Upload CI timing artifacts
if: always() && hashFiles('.cache/ci-job-timings/*') != ''
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: ci-job-timings-batch
path: .cache/ci-job-timings/
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 14

View File

@ -5,42 +5,44 @@ on:
branches: [main]
pull_request:
branches: [main, "release/*"]
schedule:
- cron: "23 0-2,4-23 * * *"
- cron: "23 3 * * *"
workflow_dispatch:
workflow_call:
inputs:
source_sha:
description: Exact qualification source; must match the caller revision.
required: true
type: string
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
# The latest main push verifies the full tree, including earlier changes.
# Publication uses its own release workflow and concurrency group.
cancel-in-progress: true
# Complete qualification finishes even while main receives more pushes.
# Main feedback also finishes, coalescing newer pushes to one pending run.
# Scheduled/manual main runs coalesce to one active and the latest pending;
# release and label-triggered qualification use exact-revision groups.
group: ci-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'qualification' || 'feedback' }}-${{ inputs.source_sha || github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}
env:
HUSKY: "0"
jobs:
pr-title:
name: Conventional PR title
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Validate title for Release Please
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: >-
node -e 'const title = process.env.PR_TITLE ?? "";
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
if (!conventional.test(title)) {
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
process.exit(1);
}'
changes:
name: Classify changes
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
tier: ${{ steps.plan.outputs.tier }}
qualification_run_id: ${{ steps.qualification.outputs.run_id }}
code: ${{ steps.plan.outputs.code }}
sqlite: ${{ steps.plan.outputs.sqlite }}
runtime: ${{ steps.plan.outputs.runtime }}
package: ${{ steps.plan.outputs.package }}
apple: ${{ steps.plan.outputs.apple }}
@ -59,26 +61,58 @@ jobs:
nativeaot: ${{ steps.plan.outputs.nativeaot }}
website: ${{ steps.plan.outputs.website }}
matrix: ${{ steps.plan.outputs.matrix }}
test_selection: ${{ steps.plan.outputs.test_selection }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.source_sha || github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Bind qualification to the caller revision
if: inputs.source_sha != ''
env:
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
test "${SOURCE_SHA}" = "${GITHUB_SHA}"
test "${SOURCE_SHA}" = "$(git rev-parse HEAD)"
- name: Find a verified ancestor for push selection
id: baseline
if: github.event_name == 'push' && inputs.source_sha == ''
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
CI_REPOSITORY: ${{ github.repository }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node scripts/ci/baseline.mjs
- name: Admit scheduled full qualification
id: qualification
if: github.event_name == 'schedule' && inputs.source_sha == ''
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
CI_REPOSITORY: ${{ github.repository }}
CI_REPOSITORY_ID: ${{ github.repository_id }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
CI_FORCE_FULL: ${{ github.event.schedule == '23 3 * * *' }}
run: node scripts/ci/qualification.mjs
- name: Select verification lanes
id: plan
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
CI_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }}
BASE_SHA: ${{ steps.baseline.outputs.base || github.event.pull_request.base.sha }}
BASELINE_REASON: ${{ steps.baseline.outputs.reason }}
HEAD_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }}
CI_FULL: ${{ inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full') }}
CI_SKIP_FULL: ${{ steps.qualification.outputs.run_full == 'false' }}
run: node scripts/ci/plan.mjs
static:
name: Static checks
needs: changes
if: ${{ !cancelled() }}
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Require successful change classification
env:
@ -90,6 +124,9 @@ jobs:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Validate workflow syntax and expressions
if: needs.changes.outputs.workflows == 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
- if: needs.changes.outputs.code == 'true'
run: npm run check:ci
- if: needs.changes.outputs.code == 'true'
@ -114,6 +151,7 @@ jobs:
needs: [changes, build]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@ -129,7 +167,14 @@ jobs:
with:
name: rea-test-runtime
path: .
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
- name: Run complete portable suite with coverage
if: needs.changes.outputs.tier == 'full'
run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
env:
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- name: Run complete portable suite for fast feedback
if: needs.changes.outputs.tier == 'fast'
run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
env:
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- uses: actions/upload-artifact@v4
@ -145,8 +190,20 @@ jobs:
build:
name: Build package
needs: changes
if: needs.changes.outputs.code == 'true'
# Reusable provider workflows consume this run's portable build artifact,
# even when a workflow-only edit selects the lane without portable tests.
if: >-
needs.changes.outputs.code == 'true' ||
needs.changes.outputs.browser == 'true' ||
needs.changes.outputs.binary_layout == 'true' ||
needs.changes.outputs.evm == 'true' ||
needs.changes.outputs.javascript_recovery == 'true' ||
needs.changes.outputs.recorded_crash == 'true' ||
needs.changes.outputs.web_network == 'true' ||
needs.changes.outputs.web_runtime == 'true' ||
needs.changes.outputs.web_source_map == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
actions: read
@ -160,21 +217,17 @@ jobs:
- name: Reuse portable build outputs across runs
uses: ./.github/actions/portable-build-cache
with:
save: "true"
save: ${{ github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
- run: npm run test:prepare
- name: Find successful default-branch timing history
- name: Find available trusted full-suite timing history
id: history
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
CI_REPOSITORY_ID: ${{ github.repository_id }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
run_id="$(gh api --method GET \
"repos/${CI_REPOSITORY}/actions/workflows/ci.yml/runs" \
-f branch="${CI_DEFAULT_BRANCH}" -f event=push -f status=success -f per_page=1 \
--jq '.workflow_runs[0].id // empty')"
echo "run-id=${run_id}" >> "${GITHUB_OUTPUT}"
run: node scripts/ci/test-history.mjs
- name: Download previous timing report
if: steps.history.outputs.run-id != ''
continue-on-error: true
@ -210,15 +263,18 @@ jobs:
retention-days: 7
sqlite-runtime:
needs: [changes, build]
if: needs.changes.outputs.code == 'true'
# Preserve all selected coverage, but admit expensive host/provider work
# only after static validation. Portable tests still provide early feedback.
needs: [changes, static, build]
if: needs.changes.outputs.sqlite == 'true'
uses: ./.github/workflows/sqlite-runtime.yml
test:
name: Test (coverage + timing)
name: Portable tests
needs: [changes, test-shard]
if: ${{ !cancelled() }}
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Require successful change classification
env:
@ -241,7 +297,7 @@ jobs:
merge-multiple: true
- name: Merge coverage, JUnit, and timing reports
id: merge
if: needs.changes.outputs.code == 'true' && success()
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success()
run: >-
npm run test:ci:merge --
--coverage.reportsDirectory=coverage
@ -251,13 +307,28 @@ jobs:
--reporter=default
--reporter=junit
--reporter=json
--reporter=./scripts/ci/test-timing-reporter.mjs
--outputFile.junit=test-results.xml
--outputFile.json=test-results.json
- name: Merge fast-suite JUnit and timing reports
id: merge-fast
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'fast' && success()
run: >-
npm exec -- vitest --merge-reports
--reporter=default
--reporter=junit
--reporter=json
--reporter=./scripts/ci/test-timing-reporter.mjs
--outputFile.junit=test-results.xml
--outputFile.json=test-results.json
- name: Summarize slowest test files
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-timings.json') != ''
env:
SHARD_RESULT: ${{ needs.test-shard.result }}
MERGE_RESULT: ${{ steps.merge.outcome }}
MERGE_RESULT: ${{ needs.changes.outputs.tier == 'full' && steps.merge.outcome || steps.merge-fast.outcome }}
CI_VERIFY_TEST_INVENTORY: "true"
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/ci/test-report.mjs
- uses: actions/upload-artifact@v4
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
@ -266,11 +337,14 @@ jobs:
path: |
test-results.xml
test-results.json
test-timings.json
coverage/
.cache/ci-test-selection/report.json
include-hidden-files: true
if-no-files-found: error
retention-days: 30
- name: Publish successful timing history
if: needs.changes.outputs.code == 'true' && success() && needs.test-shard.result == 'success'
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
uses: actions/upload-artifact@v4
with:
name: test-timings
@ -287,7 +361,7 @@ jobs:
conformance-fixtures:
name: Source-built conformance fixtures
needs: changes
needs: [changes, static]
if: needs.changes.outputs.fixtures == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
@ -298,7 +372,7 @@ jobs:
windows-native-build:
name: Build bundled Windows x64 native controls
needs: changes
needs: [changes, static]
if: needs.changes.outputs.runtime == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
@ -334,7 +408,7 @@ jobs:
package-e2e:
name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }})
needs: [changes, build, windows-native-build]
needs: [changes, static, build, windows-native-build]
if: needs.changes.outputs.runtime == 'true'
strategy:
fail-fast: false
@ -373,6 +447,8 @@ jobs:
name: windows-native-controls
path: native/windows/build/
- run: npm run verify:windows-native:artifact
- name: Verify NativeAOT diagnostic applicability on this host
run: npm run test:focused -- tests/evaluation/package/nativeAotDiagnostics.test.ts
- name: Resolve npm cache before verifier HOME isolation
id: npm-cache
run: |
@ -381,13 +457,24 @@ jobs:
- run: npm run verify:package
env:
npm_config_cache: ${{ steps.npm-cache.outputs.path }}
REA_VERIFIED_PACKAGE_DIRECTORY: ${{ needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64' && '.cache/verified-package' || '' }}
- name: Verify real Node Inspector discovery and observation
timeout-minutes: 10
run: npm run verify:inspector
- name: Retain the exact qualified release package
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
uses: actions/upload-artifact@v4
with:
name: verified-release-package
path: .cache/verified-package/
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 7
apple-artifacts:
name: Real Apple artifact CLI/MCP E2E
needs: [changes, build]
needs: [changes, static, build]
if: needs.changes.outputs.apple == 'true'
runs-on: macos-15
timeout-minutes: 15
@ -442,7 +529,7 @@ jobs:
reconstruction-readiness:
name: Reconstruction readiness
needs: changes
needs: [changes, static]
if: needs.changes.outputs.readiness == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
@ -471,7 +558,7 @@ jobs:
windows-curated:
name: Windows capability and package lane
needs: [changes, build, windows-native-build]
needs: [changes, static, build, windows-native-build]
if: needs.changes.outputs.package == 'true'
runs-on: windows-latest
timeout-minutes: 25
@ -583,6 +670,7 @@ jobs:
needs: changes
if: needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -602,73 +690,78 @@ jobs:
skills/reverse-engineer-anything
if-no-files-found: error
workflows:
name: Validate workflows
needs: changes
if: needs.changes.outputs.workflows == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate workflow syntax and expressions
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
browser:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.browser == 'true'
uses: ./.github/workflows/real-browser.yml
with:
build_cache_artifact: rea-build-cache
binary_layout:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.binary_layout == 'true'
uses: ./.github/workflows/real-binary-layout.yml
with:
build_cache_artifact: rea-build-cache
evm:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.evm == 'true'
uses: ./.github/workflows/real-evm-interface.yml
with:
build_cache_artifact: rea-build-cache
javascript_recovery:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.javascript_recovery == 'true'
uses: ./.github/workflows/real-javascript-recovery.yml
with:
build_cache_artifact: rea-build-cache
recorded_crash:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.recorded_crash == 'true'
uses: ./.github/workflows/real-recorded-crash.yml
with:
build_cache_artifact: rea-build-cache
web_network:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.web_network == 'true'
uses: ./.github/workflows/real-web-network-captures.yml
with:
build_cache_artifact: rea-build-cache
web_runtime:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.web_runtime == 'true'
uses: ./.github/workflows/real-web-runtime.yml
with:
build_cache_artifact: rea-build-cache
web_source_map:
needs: changes
needs: [changes, static, build]
if: needs.changes.outputs.web_source_map == 'true'
uses: ./.github/workflows/real-web-source-map.yml
with:
build_cache_artifact: rea-build-cache
nativeaot:
needs: changes
needs: [changes, static]
if: needs.changes.outputs.nativeaot == 'true'
uses: ./.github/workflows/nativeaot-fixtures.yml
website:
needs: changes
needs: [changes, static]
if: needs.changes.outputs.website == 'true'
uses: ./.github/workflows/website-check.yml
required:
name: CI required
if: ${{ !cancelled() }}
if: ${{ always() }}
needs:
[
changes,
pr-title,
static,
build,
test-shard,
@ -681,7 +774,6 @@ jobs:
reconstruction-readiness,
windows-curated,
docs,
workflows,
browser,
binary_layout,
evm,
@ -694,12 +786,24 @@ jobs:
website,
]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Require every selected lane to pass
id: gate
env:
CI_JOBS: ${{ toJSON(needs) }}
CI_QUALIFICATION_DIRECTORY: .cache/ci-qualification
run: node scripts/ci/required.mjs
- name: Upload full qualification receipt artifact
if: needs.changes.outputs.tier == 'full' && success()
uses: actions/upload-artifact@v4
with:
name: ${{ steps.gate.outputs.receipt_name }}
path: ${{ steps.gate.outputs.receipt_path }}
if-no-files-found: error
overwrite: true
retention-days: 7

View File

@ -12,7 +12,8 @@ jobs:
name: Build Windows x64 NativeAOT fixture
# .NET 8.0.22's bundled linker discovery supports VS 2019/2022.
runs-on: windows-2022
timeout-minutes: 15
# Two bounded restore attempts and one compile can each take five minutes.
timeout-minutes: 20
env:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_PROCESSOR_COUNT: "1"

29
.github/workflows/pr-title.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: PR title
on:
pull_request:
branches: [main, "release/*"]
types: [opened, synchronize, reopened, edited]
permissions: {}
concurrency:
group: pr-title-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
pr-title:
name: Conventional PR title
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Validate title for Release Please
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: >-
node -e 'const title = process.env.PR_TITLE ?? "";
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
if (!conventional.test(title)) {
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
process.exit(1);
}'

View File

@ -8,14 +8,16 @@ on:
type: boolean
default: false
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions:
contents: read
concurrency:
group: real-binary-layout-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
NODE_OPTIONS: --max-old-space-size=768
HUSKY: "0"
@ -33,6 +35,8 @@ jobs:
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Acquire exact upstream libraries in an isolated runner environment
shell: bash
run: |

View File

@ -2,12 +2,14 @@ name: Real browser verification
on:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch:
concurrency:
group: real-browser-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
HUSKY: "0"
@ -26,6 +28,8 @@ jobs:
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- run: npm run verify:browser
env:
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome

View File

@ -3,14 +3,16 @@ name: Real offline EVM interface
on:
workflow_dispatch:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions:
contents: read
concurrency:
group: real-evm-interface-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
NODE_OPTIONS: --max-old-space-size=768
HUSKY: "0"
@ -27,6 +29,8 @@ jobs:
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Acquire unchanged fixture compiler in isolated runner storage
shell: bash
run: |

View File

@ -2,15 +2,17 @@ name: Real JavaScript recovery
on:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch:
permissions:
contents: read
concurrency:
group: javascript-recovery-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
HUSKY: "0"
NODE_OPTIONS: "--max-old-space-size=768"
@ -28,6 +30,8 @@ jobs:
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Fetch pinned Wakaru release into the job workspace
shell: bash
run: |

View File

@ -3,14 +3,16 @@ name: Real recorded crash evidence
on:
workflow_dispatch:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions:
contents: read
concurrency:
group: real-recorded-crash-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
NODE_OPTIONS: --max-old-space-size=768
HUSKY: "0"
@ -30,6 +32,8 @@ jobs:
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Install GDB on the disposable CI runner
shell: bash
run: |

View File

@ -3,14 +3,16 @@ name: Real historical web network captures
on:
workflow_dispatch:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions:
contents: read
concurrency:
group: real-web-network-captures-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
HUSKY: "0"
NODE_OPTIONS: --max-old-space-size=768
@ -29,6 +31,8 @@ jobs:
run: npm install --global npm@11.16.0
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Acquire fixed upstream native profile in runner temporary storage
shell: bash
run: |

View File

@ -1,12 +1,15 @@
name: Real website runtime attribution
on:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch:
permissions:
contents: read
concurrency:
group: real-web-runtime-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
HUSKY: "0"
jobs:
@ -21,6 +24,8 @@ jobs:
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- run: npm run verify:browser:runtime
env:
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome

View File

@ -1,12 +1,15 @@
name: Real website source-map verification
on:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
workflow_dispatch:
permissions:
contents: read
concurrency:
group: real-web-source-map-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
HUSKY: "0"
jobs:
@ -21,6 +24,8 @@ jobs:
cache: npm
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- name: Install the pinned compiler into an isolated fixture directory
run: npm install --prefix "$RUNNER_TEMP/rea-web-source-map-compiler" --no-audit --no-fund --package-lock=false esbuild@0.25.10
- run: npm run verify:browser:source-maps

View File

@ -290,13 +290,25 @@ jobs:
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
run: npm run docs:check && git diff --exit-code
publish:
name: Publish to npm
qualification:
name: Qualify exact release revision
needs: [merged-release, release-please]
if: >-
always() && !cancelled() &&
((needs.merged-release.result == 'success' && needs.merged-release.outputs.release_created == 'true') ||
(needs.release-please.result == 'success' && inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true'))
permissions:
contents: read
actions: read
uses: ./.github/workflows/ci.yml
with:
source_sha: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
publish:
name: Publish to npm
needs: [merged-release, release-please, qualification]
if: >-
always() && !cancelled() && needs.qualification.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
@ -315,17 +327,20 @@ jobs:
- run: npm ci
- name: Prepare Windows native build toolchain
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
- name: Download the exact qualified package
uses: actions/download-artifact@v4
with:
name: verified-release-package
path: .cache/verified-package/
- name: Build and verify bundled Windows native controls
run: npm run build:windows-native && npm run verify:windows-native:artifact
- name: Verify packaged release before publishing
run: npm run verify:package
- name: Verify qualified package provenance and digest
id: package
run: node scripts/ci/verified-package.mjs validate .cache/verified-package
- name: Publish
shell: bash
env:
VERIFIED_PACKAGE_TARBALL: ${{ steps.package.outputs.tarball }}
run: |
version="$(node --input-type=module -e 'import packageJson from "./package.json" with { type: "json" }; process.stdout.write(packageJson.version)')"
published_version="$(npm view "rea-agents@${version}" version --json 2>/dev/null || true)"
@ -333,7 +348,7 @@ jobs:
echo "rea-agents@${version} is already published; verifying the existing package."
else
tag="$(node scripts/release-npm-tag.mjs "${version}")"
npm publish --access public --tag "${tag}"
npm publish "${VERIFIED_PACKAGE_TARBALL}" --access public --tag "${tag}"
fi
- name: Verify published CLI from npm

View File

@ -7,10 +7,6 @@ on:
permissions:
contents: read
concurrency:
group: website-check-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
name: Website checks

1
.gitignore vendored
View File

@ -36,6 +36,7 @@ coverage/
.vitest/
.vitest-reports/
test-results.xml
test-timings.json
.turbo/
.cache/
docs/.vitepress/cache/

View File

@ -1,5 +1,5 @@
{
"threshold": 3.0,
"threshold": 3.5,
"reporters": ["consoleFull"],
"ignore": [
"node_modules/**",

View File

@ -106,6 +106,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64;
private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000;
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
private final Map<FileBytes, Boolean> targetSourceIdentity = new IdentityHashMap<>();
private String admittedTargetSha256;
private static final Set<String> DESCRIPTOR_KEYS = Set.of(
"transport",
"endpoint_path",
@ -207,6 +209,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
"Ghidra imported-byte digest does not match the admitted target"
);
}
admittedTargetSha256 = descriptor.targetSha256;
// GhidraScript wraps run() in a transaction. End it before serving so
// each edit owns a real outer transaction and can roll back immediately.
end(true);
@ -633,7 +636,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private JsonObject addressToFileOffset(JsonObject params) {
private JsonObject addressToFileOffset(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "address"));
requireDocument(params);
Address address = requireAddress(params, "address");
@ -643,14 +646,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
Long fileOffset = null;
int mappings = 0;
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
if (!source.contains(address) || source.getFileBytes().isEmpty()) continue;
if (!source.contains(address) || source.getFileBytes().isEmpty() ||
!isTargetSource(source.getFileBytes().get())) continue;
long offset = source.getFileBytesOffset(address);
if (offset < 0) continue;
fileOffset = offset;
mappings++;
}
if (mappings != 1)
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative source mappings: " + canonicalAddress(address));
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address));
JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(address));
result.addProperty("file_offset", fileOffset);
@ -673,14 +677,96 @@ public final class ReaGhidraBridge extends HeadlessScript {
private JsonElement procedurePseudocode(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "procedure"));
requireDocument(params);
Function function = resolveProcedure(requireString(params, "procedure"));
String value = decompiledText(decompile(function));
return functionObservation(
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
String selector = requireString(params, "procedure");
JsonObject selection = new JsonObject();
Function function = resolveDecompileProcedure(selector, selection);
try {
String value = decompiledText(decompile(function));
JsonObject result = functionObservation(
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
if (selection.size() > 0) result.add("selection", selection);
return result;
} catch (RequestFailure failure) {
if (selection.size() == 0) throw failure;
// Preserve completed resolution facts without changing the backend's
// failure code or suggesting that the successful mapping was invalid.
throw new RequestFailure(failure.code, "Selector " + selector +
" mapped to analysis address " + selection.get("mapped_address").getAsString() +
" and procedure " + canonicalAddress(function.getEntryPoint()) +
"; " + failure.getMessage());
}
}
private Function resolveDecompileProcedure(String selector, JsonObject selection) throws Exception {
String prefix = "file-offset:";
boolean explicitOffset = selector.startsWith(prefix);
if (!explicitOffset) {
try {
return resolveProcedure(selector);
} catch (RequestFailure failure) {
// Existing names/addresses win. Only an unresolved numeric input
// can fall back to source coordinates; ambiguity never falls back.
if (!failure.code.equals("not_found") || !selector.matches("0x[0-9a-fA-F]+"))
throw failure;
}
}
String coordinate = explicitOffset ? selector.substring(prefix.length()) : selector;
if (!coordinate.matches("0x[0-9a-fA-F]+"))
throw new RequestFailure("invalid_request", "File offsets require hexadecimal file-offset:0x... syntax");
BigInteger parsed = new BigInteger(coordinate.substring(2), 16);
if (parsed.compareTo(BigInteger.valueOf(9007199254740991L)) > 0)
throw new RequestFailure("invalid_request", "File offset exceeds the exact JSON integer range");
long offset = parsed.longValueExact();
monitor.checkCancelled();
// Ghidra's convenience lookup spans every FileBytes and uses coordinates
// local to each object. Only a complete, digest-matched target source
// establishes that the caller's offset belongs to the admitted file.
Set<Address> mappedAddresses = new java.util.LinkedHashSet<>();
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
monitor.checkCancelled();
FileBytes backing = source.getFileBytes().orElse(null);
if (backing == null || !isTargetSource(backing)) continue;
Address mapped = source.locateAddressForFileOffset(offset);
if (mapped != null) mappedAddresses.add(mapped);
}
}
List<Address> candidates = new ArrayList<>(mappedAddresses);
if (candidates.size() > 1)
throw new RequestFailure("ambiguous", "File offset " + coordinate +
" has multiple loaded mappings: " + candidates.stream().map(ReaGhidraBridge::canonicalAddress).toList() +
"; select an explicit analysis address");
if (candidates.isEmpty())
throw new RequestFailure("not_found", "File offset " + coordinate +
" has no loaded source mapping for the admitted target file; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
". Inspect mappings with rea inspect-native-load-image <path> --provider ghidra --json or MCP inspect_native_load_image.");
Address mapped = candidates.get(0);
Function function = functionAtOrContaining(mapped);
if (function == null)
throw new RequestFailure("not_found", "File offset " + coordinate +
" maps to analysis address " + canonicalAddress(mapped) +
", but no procedure starts at or contains that address in the current Ghidra analysis. " +
"Choose an observed procedure with CLI rea search <path> . --kind procedures --mode regex --provider ghidra --json or MCP list_procedures.");
selection.addProperty("file_offset", offset);
selection.addProperty("mapped_address", canonicalAddress(mapped));
selection.addProperty("procedure_address", canonicalAddress(function.getEntryPoint()));
return function;
}
private boolean isTargetSource(FileBytes source) throws Exception {
Boolean observed = targetSourceIdentity.get(source);
if (observed != null) return observed;
// Original FileBytes are immutable even when analysis memory is edited.
// Partial/auxiliary sources remain inspectable but cannot establish an
// original-target offset from their object-local coordinates alone.
boolean matched = source.getFileOffset() == 0 &&
constantTimeEquals(hashFileBytes(source, false), admittedTargetSha256);
targetSourceIdentity.put(source, matched);
return matched;
}
private JsonElement procedureAssembly(JsonObject params) throws Exception {
@ -1833,14 +1919,25 @@ public final class ReaGhidraBridge extends HeadlessScript {
Address address = explicitAddress ? parseReaAddress(value) : tryParseAddress(value);
if (address == null)
throw new RequestFailure("not_found", "Unknown Ghidra procedure name or address: " + value);
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
if (function == null && address.isMemoryAddress())
function = currentProgram.getFunctionManager().getFunctionContaining(address);
Function function = functionAtOrContaining(address);
if (function == null)
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value);
// Other procedure operations retain their analysis-address semantics.
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value +
"; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
". Choose an observed analysis address. CLI: rea search <path> . --kind procedures --mode regex --provider ghidra --json; " +
"MCP: list_procedures. Inspect the loaded base and source mappings with CLI rea analyze <path> --provider ghidra --json " +
"and rea inspect-native-load-image <path> --provider ghidra --json, or MCP binary_overview and inspect_native_load_image. " +
"Repeat the original target options. For decompile, select source coordinates explicitly with --file-offset=0x... or MCP procedure file-offset:0x....");
return function;
}
private Function functionAtOrContaining(Address address) {
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
return function == null && address.isMemoryAddress()
? currentProgram.getFunctionManager().getFunctionContaining(address)
: function;
}
private JsonObject nativeApiBoundary(
Function function,
DecompileResults decompilation

View File

@ -41,6 +41,80 @@ does not prove text is absent. Both list and search Evidence carry this
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
bytes and references.
## Loaded addresses and file offsets
`rea analyze <path> --provider ghidra --json` and MCP `binary_overview` report
the observed loaded `image_base` in `normalized_result`. They retain each
segment's reported `image_base` and `address_space`. A provider that does not
report a base, or whose segments disagree, omits the overview field, following
the existing contract on main. REA does not substitute zero or the first
segment's address. All reported bases and address spaces remain visible in the
complete segment inventory, including conflicting observations.
`function` and most MCP procedure selectors use procedure names or
provider analysis addresses. Ghidra `decompile` / MCP `procedure_pseudo_code`
also accept source-file byte offsets. Existing symbols and analysis addresses
take precedence; a `0x`-prefixed number that resolves to no procedure falls back
to its observed source mapping. Use `--file-offset=0x...` on the CLI or
`procedure: "file-offset:0x..."` through MCP to select file coordinates explicitly.
Multiple mapping candidates fail as ambiguous rather than selecting a bank or
address space. A successful conversion retains the file offset, mapped address
and procedure entry in raw Evidence and `locations`.
If an offset maps to loaded target bytes but no procedure starts at or contains
the mapped address, the failure preserves both the original file offset and
analysis address. This differs from an unmapped offset and does not recommend
retrying the same offset syntax.
If the function resolves but the backend fails or cancels decompilation, the
failure retains the selector, mapped address and procedure entry without
changing the backend failure code.
Both mapping directions require a complete original `FileBytes` source whose
SHA-256 matches the admitted target. Auxiliary files and partial source objects
remain visible in the load-image inventory but do not establish offsets in the
original target. Ghidra's object-local offsets are not silently treated as
original-file coordinates. Original-source identity is checked once per source
object per session; analysis-memory edits do not change the original bytes.
ELF virtual addresses and file offsets are
different coordinates. Ghidra can rebase a PIE ELF during import: in the
source-owned verification fixture, ELF entry `0x1013` becomes Ghidra procedure
`0x101013` with image base `0x100000`. That example is not a general conversion
rule.
Use `list_procedures` to select an observed procedure address. To relate file
bytes to loaded memory, call MCP `inspect_native_load_image` or:
```sh
rea search <path> . --kind procedures --mode regex --provider ghidra --json
rea inspect-native-load-image <path> --provider ghidra --json
rea decompile <path> <observed-procedure-address> --provider ghidra --json
rea decompile <path> --file-offset=0x1013 --provider ghidra --json
rea address-to-file-offset <path> <observed-procedure-address> --provider ghidra --json
```
The load-image result preserves source-file indexes, file offsets, address
spaces, and inclusive mapping ranges. Follow the mapping for the selected
source file and region; adding `image_base` to an arbitrary file offset is not
valid. For a linear byte-addressed, file-backed mapping, a file offset `F` in
`[mapping.file_offset, mapping.file_offset + mapping.length)` maps to
`mapping.start + (F - mapping.file_offset)` in the reported `address_space`.
If several mappings cover the same file offset, keep those candidates distinct
and select the procedure from the observed inventory. Uninitialized regions may
have no file backing. ELF mappings are observed
without independent format-specific load-image verification, as the result's
status and limitations state.
An unsuccessful numeric procedure lookup reports the current Ghidra image base
and gives the CLI commands above alongside the corresponding MCP operations.
Repeat any target-format, architecture, and entry-point options used to open the
target. Other procedure operations keep their analysis-address semantics;
file-offset fallback applies only to decompilation.
Overview workflow revision 3 binds this output to snapshot identity. Older
serialized overview Evidence remains readable; it is not replayed as a current
overview merely because its source artifact is unchanged.
## Admitted function-analysis semantics
| Concern | Ghidra contract |

View File

@ -6,7 +6,10 @@ checks to pass. Merging that PR into main starts publication automatically.
The Release workflow validates the merged release metadata and ancestry before
creating a tag. npm and MCP Registry publication use the exact reviewed merge
SHA returned by Release Please. Later main commits cannot change that source.
SHA returned by Release Please. Before either registry is published, the release
run calls full CI qualification at that exact revision, including coverage,
portable providers, and every native package host. Later main commits cannot
change or cancel that proof.
Ordinary main pushes and closed, unmerged PRs cannot publish packages. Check
both publication jobs and the published-package canary; a merged PR or GitHub
tag alone does not establish that publication finished. Retry failed jobs in
@ -196,8 +199,14 @@ both registry publishes.
See [npm's provenance implementation](https://github.com/npm/cli/blob/v11.16.0/workspaces/libnpmpublish/lib/provenance.js)
for the use of GitHub's workflow ref and commit SHA.
The workflow builds the bundled Windows controls and verifies the packaged
artifact before npm publication. It then verifies the published CLI, the
Full qualification builds the bundled Windows controls and verifies the packaged
artifact on its native hosts. The Linux x64 verifier retains the exact tarball
it installed and tested, after its workspace cleanup completes. The npm job
downloads that archive from the same workflow run and checks its source SHA,
run identity/attempt, package and Node versions, host identity, and SHA-256.
It publishes that archive without rebuilding or repacking it. Re-running failed
publisher jobs can reuse a prior successful attempt from the same run.
The workflow then verifies the published CLI, the
capability-scoped MCP catalog, and the isolated package update path before
publishing MCP Registry metadata.

View File

@ -263,17 +263,62 @@ CI owns coverage; aggregate and domain/contract thresholds are maintained in
Coverage does not replace named boundary or real-provider scenarios.
CI runs six native Vitest shards without retries. Each shard emits a blob
report; the merge job produces aggregate coverage plus JUnit and JSON timing
report; the merge job produces JUnit and JSON diagnostics, a module timing
report, plus aggregate coverage during full qualification. It retains available
reports even when a shard fails, uploads available diagnostics, and writes the
slowest files to the workflow summary. Partial reports are marked diagnostic
only. Failed, cancelled, or missing selected shards still fail the required gate,
and aggregate coverage thresholds remain enforced by the merge command.
Only successful complete merges publish the small `test-timings` artifact, with
and aggregate coverage thresholds remain enforced in full qualification.
Only successful complete full-tier merges publish the small `test-timings` artifact, with
checkout-relative paths; the build job downloads that artifact for the sequencer
instead of the coverage/JUnit bundle. Missing history falls back to native
instead of the coverage/JUnit bundle. Timing weights come from Vitest module
diagnostics and include tests and hooks, worker preparation, environment,
imports, and setup. Import timings already include transform waits, which are
not added again. The weights describe file costs under the measured worker and
coverage settings; their sum is not workflow elapsed time. The diagnostic
artifact retains the phase breakdown, while shard history keeps only each
file's total. Legacy test-body-only history and missing history fall back to native
sharding. Static checks, documentation, build, package verification, and
real-system lanes remain separate jobs so one kind of evidence cannot stand in
for another.
for another. Workflow syntax and expression validation runs inside the required
static-check job when workflow changes select it, avoiding a separate runner.
Native, provider, fixture, SQLite compatibility, and website lanes wait for
static checks to succeed before allocating their runners. This avoids spending
those runner slots on revisions that already fail static validation. Portable
test shards and documentation checks remain independent of static results, so
they can still provide diagnostic feedback. The required gate rejects skipped
selected lanes when static checks fail; passing revisions retain all selected
coverage. This ordering reduces peak demand on invalid revisions but can add
static-check waiting to otherwise passing runs; it is not a measured reduction
in end-to-end latency.
Core jobs have execution limits of five minutes for classification, ten minutes
for static checks, builds, docs, and report merging, and thirty minutes per
portable test shard. These limits leave headroom for cold setup and full coverage
while preventing a hung job from holding the active main run for hours. They do
not bound runner queue waiting. Timeouts still fail the required gate.
The separate `CI timings` workflow batches recent completed CI and Release runs
hourly or on manual dispatch. It uploads `ci-job-timings-batch` with raw API pages and
a normalized point-in-time report for each collected workflow attempt, plus a
batch manifest describing examined coverage, failures, and limits. Overlapping
lookback windows may collect an attempt again; identify records by run ID and
attempt. Each batch examines at most 100 completed runs per workflow created in
the preceding 24 hours, with a four-minute API collection budget. It collects the
latest attempt of each selected run and does not claim an exhaustive archive.
It runs the reporter from the trusted default branch with read-only permissions,
treating workflow runs as API data. It does not allocate a new reporter job for
every completed, cancelled, or skipped workflow. Reports separate queue
waiting (`created_at` to `started_at`) from completed execution and report
percentiles plus matched `npm ci` and artifact-step durations.
Queue excludes dependency waiting before job creation and does not establish
whether account concurrency or runner availability caused the wait. Queued jobs with
placeholder start times, skipped jobs, and missing or invalid timestamps remain
unknown. The reporter targets each selected run and attempt explicitly.
Summed job elapsed time is parallel work, not workflow wall time, CPU time,
memory usage, or billing. Step names identify samples, not complete transfer
costs. Its queue, execution, and failures are independent of the required gate
and publication. CPU time and peak process-family memory are not measured by this API.
The build job saves portable Turbo outputs across runs. Eight Linux real-system
lanes restore this cache without waiting for the build job. The shared action
@ -697,6 +742,44 @@ specifications, independent mappings, unchanged bytes, and decompilation of a
known return value. AArch64 remains covered by deterministic tests rather than
this real-provider lane. See [raw binary analysis](raw-binary.md).
`npm run verify:ghidra:address-context` runs on Linux with a caller-installed
Ghidra, full JDK, and an x86-64 ELF GCC toolchain (`CC` selects another compatible
compiler). It builds
`tests/conformance/c/pie-address-context.c` as a PIE ELF without executing it.
The compiled CLI and real stdio MCP workflow must expose the measured image
base, retain segment address spaces, agree on the overview, resolve explicit and
numeric file offsets, and reject unmapped analysis addresses with actionable
guidance. It checks the reverse `address-to-file-offset` mapping, file-coordinate
Evidence locations, and the suggested CLI search and load-image commands.
The verifier compares mapped bytes with the source file and checks source
identity, session preservation after failed lookup, owned runtime cleanup, and
verified empty process lineage. This lane does not establish Windows or macOS
real-provider coverage.
For source-identity boundaries that a normal single-file import cannot create,
`tests/conformance/ghidra/ReaFileOffsetSourceProbe.java` exercises the production
bridge against real Ghidra `FileBytes`. It rejects auxiliary and partial sources,
checks the original target's round trip, and retains ambiguity when duplicate
target bytes have distinct loaded mappings. Injected decompiler failures and
cancellation verify that completed source/function resolution facts and backend
failure codes survive; these injections do not establish natural engine failures.
On Windows, after creating the
source-owned PE fixture, run from the repository root:
```powershell
node scripts/create-ghidra-windows-fixture.mjs
$proofRoot = Join-Path $env:TEMP 'rea-file-offset-source-proof'
New-Item -ItemType Directory -Path $proofRoot -Force | Out-Null
Copy-Item bridge/ghidra/ReaGhidraBridge.java, bridge/ghidra/ReaGhidraExtensions.java, tests/conformance/ghidra/ReaFileOffsetSourceProbe.java -Destination $proofRoot
& "$env:GHIDRA_INSTALL_DIR/support/analyzeHeadless.bat" $proofRoot source-proof -import "$PWD/build/fixtures/rea-ghidra-windows.exe" -scriptPath $proofRoot -postScript ReaFileOffsetSourceProbe.java -deleteProject
```
Require the `REA_FILE_OFFSET_SOURCE_OK` marker and no script errors; Ghidra may
exit successfully even when a post-script fails. The probe edits only its
temporary analysis database and does not execute the fixture. This producer
regression supplements public CLI/MCP coverage and does not establish Linux
host behavior or native process-supervision controls.
## Apple Interface Builder archives
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a
@ -717,11 +800,32 @@ or the GitHub issue and pull-request templates run formatting and
generated-document validation, including the small onboarding example consistency
suite, without the source-test shards or native package
lanes. Classification compares the PR head with its merge base, so later
base-branch changes do not expand that scope. Static and coverage aggregate jobs
base-branch changes do not expand that scope. Static and portable-test aggregate jobs
remain present and fail if classification fails. Every code change retains the
complete six-shard Linux suite; individual test files are not selected by
imports or filenames.
Ordinary PRs also produce an **observational** test-selection plan. The candidate
leaf-adapter ownership table lives in `scripts/ci/test-selection.mjs`, separately
from provider-job scopes. It proposes keeping affected owners plus every unowned
test, including all CLI/MCP, acceptance, composition, conformance, and process
tests. Shared inputs, unknown paths, missing owner tests, and removed tests fall
back to the complete inventory. Renames account for both paths. Main, scheduled,
manual, and `ci:full` runs always propose the full suite.
All six shards still execute the complete suite. The existing report merge
compares the proposal with actual module outcomes and retains
`.cache/ci-test-selection/report.json` in `test-results`, including selected and
omitted paths, changed input paths, missed failures, unobserved files, scheduling weights, PR head and
executed merge SHA. This adds no test reruns or runner jobs. Missing, duplicate,
skipped, interrupted, or unhandled results cannot establish a complete comparison;
even a complete passing comparison is not proof of ownership completeness.
Before enabling omissions, review representative full-run comparisons and
cross-owner consumers, including failures, and measure completed CI feedback and
runner demand. There is no automatic promotion or switch to skip tests. The
initial proposal measures only leaf producer-test omissions; it does not promise
the savings of a broader affected-test planner.
`scripts/ci/plan.mjs` selects lanes using the ownership table in
`scripts/ci/scopes.mjs`. Provider implementation changes run their real-provider
verifiers and the Linux x64 installed-package/Inspector baseline. The `apple`
@ -736,9 +840,19 @@ lane. Edits to verifier entrypoints run only by manual, licensed or self-hosted
workflows run static checks and the Linux suite. Website changes run website
checks. Provider workflow changes validate the workflow and run that provider's
verifier. Manual-provider/Android workflow changes run workflow validation only.
The four-version SQLite compatibility matrix has its own `sqlite` scope for
SQLite implementation, contracts, CLI/MCP registration, fixtures, and shared
runtime boundaries. SQLite and server-lifecycle test edits and the SQLite npm
scripts also select it. Unrelated code edits retain the complete portable suite
on the pinned Node version without starting four additional compatibility jobs.
Full qualification retains every SQLite version.
Shared process/contract/CLI boundaries, dependencies, other packaging inputs,
unknown paths, and changes to the planner select the full baseline. Package
script-only edits select their owning verifier when its entrypoint is
unknown paths, and changes to the planner select the full baseline.
CI timing collection, timing history, and test-report helper edits retain static
checks and the complete portable suite without selecting unrelated host/provider
lanes. Classification, qualification, gate, release receipt, and unknown CI
scripts keep the broad fallback.
Package script-only edits select their owning verifier when its entrypoint is
identifiable; unknown or shared script changes select everything. Android
build-script edits retain the Linux suite and workflow validation; real Android
verification stays manual. Renames account for both paths.
@ -746,38 +860,84 @@ verification stays manual. Renames account for both paths.
This narrower selection trades PR-time coverage for runner capacity. A change to
unowned shared runtime code that fails only on arm64, macOS, Windows, the Apple
artifact lane, or a provider lane that does not own the file is not caught on
the PR; the main-push full baseline reports it after merge. Ownership globs do
the PR; scheduled full qualification reports it after merge. Ownership globs do
not follow imports. Use the `ci:full` label when a change may have native-host
or provider effects outside its owners.
Main pushes and manual dispatch retain the full baseline, including all portable
provider lanes and the native matrix below. A `ci:full` PR label selects the full
baseline on the next PR run; manual dispatch can request it immediately. Each
Ordinary main pushes use the same owner scopes as PRs. Their diff starts at a
successful ancestor push run of this CI workflow, rather than the preceding
push: changes from failed or cancelled intermediate runs remain in scope.
Missing, untrusted, or non-ancestor history selects every lane. The lookup is
bounded to the last 100 successful push runs; lookup failures broaden selection.
Every code change still runs all six portable shards, without coverage
instrumentation in this fast tier. This deliberately avoids relying on an
import graph for generated files, filesystem inputs, subprocesses, or bridges.
Scheduled qualification is admitted hourly at minute 23. A forced full run at
03:23 UTC refreshes runner and dependency drift every day. Other hourly runs
can reuse a successful full-qualification receipt for the exact source SHA
from the same repository's trusted CI workflow on its default branch. Both the
receipt and original workflow run must be less than 24 hours old, so retrying an
old gate cannot renew stale proof. Missing, expired, failed, foreign, or uncertain proof runs full
validation; API lookup failure also runs full. Lookups examine at most 100 named
receipts and three candidate runs within a 20-second budget. Reused runs select
no validation lanes, identify the original run, and produce no new receipt, so
reuse cannot indefinitely extend proof freshness. They make no fresh provider
observation. Manual dispatch, `ci:full`, and exact release qualification always
run full validation.
Full qualification includes aggregate coverage,
every portable provider lane, and all native hosts below. Adding `ci:full`
immediately starts the separate `Full PR qualification` workflow against the
label event's merge revision; subsequent PR revisions also select the full tier
in normal CI while that label remains. Unrelated labels do not start validation
jobs or replace the main CI checks. Manual dispatch also requests full validation.
Fast-tier broad fallbacks retain all owner lanes and the three-host
package matrix; Intel macOS and coverage remain full-tier obligations. Each
classification job records its selection and broad-fallback reasons in the run
summary. Representative Git-diff cases live in
`tests/boundary/filesystem/ciChangeScope.test.ts`.
`CI required` checks classification and every selected lane, rejecting failed,
cancelled, or unexpectedly skipped work. Existing required check names are
preserved; changing branch protection to require the new aggregate is a separate
repository-settings operation. Newer commits cancel superseded CI for the same
PR or main branch. Release publication has its own concurrency group and retains
its package verification and public-registry canary. Licensed/self-hosted
`CI required` always runs and checks classification and every selected lane,
rejecting failed, cancelled, or unexpectedly skipped work. It rejects missing or
unknown tiers and requires every canonical scope to be explicitly `true` or
`false`; incomplete classification cannot silently omit verification lanes.
Branch protection
should require this aggregate, `Static checks`, `Portable tests`, and
`Conventional PR title`. Title validation runs separately on PR opening,
reopening, commits, and edits; correcting a title refreshes only this lightweight
check. It reads title text through an environment variable, executes no PR code,
and has no token permissions. Its separate concurrency group cannot cancel
test runs. Fast PR runs cancel superseded feedback for the same PR. Main push
runs keep one active run and the latest pending run for that branch.
Scheduled/manual qualification has a separate concurrency group: active work
finishes and only the latest pending run is retained. Release qualification uses
its exact source SHA and cannot be cancelled by newer main pushes. Provider and
website lanes inherit their caller's scheduling; reusable
workflows have no independent cancellation group. Direct manual lane dispatches
run independently on disposable hosted runners. Publication
requires that qualification to succeed and consumes its exact verified tarball,
checking source SHA, workflow run, run attempt, package/Node version and SHA-256
before npm publication. Earlier attempts from the same workflow run are accepted
when rerunning failed publisher jobs; future attempts and other runs are rejected.
The public-registry canary still verifies the published package. Licensed/self-hosted
provider workflows and the Android smoke test retain their explicit triggers.
CI exercises the pinned Node.js runtime on native hosted runners:
| Host | Runner | Baseline checks |
| --------------------- | ------------------ | ---------------------------------------------------------------------------------------- |
| Linux x64 | `ubuntu-latest` | Installed package and real Node Inspector CLI/MCP |
| Linux arm64 (aarch64) | `ubuntu-24.04-arm` | Installed package and real Node Inspector CLI/MCP |
| macOS 15 arm64 | `macos-15` | Installed package and real Node Inspector CLI/MCP |
| macOS 15 x64 | `macos-15-intel` | Installed package and real Node Inspector CLI/MCP (main, dispatch and `ci:full` only) |
| Windows x64 | `windows-latest` | Curated capabilities, native controls, installed package and real Node Inspector CLI/MCP |
| Host | Runner | Baseline checks |
| --------------------- | ------------------ | ------------------------------------------------------------------------------------------- |
| Linux x64 | `ubuntu-latest` | Installed package and real Node Inspector CLI/MCP |
| Linux arm64 (aarch64) | `ubuntu-24.04-arm` | Installed package and real Node Inspector CLI/MCP |
| macOS 15 arm64 | `macos-15` | Installed package and real Node Inspector CLI/MCP |
| macOS 15 x64 | `macos-15-intel` | Installed package and real Node Inspector CLI/MCP (hourly, dispatch, release and `ci:full`) |
| Windows x64 | `windows-latest` | Curated capabilities, native controls, installed package and real Node Inspector CLI/MCP |
macOS x64 is the slowest package host. Pull requests without the `ci:full` label
omit it, so darwin x64 installed-package and Inspector regressions surface on
the main-push full baseline rather than on the PR.
macOS x64 is the slowest package host. Ordinary PRs and main pushes omit it,
so darwin x64 installed-package and Inspector regressions surface in full
qualification. Schedule admission can be delayed by GitHub; the hourly trigger
does not guarantee an hourly completed result. Release publication waits for
its own complete result at the exact released revision.
Package lanes assert the actual Node platform/architecture before verification
and record those values with the Node version. The package matrix runs up to
@ -803,17 +963,34 @@ The Linux source-test shards download a shared runtime and generated test
metadata snapshot produced by the build job with `npm run test:prepare`. The
snapshot includes `dist/`, generated skills, the MCP tool catalog, product
catalog, and managed-conformance metadata. Each shard still installs the locked
dependencies and runs its assigned coverage-enabled tests; it no longer
dependencies and runs its assigned tests; full qualification enables coverage. It no longer
regenerates the same metadata independently.
The build job also retrieves the merged timing report from the last successful
default-branch push and includes that same report in each shard's snapshot.
The build job also finds an available `test-timings` artifact from a successful
trusted default-branch scheduled or manual qualification and includes that same
report in each shard's snapshot. The bounded lookup verifies the artifact's
producer; newer schedules that reuse qualification without producing timings do
not hide the last full run's artifact.
CI assigns discovered files by historical duration, with a deterministic
fallback weight for new files, rather than dividing only by file count. Every
discovered file still belongs to exactly one shard; worker limits, project
ordering, coverage instrumentation, and aggregate thresholds remain unchanged.
discovered file still belongs to exactly one shard; worker limits and project
ordering remain unchanged. The merge job rejects successful reports with missing,
unexpected, or duplicate portable files. Coverage instrumentation and aggregate
thresholds remain enforced in full qualification.
Missing or unusable history falls back to Vitest's default assignment. Only
timing data is reused; persistent compile caches remain disabled.
timing data is reused; live provider results are never cached. Turbo outputs are
restored by task hash, with trusted default-branch runs as cache writers and PRs
as restore-only consumers. npm caches hold downloads, not `node_modules`.
The eight automatic browser, binary-layout, EVM, JavaScript-recovery,
recorded-crash, and web provider lanes download `rea-build-cache` from their own
run after the build and static jobs succeed. Their existing build commands still
validate task hashes and rebuild mismatched inputs; provider observations always
run. Provider-workflow-only edits also admit the build without enabling source
test shards. Direct provider dispatches retain the cross-run restore-only cache.
This removes repeated compilation on fresh source revisions, but adds the build
as a prerequisite: a delayed build can delay provider admission. Compare queue
and execution times before claiming an end-to-end speedup.
These native baseline checks complement the Linux source-test shards and the
separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA,

View File

@ -182,6 +182,7 @@
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
"verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs",
"verify:ghidra:address-context": "npm run build:cached && node scripts/verify-real-ghidra-address-context.mjs",
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
"verify:interface-builder": "npm run build:cached && node scripts/verify/apple/interface-builder.mjs",
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",

76
scripts/ci/baseline.mjs Normal file
View File

@ -0,0 +1,76 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
const repository = process.env.CI_REPOSITORY;
const branch = process.env.CI_DEFAULT_BRANCH;
const head = process.env.HEAD_SHA;
const shaPattern = /^[0-9a-f]{40}$/i;
function ancestor(base) {
try {
execFileSync("git", ["merge-base", "--is-ancestor", base, head], {
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
function write(base, reason) {
const output = { base, reason };
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`base=${base}\nreason=${reason.replace(/[\r\n]/g, " ")}\n`,
);
}
console.log(JSON.stringify(output));
}
if (!repository || !branch || !head || !shaPattern.test(head)) {
write("", "Repository, default branch, or checked-out head is invalid");
} else {
try {
const response = execFileSync(
"gh",
[
"api",
"--method",
"GET",
`repos/${repository}/actions/workflows/ci.yml/runs`,
"-f",
`branch=${branch}`,
"-f",
"event=push",
"-f",
"status=success",
"-F",
"per_page=100",
],
{ encoding: "utf8", timeout: 15_000, maxBuffer: 4 * 1024 * 1024 },
);
const runs = JSON.parse(response).workflow_runs;
if (!Array.isArray(runs))
throw new Error("GitHub returned no workflow run list");
const base = runs.find(
(run) =>
run?.event === "push" &&
run?.conclusion === "success" &&
run?.head_branch === branch &&
run?.head_repository?.full_name === repository &&
typeof run?.head_sha === "string" &&
shaPattern.test(run.head_sha) &&
run.head_sha.toLowerCase() !== head.toLowerCase() &&
ancestor(run.head_sha),
)?.head_sha;
if (base) write(base, "");
else
write(
"",
"No trusted successful CI push baseline is an ancestor of this head",
);
} catch {
write("", "Could not read a successful CI push baseline from GitHub");
}
}

View File

@ -0,0 +1,268 @@
import { execFile } from "node:child_process";
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { collectJobTimings } from "./job-timings.mjs";
const execFileAsync = promisify(execFile);
const OUTPUT_DIRECTORY = join(".cache", "ci-job-timings");
const MAX_BUFFER = 16 * 1024 * 1024;
const API_TIMEOUT_MS = 30_000;
const WORKFLOWS = [
{ id: "ci.yml", name: "CI" },
{ id: "release.yml", name: "Release" },
];
const LOOKBACK_MS = 24 * 60 * 60 * 1_000;
const MAX_RUNS_PER_WORKFLOW = 100;
const TIME_BUDGET_MS = 240_000;
/** Collect a bounded recent sample of completed CI and Release attempts. */
export async function collectJobTimingsBatch({
repository,
outputDirectory = OUTPUT_DIRECTORY,
observedAt = new Date().toISOString(),
env = process.env,
now = Date.now,
collect = collectJobTimings,
} = {}) {
const identity = validateRepository(repository);
const started = now();
const cutoff = new Date(started - LOOKBACK_MS).toISOString();
const manifest = {
schemaVersion: 1,
observedAt,
repository: identity,
selection: {
workflows: WORKFLOWS,
status: "completed",
createdAfter: cutoff,
lookbackHours: 24,
overlap: "Each hourly run re-reads the preceding 24 hours.",
pagination:
"Run listings use the first 100; jobs are paginated within the remaining time and 16 MiB response limits.",
maxRunsPerWorkflow: MAX_RUNS_PER_WORKFLOW,
timeBudgetMs: TIME_BUDGET_MS,
exhaustive: false,
},
coverage: {
workflowsQueried: [],
runsListed: 0,
selectedRuns: 0,
collectedRuns: 0,
},
truncation: [],
errors: [],
runs: [],
elapsedMs: 0,
};
const budgetExpired = () => now() - started >= TIME_BUDGET_MS;
const selected = [];
for (const workflow of WORKFLOWS) {
if (budgetExpired()) {
manifest.truncation.push("Time budget reached while listing workflows.");
break;
}
try {
const response = await apiJson(
`repos/${identity}/actions/workflows/${workflow.id}/runs?status=completed&created=%3E%3D${encodeURIComponent(cutoff)}&per_page=${String(MAX_RUNS_PER_WORKFLOW)}&page=1`,
env,
started,
now,
);
if (!Array.isArray(response.workflow_runs))
throw new Error("run listing must contain a workflow_runs array");
manifest.coverage.workflowsQueried.push(workflow.name);
manifest.coverage.runsListed += response.workflow_runs.length;
if (response.workflow_runs.length >= MAX_RUNS_PER_WORKFLOW)
manifest.truncation.push(
`${workflow.name} run listing reached the 100-run first-page limit.`,
);
for (const run of response.workflow_runs.slice(
0,
MAX_RUNS_PER_WORKFLOW,
)) {
if (
run.status !== "completed" ||
!Number.isSafeInteger(run.id) ||
!Number.isSafeInteger(run.run_attempt)
) {
manifest.errors.push({
scope: `workflow:${workflow.name}`,
runId: run.id ?? null,
message:
"Run record missing completed status, numeric id, or numeric latest attempt.",
});
continue;
}
if (run.created_at && Date.parse(run.created_at) < Date.parse(cutoff))
continue;
selected.push({
id: run.id,
runAttempt: run.run_attempt,
workflow: workflow.name,
conclusion: run.conclusion ?? null,
createdAt: run.created_at ?? null,
});
}
} catch (cause) {
manifest.errors.push({
scope: `workflow:${workflow.name}`,
message: errorMessage(cause),
});
}
}
selected.sort(
(left, right) =>
(Date.parse(right.createdAt ?? "") || 0) -
(Date.parse(left.createdAt ?? "") || 0),
);
manifest.coverage.selectedRuns = selected.length;
for (const run of selected) {
if (budgetExpired()) {
manifest.truncation.push(
"Time budget reached before all selected runs were collected.",
);
break;
}
const runRecord = {
...run,
status: "pending",
rawPath: null,
reportPath: null,
error: null,
};
manifest.runs.push(runRecord);
const runDirectory = join(
outputDirectory,
"runs",
`${String(run.id)}-${String(run.runAttempt)}`,
);
try {
const result = await collect({
repository: identity,
runId: String(run.id),
runAttempt: String(run.runAttempt),
outputDirectory: runDirectory,
env,
timeoutMs: Math.max(
1,
Math.min(API_TIMEOUT_MS, TIME_BUDGET_MS - (now() - started)),
),
});
runRecord.status = "collected";
runRecord.rawPath = result.rawPath;
runRecord.reportPath = result.reportPath;
manifest.coverage.collectedRuns += 1;
} catch (cause) {
runRecord.status = "error";
runRecord.error = errorMessage(cause);
manifest.errors.push({
scope: "run",
runId: run.id,
runAttempt: run.runAttempt,
message: runRecord.error,
});
}
}
if (budgetExpired() && manifest.runs.length < manifest.coverage.selectedRuns)
manifest.truncation.push(
"Time budget reached; selected runs remain unattempted.",
);
return await writeManifest(outputDirectory, manifest, started, now, env);
}
function validateRepository(repository) {
if (
typeof repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
)
throw new Error(
"CI_REPOSITORY must be an owner/name repository identifier",
);
return repository;
}
async function apiJson(endpoint, env, started, now) {
const timeout = Math.max(
1,
Math.min(API_TIMEOUT_MS, TIME_BUDGET_MS - (now() - started)),
);
const { stdout } = await execFileAsync("gh", ["api", endpoint], {
env,
encoding: "utf8",
maxBuffer: MAX_BUFFER,
timeout,
});
try {
return JSON.parse(stdout);
} catch (cause) {
throw new Error("GitHub Actions API returned malformed JSON", { cause });
}
}
async function writeManifest(outputDirectory, manifest, started, now, env) {
manifest.elapsedMs = Math.max(0, now() - started);
if (
manifest.elapsedMs >= TIME_BUDGET_MS &&
!manifest.truncation.some((item) => item.includes("Time budget"))
)
manifest.truncation.push("Time budget reached.");
await mkdir(outputDirectory, { recursive: true });
const manifestPath = join(outputDirectory, "batch-manifest.json");
await writeFile(
manifestPath,
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
if (env.GITHUB_STEP_SUMMARY)
await appendBatchSummary(env.GITHUB_STEP_SUMMARY, manifest, manifestPath);
return { manifestPath, manifest };
}
async function appendBatchSummary(summaryPath, manifest, manifestPath) {
const collected = manifest.runs.filter(
({ status }) => status === "collected",
).length;
const failed = manifest.runs.filter(
({ status }) => status === "error",
).length;
const lines = [
"### Batched CI timing snapshot",
"",
`Observed ${manifest.observedAt}; collected ${String(collected)} of ${String(manifest.coverage.selectedRuns)} selected recent completed runs (${String(failed)} run errors).`,
`Coverage uses the first ${String(MAX_RUNS_PER_WORKFLOW)} runs per workflow created in the preceding 24 hours and a ${String(TIME_BUDGET_MS / 1_000)} second total budget. This sample is bounded and is not exhaustive.`,
`Manifest: \`${manifestPath}\`.`,
...manifest.truncation.map((message) => `- Truncated: ${message}`),
...manifest.errors.map(
({ scope, message }) => `- Error (${scope}): ${message}`,
),
"",
];
await appendFile(summaryPath, lines.join("\n"), "utf8");
}
function errorMessage(cause) {
return cause instanceof Error ? cause.message : String(cause);
}
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
const result = await collectJobTimingsBatch({
repository: process.env.CI_REPOSITORY,
outputDirectory: process.env.CI_JOB_TIMINGS_DIRECTORY ?? OUTPUT_DIRECTORY,
});
process.stdout.write(
`Wrote batch manifest for ${String(result.manifest.coverage.collectedRuns)} collected runs to ${result.manifestPath}\n`,
);
} catch (cause) {
process.stderr.write(`CI timing batch failed: ${errorMessage(cause)}\n`);
process.exitCode = 1;
}
}

354
scripts/ci/job-timings.mjs Normal file
View File

@ -0,0 +1,354 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const MAX_BUFFER = 16 * 1024 * 1024;
const TIMEOUT_MS = 30_000;
const OUTPUT_DIRECTORY = join(".cache", "ci-job-timings");
const STEP_COSTS = [
{ key: "npmCi", pattern: /\bnpm ci\b/iu },
{ key: "artifactUpload", pattern: /upload.*artifact/iu },
{ key: "artifactDownload", pattern: /download.*artifact/iu },
];
/** Fetch and summarize one workflow attempt without turning telemetry into a gate. */
export async function collectJobTimings({
repository,
runId,
runAttempt,
outputDirectory = OUTPUT_DIRECTORY,
summaryPath,
observedAt = new Date().toISOString(),
env = process.env,
timeoutMs = TIMEOUT_MS,
}) {
const identity = validateIdentity({ repository, runId, runAttempt });
const endpoint = `repos/${identity.repository}/actions/runs/${identity.runId}/attempts/${identity.runAttempt}/jobs?per_page=100`;
const { stdout } = await execFileAsync(
"gh",
["api", "--paginate", "--slurp", endpoint],
{ env, encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: timeoutMs },
);
const rawPath = join(outputDirectory, "jobs.json");
const reportPath = join(outputDirectory, "report.json");
await mkdir(outputDirectory, { recursive: true });
await writeFile(rawPath, stdout, "utf8");
const pages = parsePages(stdout);
const jobs = pages.flatMap(({ jobs: pageJobs }, index) =>
validateJobs(pageJobs, index),
);
const report = normalizeReport({ identity, jobs, observedAt });
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
if (summaryPath !== undefined)
await appendSummary(summaryPath, report, rawPath, reportPath);
return { rawPath, reportPath, report };
}
function validateIdentity({ repository, runId, runAttempt }) {
if (
typeof repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
)
throw new Error(
"CI_REPOSITORY must be an owner/name repository identifier",
);
return {
repository,
runId: parsePositiveInteger(runId, "CI_TIMING_RUN_ID"),
runAttempt: parsePositiveInteger(runAttempt, "CI_TIMING_RUN_ATTEMPT"),
};
}
function parsePositiveInteger(value, name) {
if (typeof value === "string" && /^[1-9]\d*$/u.test(value)) {
const parsed = Number(value);
if (Number.isSafeInteger(parsed)) return parsed;
}
throw new Error(`${name} must be a positive safe integer`);
}
function parsePages(stdout) {
let pages;
try {
pages = JSON.parse(stdout);
} catch (cause) {
throw new Error("GitHub jobs API returned malformed JSON", { cause });
}
if (!Array.isArray(pages) || pages.length === 0)
throw new Error("GitHub jobs API slurp must contain a nonempty page array");
for (const page of pages)
if (
typeof page !== "object" ||
page === null ||
Array.isArray(page) ||
!Array.isArray(page.jobs)
)
throw new Error("GitHub jobs API page must contain a jobs array");
return pages;
}
function validateJobs(jobs, pageIndex) {
return jobs.map((job, jobIndex) => {
if (
typeof job !== "object" ||
job === null ||
Array.isArray(job) ||
typeof job.name !== "string" ||
job.name.length === 0 ||
typeof job.status !== "string"
)
throw new Error(
`GitHub jobs API page ${String(pageIndex + 1)} job ${String(jobIndex + 1)} is malformed`,
);
return job;
});
}
function normalizeReport({ identity, jobs, observedAt }) {
const normalizedJobs = jobs.map(normalizeJob);
const queues = normalizedJobs
.map(({ queue }) => queue.seconds)
.filter((value) => value !== null);
const completedExecutions = normalizedJobs
.filter(
({ status, conclusion }) =>
status === "completed" && conclusion !== "skipped",
)
.map(({ execution }) => execution.seconds)
.filter((value) => value !== null);
const completedDurationSumSeconds = completedExecutions.reduce(
(sum, seconds) => sum + seconds,
0,
);
const completedExecutionUnknown = normalizedJobs.filter(
({ status, conclusion, execution }) =>
status === "completed" &&
conclusion !== "skipped" &&
execution.seconds === null,
).length;
const skipped = normalizedJobs.filter(
({ conclusion }) => conclusion === "skipped",
).length;
const costs = Object.fromEntries(STEP_COSTS.map(({ key }) => [key, []]));
for (const job of normalizedJobs)
for (const step of job.steps)
if (step.costKey !== null)
costs[step.costKey].push({ job: job.name, ...step });
return {
schemaVersion: 1,
run: {
...identity,
observedAt,
snapshot:
normalizedJobs.length > 0 &&
normalizedJobs.every(({ status }) => status === "completed")
? "completed job status snapshot; unknown timestamps remain unmeasured"
: "partial; job statuses may still be queued or in progress",
},
counts: {
jobs: normalizedJobs.length,
completed: normalizedJobs.filter(({ status }) => status === "completed")
.length,
skipped,
queueUnknown: normalizedJobs.filter(({ queue }) => queue.seconds === null)
.length,
executionUnknown: normalizedJobs.filter(
({ execution }) => execution.seconds === null,
).length,
completedExecutionUnknown,
},
aggregates: {
queueSeconds: percentileSummary(queues),
completedExecutionSeconds: percentileSummary(completedExecutions),
summedCompletedJobElapsedSeconds: {
value:
completedExecutions.length === 0
? null
: round(completedDurationSumSeconds),
knownCount: completedExecutions.length,
unknownCount: completedExecutionUnknown,
label:
"sum of known completed job elapsed durations; incomplete when timestamps are unknown; not CPU time, billing, or workflow wall time",
},
},
jobs: normalizedJobs,
stepCostInterpretation:
"Matched install/artifact steps by step name; these are not total artifact transfer costs.",
stepCosts: costs,
};
}
function normalizeJob(job) {
const skipped = job.conclusion === "skipped";
const hasStarted = ["in_progress", "completed"].includes(job.status);
const queue = skipped
? unknownDuration("job was skipped")
: !hasStarted
? unknownDuration("job start is not established by its status")
: duration(job.created_at, job.started_at);
const execution = skipped
? unknownDuration("job was skipped")
: job.status === "completed"
? duration(job.started_at, job.completed_at)
: unknownDuration("job is not completed");
const steps = Array.isArray(job.steps) ? job.steps.map(normalizeStep) : [];
return {
id: Number.isSafeInteger(job.id) && job.id > 0 ? job.id : null,
url: stringOrNull(job.html_url),
name: job.name,
workflowName: stringOrNull(job.workflow_name),
status: job.status,
conclusion: stringOrNull(job.conclusion),
createdAt: stringOrNull(job.created_at),
startedAt: stringOrNull(job.started_at),
completedAt: stringOrNull(job.completed_at),
queue,
execution,
steps,
};
}
function normalizeStep(step) {
if (
typeof step !== "object" ||
step === null ||
Array.isArray(step) ||
typeof step.name !== "string"
)
throw new Error("GitHub jobs API contains a malformed job step");
const cost = STEP_COSTS.find(({ pattern }) => pattern.test(step.name));
return {
name: step.name,
costKey: cost?.key ?? null,
duration: duration(step.started_at, step.completed_at),
};
}
function duration(startValue, endValue) {
if (typeof startValue !== "string" || typeof endValue !== "string")
return unknownDuration("timestamp unavailable");
const start = Date.parse(startValue);
const end = Date.parse(endValue);
if (!Number.isFinite(start) || !Number.isFinite(end))
return unknownDuration("invalid timestamp");
const milliseconds = end - start;
if (!Number.isFinite(milliseconds) || milliseconds < 0)
return unknownDuration("timestamps out of order");
return { seconds: round(milliseconds / 1_000), reason: null };
}
function unknownDuration(reason) {
return { seconds: null, reason };
}
function percentileSummary(values) {
return {
count: values.length,
p50: nearestRank(values, 0.5),
p95: nearestRank(values, 0.95),
unit: "seconds",
};
}
function nearestRank(values, percentile) {
if (values.length === 0) return null;
const sorted = [...values].sort((left, right) => left - right);
return round(sorted[Math.ceil(percentile * sorted.length) - 1]);
}
function round(value) {
return Math.round(value * 1_000) / 1_000;
}
function stringOrNull(value) {
return typeof value === "string" ? value : null;
}
async function appendSummary(summaryPath, report, rawPath, reportPath) {
await mkdir(dirname(summaryPath), { recursive: true });
const rows = [
["Queue", report.aggregates.queueSeconds],
["Completed execution", report.aggregates.completedExecutionSeconds],
]
.map(
([label, item]) =>
`| ${label} | ${display(item.p50)} | ${display(item.p95)} | ${String(item.count)} |`,
)
.join("\n");
const jobRows = report.jobs
.map(
(job) =>
`| ${escapeCell(job.name)} | ${job.status} | ${job.conclusion ?? "unknown"} | ${display(job.queue.seconds)} | ${job.queue.seconds === null ? job.queue.reason : ""} | ${display(job.execution.seconds)} | ${job.execution.seconds === null ? job.execution.reason : ""} |`,
)
.join("\n");
const costRows = STEP_COSTS.map(({ key }) => {
const samples = report.stepCosts[key];
const values = samples
.map(({ duration: item }) => item.seconds)
.filter((value) => value !== null);
return `| ${key} | ${String(samples.length)} | ${display(nearestRank(values, 0.5))} | ${samples.filter(({ duration: item }) => item.seconds === null).length} |`;
}).join("\n");
const text = [
"### CI job timing snapshot",
"",
`Observed at ${report.run.observedAt} for run ${String(report.run.runId)}, attempt ${String(report.run.runAttempt)}. This is a point-in-time snapshot of fetched jobs. Queue starts at job creation and excludes preceding dependency waiting. Missing, queued, in-progress, invalid, or out-of-order timestamps are reported as unknown. Elapsed durations are not CPU time or billing data.`,
"",
`Fetched ${String(report.counts.jobs)} jobs; ${String(report.counts.completed)} are completed (${String(report.counts.skipped)} skipped); ${String(report.counts.queueUnknown)} queue durations and ${String(report.counts.executionUnknown)} execution durations (${String(report.counts.completedExecutionUnknown)} for non-skipped completed jobs) are unknown.`,
"",
"| Metric | p50 (s) | p95 (s) | Samples |",
"| --- | ---: | ---: | ---: |",
rows,
"",
"| Job | Status | Conclusion | Queue (s) | Queue detail | Execution (s) | Execution detail |",
"| --- | --- | --- | ---: | --- | ---: | --- |",
jobRows,
"",
`Sum of known completed job elapsed durations: **${display(report.aggregates.summedCompletedJobElapsedSeconds.value)} s** across ${String(report.aggregates.summedCompletedJobElapsedSeconds.knownCount)} known and ${String(report.aggregates.summedCompletedJobElapsedSeconds.unknownCount)} unknown jobs; this is not CPU time, billing, or workflow wall time.`,
"",
"Matched install/artifact steps by name; these are not total artifact transfer costs.",
"",
"| Matched step group | Samples | p50 (s) | Unknown |",
"| --- | ---: | ---: | ---: |",
costRows,
"",
`Reports: \`${rawPath}\`, \`${reportPath}\`.`,
"",
].join("\n");
await writeFile(summaryPath, text, { encoding: "utf8", flag: "a" });
}
function display(value) {
return value === null ? "unknown" : String(value);
}
function escapeCell(value) {
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
}
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
const result = await collectJobTimings({
repository: process.env.CI_REPOSITORY,
runId: process.env.CI_TIMING_RUN_ID,
runAttempt: process.env.CI_TIMING_RUN_ATTEMPT,
outputDirectory: process.env.CI_JOB_TIMINGS_DIRECTORY ?? OUTPUT_DIRECTORY,
summaryPath: process.env.GITHUB_STEP_SUMMARY,
});
process.stdout.write(
`Wrote ${result.report.jobs.length} job timing records to ${result.reportPath}\n`,
);
} catch (cause) {
process.stderr.write(
`CI timing telemetry failed: ${cause instanceof Error ? cause.message : String(cause)}\n`,
);
process.exitCode = 1;
}
}

View File

@ -1,30 +1,20 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { matchesGlob } from "node:path";
import { MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs";
import { CI_FLAGS as flags, MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs";
import { planTestSelection } from "./test-selection.mjs";
const flags = [
...new Set([
"code",
"runtime",
"package",
"apple",
"fixtures",
"readiness",
"docs",
"workflows",
...Object.keys(SCOPES),
]),
];
// Test files select the native-host lanes that run them explicitly. Provider
// scopes remain implementation-owned, so test-only edits stay off those lanes.
const NATIVE_TEST_OWNERS = ["apple", "package"];
// Test files select the host/runtime lanes that execute their focused suites.
const HOST_TEST_OWNERS = ["apple", "package", "sqlite"];
// Actionlint validates workflow syntax only. The semantic suite asserts these
// workflows' release, publication and self-hosted runner safeguards.
const GUARDED_WORKFLOWS = new Set([
".github/workflows/pr-title.yml",
".github/workflows/ci-full-pr.yml",
".github/workflows/release.yml",
".github/workflows/real-ghidra-windows.yml",
".github/workflows/sqlite-runtime.yml",
".github/workflows/ci-timings.yml",
]);
// Sources compiled by build-conformance-fixtures.mjs. The readiness journey
// analyzes the built C fixture as well as its own applications.
@ -36,6 +26,15 @@ const FIXTURE_SOURCES = [
"tests/conformance/versions/**",
];
const READINESS_SOURCES = ["tests/conformance/readiness/**"];
// These reporters affect CI output only; they do not select a host or provider
// verification lane when changed on their own.
const PORTABLE_CI_REPORTERS = new Set([
"scripts/ci/job-timings.mjs",
"scripts/ci/job-timings-batch.mjs",
"scripts/ci/test-report.mjs",
"scripts/ci/test-timing-reporter.mjs",
"scripts/ci/test-history.mjs",
]);
const matchesAny = (file, patterns) =>
patterns.some((pattern) => matchesGlob(file, pattern));
const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
@ -63,6 +62,10 @@ function classifyPackage(base, head) {
]),
].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]);
for (const script of changed) {
if (script === "test:sqlite" || script === "test:sqlite:run") {
selected.sqlite = true;
continue;
}
if (
/^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip$|jscpd$)/.test(
script,
@ -108,98 +111,134 @@ function classifyPackage(base, head) {
}
const event = process.env.GITHUB_EVENT_NAME;
let files = [];
if (
event === "push" ||
if (!["pull_request", "push", "schedule", "workflow_dispatch"].includes(event))
throw new Error(`Unsupported CI event: ${event}`);
const requestedFull =
event === "schedule" ||
event === "workflow_dispatch" ||
process.env.CI_FULL === "true"
) {
process.env.CI_FULL === "true";
const tier =
event === "schedule" &&
process.env.CI_SKIP_FULL === "true" &&
process.env.CI_FULL !== "true"
? "reused"
: requestedFull
? "full"
: "fast";
let files = [];
if (tier === "reused") {
reasons.push(
"Recent exact-SHA full qualification reused; no fresh provider observation",
);
} else if (tier === "full") {
full("Full baseline requested by event or ci:full label");
} else if (event === "pull_request") {
const base = git("merge-base", process.env.BASE_SHA, process.env.HEAD_SHA);
} else if (event === "pull_request" || event === "push") {
const head = process.env.HEAD_SHA;
// Disable rename detection so both the removed and added paths participate.
files = git("diff", "--name-only", "--no-renames", "-z", base, head)
.split("\0")
.filter(Boolean);
for (const file of files) {
if (
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
file,
) ||
file.startsWith("docs/") ||
file.startsWith(".github/ISSUE_TEMPLATE/")
) {
selected.docs = true;
selected.website ||= matchesAny(file, SCOPES.website);
continue;
}
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
selected.code = true;
for (const owner of NATIVE_TEST_OWNERS)
selected[owner] ||= matchesAny(file, SCOPES[owner]);
continue;
}
if (matchesAny(file, MANUAL_VERIFIERS)) {
selected.code = true;
continue;
}
const owners = Object.entries(SCOPES)
.filter(([, patterns]) =>
patterns.some((pattern) => matchesGlob(file, pattern)),
)
.map(([owner]) => owner);
for (const owner of owners) selected[owner] = true;
if (
file.startsWith(".github/workflows/") ||
file === ".github/actionlint.yaml"
) {
selected.workflows = true;
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
if (file !== ".github/workflows/ci.yml") continue;
}
if (owners.length === 1 && owners[0] === "website") continue;
if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true;
if (file === "package.json") {
classifyPackage(base, head);
continue;
}
selected.code = true;
if (matchesAny(file, FIXTURE_SOURCES)) {
selected.fixtures = true;
selected.readiness = true;
}
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
if (file.startsWith("tests/")) continue;
if (
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
file,
)
) {
full(`Shared boundary: ${file}`);
} else if (owners.length) {
if (file.startsWith("src/") || file.startsWith("bridge/"))
selected.runtime = true;
} else if (/^(src|bridge)\//.test(file)) {
// Unowned runtime sources keep the Linux x64 installed-package baseline;
// ci:full, main pushes and manual dispatch retain every native host.
selected.runtime = true;
reasons.push(`Unowned runtime source: ${file}`);
} else if (file.startsWith(".agents/skills/")) {
selected.runtime = true;
selected.docs = true;
} else {
full(`Unclassified input: ${file}`);
let base;
if (event === "pull_request") {
base = git("merge-base", process.env.BASE_SHA, head);
} else {
const candidate = process.env.BASE_SHA;
const baselineReason = process.env.BASELINE_REASON;
try {
if (!candidate || !/^[0-9a-f]{40}$/i.test(candidate))
throw new Error(
baselineReason || "No valid successful push baseline was found",
);
execFileSync("git", ["merge-base", "--is-ancestor", candidate, head], {
stdio: "ignore",
});
base = candidate;
} catch {
full(
baselineReason ||
"No valid successful push baseline ancestor; selecting all lanes",
);
}
}
} else throw new Error(`Unsupported CI event: ${event}`);
if (base) {
// Disable rename detection so both the removed and added paths participate.
files = git("diff", "--name-only", "--no-renames", "-z", base, head)
.split("\0")
.filter(Boolean);
for (const file of files) {
if (
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
file,
) ||
file.startsWith("docs/") ||
file.startsWith(".github/ISSUE_TEMPLATE/")
) {
selected.docs = true;
selected.website ||= matchesAny(file, SCOPES.website);
continue;
}
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
selected.code = true;
for (const owner of HOST_TEST_OWNERS)
selected[owner] ||= matchesAny(file, SCOPES[owner]);
continue;
}
if (matchesAny(file, MANUAL_VERIFIERS)) {
selected.code = true;
continue;
}
const owners = Object.entries(SCOPES)
.filter(([, patterns]) =>
patterns.some((pattern) => matchesGlob(file, pattern)),
)
.map(([owner]) => owner);
for (const owner of owners) selected[owner] = true;
if (
file.startsWith(".github/workflows/") ||
file === ".github/actionlint.yaml"
) {
selected.workflows = true;
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
if (file !== ".github/workflows/ci.yml") continue;
}
if (owners.length === 1 && owners[0] === "website") continue;
if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true;
if (file === "package.json") {
classifyPackage(base, head);
continue;
}
selected.code = true;
if (matchesAny(file, FIXTURE_SOURCES)) {
selected.fixtures = true;
selected.readiness = true;
}
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
if (file.startsWith("tests/")) continue;
if (PORTABLE_CI_REPORTERS.has(file)) continue;
if (
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
file,
)
) {
full(`Shared boundary: ${file}`);
} else if (owners.length) {
if (file.startsWith("src/") || file.startsWith("bridge/"))
selected.runtime = true;
} else if (/^(src|bridge)\//.test(file)) {
// Unowned runtime sources keep the Linux x64 installed-package baseline;
// full qualification retains every native host.
selected.runtime = true;
reasons.push(`Unowned runtime source: ${file}`);
} else if (file.startsWith(".agents/skills/")) {
selected.runtime = true;
selected.docs = true;
} else {
full(`Unclassified input: ${file}`);
}
}
}
}
selected.runtime ||= selected.package;
const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" };
// macOS x64 is the slowest package host. Pull requests run it only with ci:full;
// main pushes, schedules and manual dispatch always include it.
// macOS x64 is the slowest package host. Fast qualification omits it.
const intel =
event !== "pull_request" || process.env.CI_FULL === "true"
tier === "full"
? [{ os: "macos-15-intel", platform: "darwin", architecture: "x64" }]
: [];
const matrix = {
@ -212,17 +251,23 @@ const matrix = {
]
: [linux],
};
const plan = { ...selected, matrix, files, reasons };
const testSelection = planTestSelection(files, event, tier);
const plan = { ...selected, tier, matrix, files, reasons, testSelection };
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries({ ...selected, matrix: JSON.stringify(matrix) })
Object.entries({
...selected,
tier,
matrix: JSON.stringify(matrix),
test_selection: JSON.stringify(testSelection),
})
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`### Selected CI lanes\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`,
`### Selected CI lanes (${tier})\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`,
);
console.log(JSON.stringify(plan));

View File

@ -0,0 +1,176 @@
import { execFileSync } from "node:child_process";
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ARTIFACT_NAME = "ci-full-qualification-v1";
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
const positiveId = (value) =>
(typeof value === "string" || typeof value === "number") &&
/^[1-9]\d*$/.test(String(value)) &&
Number.isSafeInteger(Number(value));
const commitSha = (value) =>
typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
/** Record only after the required gate has verified every full-tier obligation. */
export function recordQualification(directory) {
const sourceSha = process.env.GITHUB_SHA;
const runId = process.env.GITHUB_RUN_ID;
const runAttempt = process.env.GITHUB_RUN_ATTEMPT;
const repository = process.env.GITHUB_REPOSITORY;
if (
!commitSha(sourceSha) ||
!positiveId(runId) ||
!positiveId(runAttempt) ||
!repository
)
throw new Error(
"Full qualification receipt requires source and workflow identity",
);
const checkoutSha = execFileSync("git", ["rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
if (checkoutSha !== sourceSha)
throw new Error("Qualification checkout differs from workflow source");
mkdirSync(directory, { recursive: true });
const path = resolve(directory, "receipt.json");
writeFileSync(
path,
`${JSON.stringify(
{
schemaVersion: 1,
tier: "full",
sourceSha,
repository,
runId: Number(runId),
runAttempt: Number(runAttempt),
qualifiedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
`receipt_name=${ARTIFACT_NAME}\nreceipt_path=${path}\n`,
);
}
function admit() {
if (process.env.CI_FORCE_FULL === "true")
return {
run_full: true,
reason: "Forced daily runner and dependency refresh",
};
const repository = process.env.CI_REPOSITORY;
const repositoryId = Number(process.env.CI_REPOSITORY_ID);
const branch = process.env.CI_DEFAULT_BRANCH;
const head = process.env.HEAD_SHA;
if (!repository || !positiveId(repositoryId) || !branch || !commitSha(head))
return {
run_full: true,
reason: "Qualification lookup identity is incomplete",
};
const now = Date.now();
const deadline = now + 20_000;
const api = (...arguments_) => {
const remaining = deadline - Date.now();
if (remaining <= 0)
throw new Error("Qualification lookup budget exhausted");
return JSON.parse(
execFileSync("gh", ["api", "--method", "GET", ...arguments_], {
encoding: "utf8",
timeout: Math.min(15_000, remaining),
maxBuffer: 8 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}),
);
};
try {
const { artifacts } = api(
`repos/${repository}/actions/artifacts`,
"-f",
`name=${ARTIFACT_NAME}`,
"-F",
"per_page=100",
);
if (!Array.isArray(artifacts))
throw new Error("Missing qualification artifact inventory");
const candidates = artifacts.filter((artifact) => {
const lineage = artifact?.workflow_run;
const age = now - Date.parse(artifact?.created_at);
return (
artifact?.name === ARTIFACT_NAME &&
artifact?.expired === false &&
typeof artifact?.created_at === "string" &&
Number.isFinite(age) &&
age >= 0 &&
age < MAX_AGE_MS &&
positiveId(lineage?.id) &&
String(lineage.id) !== process.env.GITHUB_RUN_ID &&
lineage?.repository_id === repositoryId &&
lineage?.head_repository_id === repositoryId &&
lineage?.head_branch === branch &&
lineage?.head_sha === head
);
});
// Artifact metadata narrows the search before run lookups. Never scan an
// unbounded history or treat a missing receipt as a successful qualification.
for (const artifact of candidates.slice(0, 3)) {
const run = api(
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
);
// An old run can upload a new artifact when only its gate is rerun.
// Bound the original run age as well so retries cannot renew stale proof.
const runAge = Date.now() - Date.parse(run.created_at);
if (
typeof run.created_at === "string" &&
Number.isFinite(runAge) &&
runAge >= 0 &&
runAge < MAX_AGE_MS &&
run.id === artifact.workflow_run.id &&
run.status === "completed" &&
run.conclusion === "success" &&
run.path === ".github/workflows/ci.yml" &&
["schedule", "workflow_dispatch"].includes(run.event) &&
run.head_sha === head &&
run.head_branch === branch &&
run.head_repository?.full_name === repository
) {
return {
run_full: false,
run_id: run.id,
reason: `Reusing full qualification from run ${run.id}; no fresh provider observation`,
};
}
}
return {
run_full: true,
reason: "No trusted full qualification for this SHA within 24 hours",
};
} catch {
return {
run_full: true,
reason: "Qualification lookup unavailable; running full validation",
};
}
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
const decision = admit();
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries(decision)
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`### Scheduled qualification\n\n${decision.reason}.\n`,
);
console.log(JSON.stringify(decision));
}

View File

@ -1,18 +1,46 @@
import { CI_FLAGS } from "./scopes.mjs";
import { recordQualification } from "./qualification.mjs";
const jobs = JSON.parse(process.env.CI_JOBS);
const outputs = jobs.changes.outputs;
const required = ["changes", "static", "test"];
const outputs = jobs?.changes?.outputs;
if (!["fast", "full", "reused"].includes(outputs?.tier))
throw new Error(
"CI classification requires a valid tier: fast, full, or reused",
);
const invalidFlags = CI_FLAGS.filter(
(flag) => outputs[flag] !== "true" && outputs[flag] !== "false",
);
if (invalidFlags.length)
throw new Error(
`CI classification requires explicit true/false scope outputs: ${invalidFlags.join(", ")}`,
);
const selectedScopes = CI_FLAGS.filter((flag) => outputs[flag] === "true");
const reused = outputs.tier === "reused";
if (
reused &&
(process.env.GITHUB_EVENT_NAME !== "schedule" ||
!/^[1-9]\d*$/.test(outputs.qualification_run_id ?? "") ||
!Number.isSafeInteger(Number(outputs.qualification_run_id)) ||
selectedScopes.length !== 0)
)
throw new Error(
"Reused qualification requires a scheduled admission receipt and no selected lanes",
);
if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
throw new Error("Full qualification requires every canonical CI scope");
const required = reused ? ["changes"] : ["changes", "static", "test"];
const lanes = {
code: ["build", "test-shard", "sqlite-runtime"],
code: ["build", "test-shard"],
runtime: ["windows-native-build", "package-e2e"],
package: ["windows-curated"],
sqlite: ["sqlite-runtime"],
apple: ["apple-artifacts"],
fixtures: ["conformance-fixtures"],
readiness: ["reconstruction-readiness"],
docs: ["docs"],
workflows: ["static"],
};
for (const [scope, value] of Object.entries(outputs)) {
if (value === "true") required.push(...(lanes[scope] ?? [scope]));
}
for (const scope of selectedScopes) required.push(...(lanes[scope] ?? [scope]));
const failed = [...new Set(required)].filter(
(name) => jobs[name]?.result !== "success",
);
@ -23,3 +51,9 @@ for (const [name, job] of Object.entries(jobs)) {
if (failed.length)
throw new Error(`CI lanes did not pass: ${failed.join(", ")}`);
console.log("Every selected CI lane passed");
if (reused)
console.log(
`Qualification reused from run ${outputs.qualification_run_id}; no fresh provider observation`,
);
if (outputs.tier === "full" && process.env.CI_QUALIFICATION_DIRECTORY)
recordQualification(process.env.CI_QUALIFICATION_DIRECTORY);

View File

@ -92,6 +92,55 @@ export const SCOPES = {
"tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts",
"tests/boundary/process/sourceMapUncHosts.test.ts",
],
// Native SQLite runtime compatibility for the producer, public adapters,
// fixtures, shared lifecycle, and the package script that defines its suite.
sqlite: [
"src/sqlite/**",
"src/domain/sqlite/**",
"src/contracts/sqlite/**",
"src/application/sqlite/**",
"src/composition/sqlite.ts",
"src/cli/sqliteCommands.ts",
"src/server/registerSqliteTools.ts",
"src/application/DirectAnalysisStatus*.ts",
"src/application/CapabilityInventory*.ts",
"src/application/AnalysisProvider*.ts",
"src/contracts/toolContracts.ts",
"src/contracts/toolContractTypes.ts",
"src/contracts/toolEffects*.ts",
"src/contracts/toolOutputSchemaPrimitives.ts",
"src/domain/analysisError*.ts",
"src/domain/evidence*.ts",
"src/domain/inputIssueProjection*.ts",
"src/domain/jsonSerialization*.ts",
"src/domain/jsonValue*.ts",
"src/domain/localPath*.ts",
"src/domain/providerAdapterError*.ts",
"src/domain/providerCleanupError*.ts",
"src/domain/result*.ts",
"src/process/OwnedCommand*.ts",
"src/process/ProviderProcess*.ts",
"src/process/PrivateRuntimeRoot*.ts",
"src/artifacts/ArtifactReader*.ts",
"src/artifacts/ArtifactResourceScope*.ts",
"src/artifacts/ArtifactPaths*.ts",
"src/artifacts/readStableArtifact*.ts",
"src/filesystem/OwnedFileHandle*.ts",
"src/server/EvidenceMcpServer*.ts",
"src/server/analysisAdmission*.ts",
"src/server/contractToolHandler*.ts",
"src/server/toolRegistrationOptions*.ts",
"src/server/createServer*.ts",
"src/cli.ts",
"src/cliCommandNames.ts",
"src/cliLogging.ts",
"src/cli/commandCancellation.ts",
"tests/boundary/sqlite/**",
"tests/fixtures/sqlite/**",
"tests/acceptance/sqliteDatabaseCli.test.ts",
"tests/boundary/mcp/serverLifecycleMcp.test.ts",
".github/workflows/sqlite-runtime.yml",
],
browser: [
".github/workflows/real-browser.yml",
"scripts/verify-real-browser.mjs",
@ -307,6 +356,7 @@ export const SCOPES = {
nativeaot: [
"bridge/nativeaot/**",
"scripts/generate-nativeaot-fixtures.mjs",
"scripts/lib/nativeaot-build.*",
"tests/fixtures/nativeaot/**",
".github/workflows/nativeaot-fixtures.yml",
],
@ -319,6 +369,20 @@ export const SCOPES = {
],
};
export const CI_FLAGS = [
...new Set([
"code",
"runtime",
"package",
"apple",
"fixtures",
"readiness",
"docs",
"workflows",
...Object.keys(SCOPES),
]),
];
// Verifier entrypoints that only manual, licensed, or self-hosted workflows
// run. No selected CI lane executes them, so their edits need only the static
// checks and the source-test suite. Shared helpers under scripts/lib remain

114
scripts/ci/test-history.mjs Normal file
View File

@ -0,0 +1,114 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ARTIFACT_NAME = "test-timings";
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_CANDIDATES = 5;
const positiveId = (value) =>
(typeof value === "string" || typeof value === "number") &&
/^[1-9]\d*$/.test(String(value)) &&
Number.isSafeInteger(Number(value));
function findTimingRun({ repository, branch, repositoryId, now = Date.now() }) {
if (
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository ?? "") ||
!branch ||
!positiveId(repositoryId)
)
return "";
const deadline = Date.now() + 20_000;
const api = (...arguments_) => {
const remaining = deadline - Date.now();
if (remaining <= 0)
throw new Error("Timing history lookup budget exhausted");
return JSON.parse(
execFileSync("gh", ["api", "--method", "GET", ...arguments_], {
encoding: "utf8",
timeout: Math.min(10_000, remaining),
maxBuffer: 4 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}),
);
};
const { artifacts } = api(
`repos/${repository}/actions/artifacts`,
"-f",
`name=${ARTIFACT_NAME}`,
"-F",
"per_page=100",
);
if (!Array.isArray(artifacts))
throw new Error("Missing timing artifact inventory");
const candidates = artifacts
.filter((artifact) => {
const lineage = artifact?.workflow_run;
const age = now - Date.parse(artifact?.created_at);
return (
artifact?.name === ARTIFACT_NAME &&
artifact?.expired === false &&
typeof artifact?.created_at === "string" &&
Number.isFinite(age) &&
age >= 0 &&
age < MAX_AGE_MS &&
positiveId(lineage?.id) &&
positiveId(lineage?.repository_id) &&
positiveId(lineage?.head_repository_id) &&
lineage.repository_id === lineage.head_repository_id &&
lineage.repository_id === Number(repositoryId) &&
lineage.head_branch === branch
);
})
.sort(
(left, right) =>
Date.parse(right.created_at) - Date.parse(left.created_at),
)
.slice(0, MAX_CANDIDATES);
for (const artifact of candidates) {
const run = api(
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
);
const runAge = now - Date.parse(run?.created_at);
if (
typeof run?.created_at === "string" &&
Number.isFinite(runAge) &&
runAge >= 0 &&
runAge < MAX_AGE_MS &&
run.id === artifact.workflow_run.id &&
run.status === "completed" &&
run.conclusion === "success" &&
run.path === ".github/workflows/ci.yml" &&
["schedule", "workflow_dispatch"].includes(run.event) &&
run.head_branch === branch &&
run.head_repository?.full_name === repository
)
return String(run.id);
}
return "";
}
function write(runId) {
if (process.env.GITHUB_OUTPUT)
appendFileSync(process.env.GITHUB_OUTPUT, `run-id=${runId}\n`);
console.log(JSON.stringify({ run_id: runId }));
}
if (
process.argv[1] &&
fileURLToPath(import.meta.url) === resolve(process.argv[1])
) {
let runId = "";
try {
runId = findTimingRun({
repository: process.env.CI_REPOSITORY,
branch: process.env.CI_DEFAULT_BRANCH,
repositoryId: process.env.CI_REPOSITORY_ID,
});
} catch {
// History improves shard ordering but must never block CI.
}
write(runId);
}

View File

@ -1,28 +1,36 @@
import { execFileSync } from "node:child_process";
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { z } from "zod";
import { compareTestSelection } from "./test-selection.mjs";
const root = process.cwd();
const historyDirectory = join(root, ".cache/ci-test-history");
const historyPath = join(historyDirectory, "test-results.json");
const selectionDirectory = join(root, ".cache/ci-test-selection");
const selectionPath = join(selectionDirectory, "report.json");
// Never let a failed or partial rerun leave a previous successful history file.
await rm(historyPath, { force: true });
await rm(selectionPath, { force: true });
const report = z
.object({
version: z.literal(1),
success: z.boolean(),
numFailedTests: z.number().int().nonnegative(),
testResults: z.array(
reason: z.string().optional(),
unhandledErrors: z.number().int().nonnegative().optional(),
files: z.array(
z.object({
name: z.string().min(1),
startTime: z.number().finite(),
endTime: z.number().finite(),
durationMs: z.number().finite().nonnegative(),
state: z.string().optional(),
}),
),
})
.parse(JSON.parse(await readFile("test-results.json", "utf8")));
.parse(JSON.parse(await readFile("test-timings.json", "utf8")));
const files = report.testResults.map(({ name, startTime, endTime }) => {
const files = report.files.map(({ name, durationMs, state }) => {
const path = relative(root, resolve(root, name));
if (
path === "" ||
@ -31,32 +39,85 @@ const files = report.testResults.map(({ name, startTime, endTime }) => {
isAbsolute(path)
)
throw new Error(`Test report path is outside the checkout: ${name}`);
const duration = endTime - startTime;
if (!Number.isFinite(duration) || duration < 0)
throw new Error(`Test report has an invalid duration: ${name}`);
return { name: path.split(sep).join("/"), startTime: 0, endTime: duration };
return { name: path.split(sep).join("/"), durationMs, state };
});
const complete = process.env.SHARD_RESULT === "success";
const passed =
complete &&
process.env.MERGE_RESULT === "success" &&
report.success &&
report.numFailedTests === 0;
complete && process.env.MERGE_RESULT === "success" && report.success;
// Successful shards and merged reports must account for the complete portable
// inventory. Coverage thresholds alone cannot prove that every file ran.
const verifyInventory = process.env.CI_VERIFY_TEST_INVENTORY === "true";
const expected =
verifyInventory || process.env.CI_TEST_SELECTION
? execFileSync("git", ["ls-files", "-z", "--", "src", "tests"], {
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
})
.split("\0")
.filter((path) => path.endsWith(".test.ts"))
: [];
let selection;
if (process.env.CI_TEST_SELECTION) {
const comparison = compareTestSelection(
JSON.parse(process.env.CI_TEST_SELECTION),
expected,
files,
);
selection = {
...comparison,
complete:
comparison.complete &&
report.unhandledErrors === 0 &&
["passed", "failed"].includes(report.reason) &&
["success", "failure"].includes(process.env.SHARD_RESULT) &&
["success", "failure"].includes(process.env.MERGE_RESULT),
executionSha: process.env.GITHUB_SHA ?? null,
prHeadSha: process.env.CI_PR_HEAD_SHA ?? null,
runId: process.env.GITHUB_RUN_ID ?? null,
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null,
};
await mkdir(selectionDirectory, { recursive: true });
await writeFile(selectionPath, JSON.stringify(selection));
}
if (passed && verifyInventory) {
const actual = files.map(({ name }) => name);
const expectedSet = new Set(expected);
const actualSet = new Set(actual);
const missing = expected.filter((path) => !actualSet.has(path));
const unexpected = actual.filter((path) => !expectedSet.has(path));
if (missing.length || unexpected.length || actualSet.size !== actual.length)
throw new Error(
`Incomplete portable test inventory: ${JSON.stringify({ missing, unexpected, duplicateFiles: actual.length - actualSet.size })}`,
);
}
const summary = [
complete ? "## Merged test diagnostics" : "## Partial test diagnostics",
"",
...(selection
? [
"### Observational PR test selection",
"",
`Plan: ${selection.plan.mode}. Proposed files: ${selection.selected.length}; omitted: ${selection.omitted.length}.`,
`Full-run comparison: ${selection.complete ? "complete" : "incomplete"}; failures outside proposed selection: ${selection.missedFailures.length}.`,
"The complete suite still ran. This report cannot enable omissions or qualify a revision. Skipped, missing, and interrupted observations do not establish parity.",
"",
]
: []),
`Shard result: ${process.env.SHARD_RESULT ?? "unknown"}; merge result: ${process.env.MERGE_RESULT ?? "unknown"}.`,
passed
? "Successful timing history is available."
: "This report is diagnostic only and is not a successful timing baseline.",
"",
"Durations include tests and hooks, worker preparation, environment, imports, and setup. They are scheduling weights, not summed workflow wall time.",
"",
"| Slowest file | Duration (ms) |",
"| --- | ---: |",
...files
.toSorted((left, right) => right.endTime - left.endTime)
.toSorted((left, right) => right.durationMs - left.durationMs)
.slice(0, 20)
.map(
({ name, endTime }) => `| ${name.replaceAll("|", "\\|")} | ${endTime} |`,
({ name, durationMs }) =>
`| ${name.replaceAll("|", "\\|")} | ${durationMs} |`,
),
"",
].join("\n");
@ -68,6 +129,10 @@ if (passed) {
await mkdir(historyDirectory, { recursive: true });
await writeFile(
historyPath,
JSON.stringify({ success: true, numFailedTests: 0, testResults: files }),
JSON.stringify({
version: 1,
success: true,
files: files.map(({ name, durationMs }) => ({ name, durationMs })),
}),
);
}

View File

@ -0,0 +1,35 @@
export interface TestSelectionPlan {
readonly version: 1;
readonly mode: "full" | "candidate";
readonly owners: readonly string[];
readonly changedTests: readonly string[];
readonly changedPaths: readonly string[];
readonly reason: string;
}
export function planTestSelection(
files: readonly string[],
event: string,
tier: string,
): TestSelectionPlan;
export function compareTestSelection(
plan: unknown,
inventory: readonly string[],
results: readonly {
readonly name: string;
readonly durationMs: number;
readonly state?: string;
}[],
): {
readonly version: 1;
readonly observational: true;
readonly plan: TestSelectionPlan;
readonly selected: readonly string[];
readonly omitted: readonly string[];
readonly missing: readonly string[];
readonly unexpected: readonly string[];
readonly duplicateFiles: number;
readonly unobserved: readonly string[];
readonly missedFailures: readonly string[];
readonly complete: boolean;
readonly omittedDurationMs: number;
};

View File

@ -0,0 +1,151 @@
import { matchesGlob } from "node:path";
// Candidate ownership only: these leaf adapters and their producer tests can be
// compared with full-suite outcomes. This table never changes test execution.
// Shared application/domain code and dynamic CLI/MCP consumers are not leaf owners.
const OWNERS = {
adb: ["src/adb/**", "tests/boundary/providers/adb/**"],
apktool: ["src/apktool/**"],
dotnet: ["src/dotnet/**"],
evm: ["src/evm/**", "tests/boundary/evm/**"],
firmware: ["src/firmware/**", "tests/boundary/firmware/**"],
flutter: ["src/flutter/**"],
ghidra: ["src/ghidra/**", "tests/boundary/providers/ghidra/**"],
hopper: ["src/hopper/**", "tests/boundary/providers/hopper/**"],
ida: ["src/ida/**", "tests/boundary/providers/ida/**"],
jeb: ["src/jeb/**"],
pcap: ["src/pcap/**", "tests/boundary/pcap/**"],
sqlite: ["src/sqlite/**", "tests/boundary/sqlite/**"],
wasm: ["src/wasm/**"],
};
const ownersOf = (path) =>
Object.entries(OWNERS)
.filter(([, patterns]) => patterns.some((glob) => matchesGlob(path, glob)))
.map(([owner]) => owner);
const validPath = (path) =>
typeof path === "string" &&
/^(src|tests)\//.test(path) &&
!/[\\\0\r\n]/.test(path) &&
!path.split("/").some((part) => ["", ".", ".."].includes(part));
const full = (reason, changedPaths = []) => ({
version: 1,
mode: "full",
owners: [],
changedTests: [],
changedPaths,
reason,
});
/** Plan an observational PR subset from the classifier's existing Git diff. */
export function planTestSelection(files, event, tier) {
if (event !== "pull_request" || tier !== "fast")
return full("Only ordinary PRs are candidates", files);
if (!files.length) return full("No changed paths available");
const owners = new Set();
const changedTests = [];
for (const path of files) {
if (!validPath(path))
return full(`Shared or unknown input: ${path}`, files);
const matches = ownersOf(path);
if (!matches.length) return full(`Shared or unknown input: ${path}`, files);
for (const owner of matches) owners.add(owner);
if (path.endsWith(".test.ts")) changedTests.push(path);
}
return {
version: 1,
mode: "candidate",
owners: [...owners].sort(),
changedTests: [...new Set(changedTests)].sort(),
changedPaths: files,
reason:
"Observe affected leaf owners; retain integration and unowned tests",
};
}
/** Compare a proposal with the full run; missing observations never prove parity. */
export function compareTestSelection(plan, inventory, results) {
if (
plan?.version !== 1 ||
!["full", "candidate"].includes(plan.mode) ||
!Array.isArray(plan.owners) ||
plan.owners.some((owner) => !Object.hasOwn(OWNERS, owner)) ||
!Array.isArray(plan.changedTests) ||
!Array.isArray(plan.changedPaths) ||
plan.changedPaths.some((path) => typeof path !== "string") ||
plan.changedTests.some(
(path) => !validPath(path) || !path.endsWith(".test.ts"),
) ||
typeof plan.reason !== "string" ||
(plan.mode === "candidate" && !plan.owners.length) ||
(plan.mode === "full" && (plan.owners.length || plan.changedTests.length))
)
throw new Error("Invalid observational test selection plan");
const expected = new Set(inventory);
if (!expected.size || expected.size !== inventory.length)
throw new Error("Test selection requires a nonempty unique inventory");
const ownersByFile = new Map(
plan.mode === "candidate"
? inventory.map((path) => [path, ownersOf(path)])
: [],
);
// Removed/renamed tests have no current executable identity. Keep all tests.
const removed = plan.changedTests.filter((path) => !expected.has(path));
const missingOwners = plan.owners.filter(
(owner) =>
![...ownersByFile.values()].some((owners) => owners.includes(owner)),
);
const effective = removed.length
? full(
`Changed tests absent from current inventory: ${removed.join(", ")}`,
plan.changedPaths,
)
: missingOwners.length
? full(
`Owners without executable tests: ${missingOwners.join(", ")}`,
plan.changedPaths,
)
: plan;
const selected = [];
const omitted = [];
for (const path of inventory.toSorted()) {
const owners = ownersByFile.get(path) ?? [];
const keep =
effective.mode === "full" ||
!owners.length ||
effective.changedTests.includes(path) ||
owners.some((owner) => effective.owners.includes(owner));
(keep ? selected : omitted).push(path);
}
const actual = new Set(results.map((file) => file.name));
const missing = inventory.filter((path) => !actual.has(path));
const unexpected = [...actual].filter((path) => !expected.has(path));
const duplicateFiles = results.length - actual.size;
const unobserved = results
.filter((file) => !["passed", "failed"].includes(file.state))
.map((file) => file.name);
const omittedSet = new Set(omitted);
const missedFailures = results
.filter((file) => omittedSet.has(file.name) && file.state === "failed")
.map((file) => file.name);
return {
version: 1,
observational: true,
plan: effective,
selected,
omitted,
missing,
unexpected,
duplicateFiles,
unobserved,
missedFailures,
complete:
!missing.length &&
!unexpected.length &&
!duplicateFiles &&
!unobserved.length,
// Phase totals are scheduling weights, not an estimate of wall-time savings.
omittedDurationMs: results
.filter((file) => omittedSet.has(file.name))
.reduce((sum, file) => sum + file.durationMs, 0),
};
}

View File

@ -0,0 +1,43 @@
import { writeFile } from "node:fs/promises";
/** @implements {import("vitest/node").Reporter} */
export default class TestTimingReporter {
/**
* @param {ReadonlyArray<import("vitest/node").TestModule>} modules
* @param {ReadonlyArray<import("vitest/node").SerializedError>} errors
* @param {import("vitest/node").TestRunEndReason} reason
*/
async onTestRunEnd(modules, errors, reason) {
const files = modules.map((module) => {
const diagnostic = module.diagnostic();
// These disjoint phases match Vitest's per-file wall-time accounting.
// Import timings already include transform waits; do not add them again.
const phases = {
execution: diagnostic.duration,
preparation: diagnostic.prepareDuration,
environment: diagnostic.environmentSetupDuration,
imports: diagnostic.collectDuration,
setup: diagnostic.setupDuration,
};
return {
name: module.moduleId,
state: module.state(),
durationMs: Object.values(phases).reduce(
(total, value) => total + value,
0,
),
phases,
};
});
await writeFile(
"test-timings.json",
JSON.stringify({
version: 1,
success: reason === "passed" && errors.length === 0,
reason,
unhandledErrors: errors.length,
files,
}),
);
}
}

View File

@ -0,0 +1,235 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { execFile } from "node:child_process";
import { copyFile, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const MANIFEST_NAME = "manifest.json";
const TARBALL_NAME = "package.tgz";
const MANIFEST_KEYS = [
"schemaVersion",
"sourceSha",
"runId",
"runAttempt",
"platform",
"architecture",
"packageVersion",
"nodeVersion",
"sha256",
].sort();
const execFileAsync = promisify(execFile);
/** Normalize workflow identity and host facts into the manifest-owned fields. */
export function createVerifiedPackageMetadata({
sourceSha,
runId,
runAttempt,
platform,
architecture,
packageVersion,
nodeVersion,
}) {
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("Source SHA must be a full commit SHA");
return {
sourceSha,
runId: parsePositiveInteger(runId, "GITHUB_RUN_ID"),
runAttempt: parsePositiveInteger(runAttempt, "GITHUB_RUN_ATTEMPT"),
platform,
architecture,
packageVersion,
nodeVersion,
};
}
/** Build the single supported manifest representation for a verified tarball. */
function createVerifiedPackageManifest(metadata) {
const manifest = { schemaVersion: 1, ...metadata };
validateManifestShape(manifest);
return manifest;
}
/** Write a manifest after the caller has completed qualification and cleanup. */
export async function writeVerifiedPackageManifest(directory, metadata) {
const manifest = createVerifiedPackageManifest(metadata);
await writeFile(
join(directory, MANIFEST_NAME),
`${JSON.stringify(manifest, null, 2)}\n`,
{ encoding: "utf8" },
);
return manifest;
}
/** Copy the qualified archive under its fixed name and prepare its manifest. */
export async function retainVerifiedPackage({
sourceTarball,
directory,
metadata,
}) {
const tarball = join(directory, TARBALL_NAME);
await copyFile(sourceTarball, tarball);
const sha256 = await sha256File(tarball);
return {
directory,
metadata: createVerifiedPackageManifest({ ...metadata, sha256 }),
};
}
/** Validate a retained package against the current checkout and workflow attempt. */
async function validateVerifiedPackage({
directory,
sourceSha,
runId,
runAttempt,
packageVersion,
nodeVersion,
}) {
if (!/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("Expected source SHA must be a full commit SHA");
if (!Number.isSafeInteger(runId) || runId <= 0)
throw new Error("Expected workflow run ID must be a positive integer");
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0)
throw new Error("Expected workflow run attempt must be a positive integer");
const manifestPath = join(directory, MANIFEST_NAME);
let manifest;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (cause) {
throw new Error(`Could not read verified package manifest: ${cause}`, {
cause,
});
}
validateManifestShape(manifest);
assertEqual(manifest.sourceSha, sourceSha, "source SHA");
assertEqual(manifest.runId, runId, "workflow run ID");
// A successful producer may belong to an earlier attempt when only failed
// publisher jobs are rerun. The workflow still requires same-run qualification.
if (manifest.runAttempt > runAttempt)
throw new Error("Verified package workflow run attempt is from the future");
assertEqual(manifest.platform, "linux", "verifier platform");
assertEqual(manifest.architecture, "x64", "verifier architecture");
assertEqual(manifest.packageVersion, packageVersion, "package version");
assertEqual(manifest.nodeVersion, nodeVersion, "Node.js version");
// The artifact format deliberately fixes the name; no manifest value can
// redirect validation to another path.
const tarball = resolve(directory, TARBALL_NAME);
const digest = await sha256File(tarball);
assertEqual(manifest.sha256, digest, "tarball SHA-256");
return tarball;
}
function validateManifestShape(manifest) {
if (
typeof manifest !== "object" ||
manifest === null ||
Array.isArray(manifest)
)
throw new Error("Verified package manifest must be a JSON object");
const keys = Object.keys(manifest).sort();
if (
keys.length !== MANIFEST_KEYS.length ||
keys.some((key, index) => key !== MANIFEST_KEYS[index])
)
throw new Error("Verified package manifest has missing or unknown fields");
if (manifest.schemaVersion !== 1)
throw new Error("Unsupported verified package manifest schema version");
if (
typeof manifest.sourceSha !== "string" ||
!/^[a-f0-9]{40}$/u.test(manifest.sourceSha)
)
throw new Error("Verified package manifest has an invalid source SHA");
if (!Number.isSafeInteger(manifest.runId) || manifest.runId <= 0)
throw new Error("Verified package manifest has an invalid workflow run ID");
if (!Number.isSafeInteger(manifest.runAttempt) || manifest.runAttempt <= 0)
throw new Error(
"Verified package manifest has an invalid workflow run attempt",
);
for (const key of [
"platform",
"architecture",
"packageVersion",
"nodeVersion",
])
if (typeof manifest[key] !== "string" || manifest[key].length === 0)
throw new Error(`Verified package manifest has an invalid ${key}`);
if (
typeof manifest.sha256 !== "string" ||
!/^[a-f0-9]{64}$/u.test(manifest.sha256)
)
throw new Error("Verified package manifest has an invalid tarball SHA-256");
}
function assertEqual(actual, expected, label) {
if (actual !== expected)
throw new Error(`Verified package ${label} mismatch`);
}
async function sha256File(path) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
}
async function validateFromCli(directory) {
const sourceSha = process.env.GITHUB_SHA;
const runId = parsePositiveInteger(
process.env.GITHUB_RUN_ID,
"GITHUB_RUN_ID",
);
const runAttempt = parsePositiveInteger(
process.env.GITHUB_RUN_ATTEMPT,
"GITHUB_RUN_ATTEMPT",
);
if (sourceSha === undefined || !/^[a-f0-9]{40}$/u.test(sourceSha))
throw new Error("GITHUB_SHA must be a full commit SHA");
const checkoutSha = (
await execFileAsync("git", ["rev-parse", "HEAD"])
).stdout.trim();
assertEqual(checkoutSha, sourceSha, "checked out source SHA");
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const tarball = await validateVerifiedPackage({
directory,
sourceSha,
runId,
runAttempt,
packageVersion: packageJson.version,
nodeVersion: process.version,
});
if (process.env.GITHUB_OUTPUT === undefined)
throw new Error(
"GITHUB_OUTPUT is required to publish the validated tarball path",
);
await writeFile(process.env.GITHUB_OUTPUT, `tarball=${tarball}\n`, {
encoding: "utf8",
flag: "a",
});
process.stdout.write(`${tarball}\n`);
}
function parsePositiveInteger(value, name) {
if (value === undefined || !/^[1-9]\d*$/u.test(value))
throw new Error(`${name} must be a positive integer`);
const parsed = Number(value);
if (!Number.isSafeInteger(parsed))
throw new Error(`${name} must be a safe positive integer`);
return parsed;
}
const invokedPath = process.argv[1];
if (
invokedPath !== undefined &&
resolve(invokedPath) === fileURLToPath(import.meta.url)
) {
const [command, directory, ...extra] = process.argv.slice(2);
if (command !== "validate" || directory === undefined || extra.length > 0) {
process.stderr.write(
"Usage: node scripts/ci/verified-package.mjs validate <artifact-directory>\n",
);
process.exitCode = 2;
} else {
await validateFromCli(directory);
}
}

View File

@ -4,9 +4,14 @@ import { createHash } from "node:crypto";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
buildNativeAotFixture,
nativeAotCommandError,
supportsNativeAotFixtureHost,
} from "./lib/nativeaot-build.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
if (process.arch !== "x64" || !["linux", "win32"].includes(process.platform))
if (!supportsNativeAotFixtureHost(process.platform, process.arch))
throw new Error(
"NativeAOT fixture lane requires Linux or Windows x64; it does not cross-compile NativeAOT.",
);
@ -40,9 +45,7 @@ const run = (command, args) => {
maxBuffer: 8 * 1024 * 1024,
});
if (result.error || result.status !== 0)
throw new Error(
`NativeAOT fixture lane requires/action failed ${command}: ${result.error?.message ?? ""}\n${result.stdout ?? ""}${result.stderr ?? ""}`,
);
throw nativeAotCommandError(command, args[0], result);
return result.stdout;
};
const dotnet = process.env.REA_NATIVEAOT_DOTNET ?? "dotnet";
@ -57,20 +60,12 @@ if (process.platform === "linux") {
run("clang", ["--version"]);
}
const map = join(output, "NativeAotFixture.map");
run(dotnet, [
"publish",
join(root, "tests/fixtures/nativeaot/NativeAotFixture.csproj"),
"-r",
await buildNativeAotFixture(run, {
dotnet,
project: join(root, "tests/fixtures/nativeaot/NativeAotFixture.csproj"),
runtime,
"-c",
"Release",
"-o",
join(output, "symbols"),
"--maxcpucount:1",
`-p:BaseIntermediateOutputPath=${join(output, "obj")}/`,
`-p:BaseOutputPath=${join(output, "bin")}/`,
`-p:NativeAotMapPath=${map}`,
]);
output,
});
const name =
process.platform === "win32" ? "NativeAotFixture.exe" : "NativeAotFixture";
const binary = join(output, "symbols", name);

View File

@ -0,0 +1,25 @@
export function supportsNativeAotFixtureHost(
platform: string,
architecture: string,
): boolean;
export function buildNativeAotFixture(
run: (command: string, args: string[]) => unknown,
options: {
dotnet: string;
project: string;
runtime: string;
output: string;
},
): Promise<void>;
export function nativeAotCommandError(
command: string,
operation: string,
result: {
error?: { message: string };
status: number | null;
stdout?: string | null;
stderr?: string | null;
},
): Error;

View File

@ -0,0 +1,60 @@
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
/** NativeAOT fixtures use native toolchains, not cross-compilation. */
export function supportsNativeAotFixtureHost(platform, architecture) {
return architecture === "x64" && ["linux", "win32"].includes(platform);
}
/** Keep network recovery separate from compilation and preserve restore failures. */
export async function buildNativeAotFixture(run, options) {
const { dotnet, project, runtime, output } = options;
const common = [
project,
"-r",
runtime,
"--maxcpucount:1",
"-p:Configuration=Release",
`-p:BaseIntermediateOutputPath=${join(output, "obj")}/`,
`-p:BaseOutputPath=${join(output, "bin")}/`,
`-p:NativeAotMapPath=${join(output, "NativeAotFixture.map")}`,
];
for (let attempt = 1; attempt <= 2; attempt++) {
console.error(`NativeAOT dependency restore: attempt ${attempt}/2`);
try {
run(dotnet, ["restore", ...common, "--verbosity", "normal"]);
break;
} catch (cause) {
if (attempt === 2)
throw new Error(
"NativeAOT dependency restore failed after two attempts",
{
cause,
},
);
// Child diagnostics can contain authenticated feed URLs; keep retry logs static.
console.error(
"NativeAOT dependency restore failed; retrying in 1 second",
);
await delay(1_000);
}
}
console.error("NativeAOT compilation (dependencies already restored)");
run(dotnet, [
"publish",
...common,
"--no-restore",
"-o",
join(output, "symbols"),
]);
}
/** Sanitize child diagnostics before any retry, cause chain, or uncaught output. */
export function nativeAotCommandError(command, operation, result) {
const diagnostic = `NativeAOT fixture lane requires/action failed ${command} ${operation}: ${result.error?.message ?? `exit ${result.status}`}\n${result.stdout ?? ""}${result.stderr ?? ""}`;
// Only URL authority userinfo is authentication; preserve host, path, query,
// and unrelated diagnostic values. Never retain the raw result as a cause.
return new Error(
diagnostic.replace(/\b(https?:\/\/)[^\\\s/?#]*@/giu, "$1[redacted]@"),
);
}

View File

@ -0,0 +1,264 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../dist/domain/evidence.js";
import {
requireMcpOperationResult,
requireMcpToolError,
mcpTextValue,
} from "./mcp-verifier-results.mjs";
/** Exercise file coordinates through public CLI/MCP using the source-owned PE fixture. */
export async function verifyGhidraFileOffsets(
targetPath,
entrypoint,
procedure,
) {
const original = await readFile(targetPath);
const workspace = await mkdtemp(join(tmpdir(), "rea-file-offsets-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const env = {
...process.env,
TEMP: runtime,
TMP: runtime,
TMPDIR: runtime,
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "silent",
};
const client = new Client({ name: "rea-file-offsets", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
let connected = false;
let complete = false;
let primaryFailure;
const call = async (name, arguments_ = {}) => {
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 240_000 },
);
const projected = requireMcpOperationResult(result, name);
return ["open_binary", "binary_session", "close_binary"].includes(name)
? projected
: (result.structuredContent ?? JSON.parse(mcpTextValue(result)));
};
try {
await client.connect(transport);
connected = true;
await call("open_binary", { path: targetPath, provider_id: "ghidra" });
const session = await call("binary_session");
const mapping = parseEvidence(
await call("address_to_file_offset", { address: procedure }),
);
const offset = mapping.normalized_result.file_offset;
const coordinate = `0x${offset.toString(16)}`;
const expected = parseEvidence(
await call("procedure_pseudo_code", { procedure }),
);
const explicit = parseEvidence(
await call("procedure_pseudo_code", {
procedure: `file-offset:${coordinate}`,
}),
);
const automatic = parseEvidence(
await call("procedure_pseudo_code", { procedure: coordinate }),
);
for (const actual of [explicit, automatic]) {
assert.equal(actual.normalized_result, expected.normalized_result);
assert.equal(actual.raw_result.selection.file_offset, offset);
assert.equal(actual.raw_result.selection.mapped_address, procedure);
assert.ok(
actual.locations.some(
(location) =>
location.kind === "file-offset" && location.offset === offset,
),
);
}
const invalid = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x20000000000000" },
});
assert.equal(requireMcpToolError(invalid).code, "invalid_request");
const unmapped = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x100000" },
});
assert.ok(
JSON.stringify(requireMcpToolError(unmapped)).includes(
"no loaded source mapping",
),
);
assert.equal(
(await call("binary_session")).analysis_run.run_id,
session.analysis_run.run_id,
);
const image = parseEvidence(await call("inspect_native_load_image"));
const headerMapping = image.normalized_result.observations.mappings.find(
(item) => item.initialized && item.file_offset === 0,
);
assert.ok(
headerMapping,
"Fixture header must have an observed source mapping",
);
const headerOffset = parseEvidence(
await call("address_to_file_offset", { address: headerMapping.start }),
);
assert.equal(headerOffset.normalized_result.file_offset, 0);
for (const selector of ["file-offset:0x0", "0x0"]) {
const missing = requireMcpToolError(
await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: selector },
}),
);
assertMissingMappedProcedure(missing, headerMapping.start);
}
assert.equal(
(await call("binary_session")).analysis_run.run_id,
session.analysis_run.run_id,
);
await call("close_binary");
const cli = JSON.parse(
(
await promisify(execFile)(
process.execPath,
[
entrypoint,
"decompile",
targetPath,
`--file-offset=${coordinate}`,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
)
).stdout,
);
assert.equal(cli.normalized_result, explicit.normalized_result);
assert.deepEqual(cli.locations, explicit.locations);
const missingCli = await promisify(execFile)(
process.execPath,
[
entrypoint,
"decompile",
targetPath,
"--file-offset=0x0",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 },
).then(
() => {
throw new Error(
"Mapped header bytes must not decompile as a procedure",
);
},
(cause) => JSON.parse(cause.stdout),
);
assertMissingMappedProcedure(missingCli, headerMapping.start);
const alias = Buffer.from(original);
const pe = alias.readUInt32LE(0x3c);
if (alias.readUInt16LE(pe + 6) >= 2) {
const secondSection = pe + 24 + alias.readUInt16LE(pe + 20) + 40;
// Both sections now carry the same original bytes at distinct virtual addresses.
alias.writeUInt32LE(0x200, secondSection + 20);
const ambiguousPath = join(workspace, "ambiguous.exe");
await writeFile(ambiguousPath, alias);
await call("open_binary", { path: ambiguousPath, provider_id: "ghidra" });
const ambiguous = await client.callTool(
{
name: "procedure_pseudo_code",
arguments: { procedure: `file-offset:${coordinate}` },
},
{ timeout: 240_000 },
);
assert.ok(
JSON.stringify(requireMcpToolError(ambiguous)).includes(
"multiple loaded mappings",
),
);
await call("close_binary");
}
assert.deepEqual(await readFile(targetPath), original);
await client.close();
connected = false;
assert.deepEqual(await readdir(runtime), []);
complete = true;
return {
cli_mcp_parity: true,
explicit_and_numeric_offsets: true,
invalid_and_unmapped_offsets: true,
mapped_offset_without_procedure: true,
ambiguous_mappings:
alias.readUInt16LE(pe + 6) >= 2
? "verified"
: "unavailable-single-section",
source_unchanged: true,
cleanup: "complete",
};
} catch (cause) {
primaryFailure = cause;
throw cause;
} finally {
const cleanupFailures = [];
if (connected) {
try {
await call("close_binary");
} catch (cause) {
cleanupFailures.push(cause);
}
}
try {
await client.close();
} catch (cause) {
cleanupFailures.push(cause);
}
if (cleanupFailures.length > 0) {
process.stderr.write(
`Retained file-offset verifier workspace: ${workspace}\n`,
);
throw new AggregateError(
primaryFailure === undefined
? cleanupFailures
: [primaryFailure, ...cleanupFailures],
"File-offset verifier cleanup failed",
);
}
if (complete) await rm(workspace, { recursive: true, force: true });
else
process.stderr.write(
`Retained file-offset verifier workspace: ${workspace}\n`,
);
}
}
function assertMissingMappedProcedure(error, mappedAddress) {
assert.equal(error.code, "invalid_request");
const message = error.details?.issues?.[0]?.message;
assert.equal(typeof message, "string");
assert.ok(
message.includes("File offset 0x0 maps to analysis address"),
message,
);
assert.ok(message.includes(mappedAddress), message);
assert.ok(message.includes("no procedure starts at or contains"), message);
assert.ok(!message.includes("--file-offset"), message);
}

View File

@ -1,6 +1,8 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, unlink } from "node:fs/promises";
import { execFile } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
@ -21,12 +23,19 @@ import { verifyRepositorySkill } from "./verify-repository-skill.mjs";
import { verifyManaged } from "./verify-package-managed.mjs";
import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import {
createVerifiedPackageMetadata,
retainVerifiedPackage,
writeVerifiedPackageManifest,
} from "./ci/verified-package.mjs";
import {
measureMcpStartup,
profileMcpModuleLoading,
} from "./lib/mcp-startup-probe.mjs";
const root = process.cwd();
const execFileAsync = promisify(execFile);
const retentionDirectory = process.env.REA_VERIFIED_PACKAGE_DIRECTORY;
const verifierRun = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-package-"));
const evidenceRoot = join(workspace, "evidence");
@ -37,6 +46,8 @@ let mcpModuleLoading;
let update;
let repositorySkill;
let report;
let retainedPackage;
let retentionMetadata;
const runStage = async (name, action) => {
const started = performance.now();
@ -49,6 +60,27 @@ const runStage = async (name, action) => {
};
try {
if (retentionDirectory !== undefined && retentionDirectory !== "") {
const sourceSha = (
await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })
).stdout.trim();
retentionMetadata = createVerifiedPackageMetadata({
sourceSha,
runId: process.env.GITHUB_RUN_ID,
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
platform: process.platform,
architecture: process.arch,
packageVersion: PRODUCT_IDENTITY.packageVersion,
nodeVersion: process.version,
});
await mkdir(retentionDirectory, { recursive: true });
await unlink(join(retentionDirectory, "manifest.json")).catch((cause) => {
if (
!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")
)
throw cause;
});
}
repositorySkill = await runStage("repository skill", () =>
verifyRepositorySkill({ root, workspace }),
);
@ -215,6 +247,13 @@ try {
mcpStartup,
mcpModuleLoading,
};
if (retentionMetadata !== undefined) {
retainedPackage = await retainVerifiedPackage({
sourceTarball: tarball,
directory: retentionDirectory,
metadata: retentionMetadata,
});
}
} finally {
await runStage("workspace cleanup", () =>
rm(workspace, { recursive: true, force: true }),
@ -223,6 +262,11 @@ try {
const verifierLineage = await runStage("verifier lineage", () =>
completeVerifierRun(verifierRun),
);
if (retainedPackage !== undefined)
await writeVerifiedPackageManifest(
retainedPackage.directory,
retainedPackage.metadata,
);
process.stdout.write(
`${JSON.stringify({ verifier_run: verifierLineage, ...report })}\n`,
);

View File

@ -0,0 +1,313 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
if (process.argv.length !== 2 || process.platform !== "linux")
throw new Error("Run this ELF verifier on Linux without arguments.");
if (!process.env.GHIDRA_INSTALL_DIR)
throw new Error("Set GHIDRA_INSTALL_DIR to a caller-installed Ghidra.");
const exec = promisify(execFile);
const run = createVerifierRun();
const root = fileURLToPath(new URL("..", import.meta.url));
const workspace = await mkdtemp(join(tmpdir(), "rea-address-context-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const path = join(workspace, "pie.elf");
const entrypoint = join(root, "scripts/rea.mjs");
const env = {
...process.env,
TMPDIR: runtime,
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "silent",
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-address-context", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
let connected = false;
let primaryFailure;
let verifierRun;
try {
const compiler = process.env.CC ?? "gcc";
const version = await exec(compiler, ["--version"], { timeout: 10000 });
await exec(
compiler,
[
"-O0",
"-g",
"-fPIE",
"-pie",
"-nostdlib",
"-Wl,-e,rea_pie_entry",
"-Wl,--build-id=none",
join(root, "tests/conformance/c/pie-address-context.c"),
"-o",
path,
],
{ timeout: 30000 },
);
const bytes = await readFile(path);
assert.equal(bytes.subarray(0, 6).toString("hex"), "7f454c460201");
assert.equal(bytes.readUInt16LE(16), 3); // ET_DYN: source-owned x86-64 PIE.
assert.equal(bytes.readUInt16LE(18), 62);
const declaredEntry = `0x${bytes.readBigUInt64LE(24).toString(16)}`;
const sha256 = createHash("sha256").update(bytes).digest("hex");
await client.connect(transport);
connected = true;
await call("open_binary", { path, provider_id: "ghidra" });
const overview = parseEvidence(await call("binary_overview"));
assert.equal(overview.subject.digest.sha256, sha256);
const base = overview.normalized_result.image_base;
assert.match(base, /^0x[0-9a-f]+$/u);
assert.notEqual(base, "0x0");
const active = await call("binary_session");
const image = parseEvidence(await call("inspect_native_load_image"));
assert.equal(base, image.normalized_result.observations.image_base);
assert.ok(overview.normalized_result.segments.length > 0);
assert.ok(
overview.normalized_result.segments.every(
(segment) =>
segment.image_base === base &&
typeof segment.address_space === "string",
),
);
const procedures = parseEvidence(await call("list_procedures"));
const entry = procedures.normalized_result.find(
(item) => item.value === "rea_pie_entry",
);
const addition = procedures.normalized_result.find(
(item) => item.value === "rea_pie_add",
);
assert.ok(entry && addition);
assert.notEqual(entry.address, declaredEntry);
assert.ok(
image.normalized_result.observations.entry_points.includes(entry.address),
);
const mapping = image.normalized_result.observations.mappings.find(
(item) =>
item.source_file_index === 0 &&
item.file_offset !== null &&
item.address_space === "ram" &&
BigInt(item.start) <= BigInt(entry.address) &&
BigInt(item.end) >= BigInt(entry.address),
);
assert.ok(mapping);
const offset =
mapping.file_offset + Number(BigInt(entry.address) - BigInt(mapping.start));
const reverseMapping = parseEvidence(
await call("address_to_file_offset", { address: entry.address }),
);
assert.equal(reverseMapping.normalized_result.file_offset, offset);
const memory = parseEvidence(
await call("read_bytes", { address: entry.address, length: 8 }),
);
assert.equal(
memory.normalized_result.bytes_hex,
bytes.subarray(offset, offset + 8).toString("hex"),
);
const fromOffset = parseEvidence(
await call("procedure_pseudo_code", {
procedure: `file-offset:0x${offset.toString(16)}`,
}),
);
assert.ok(
fromOffset.locations.some(
(location) =>
location.kind === "file-offset" && location.offset === offset,
),
);
assert.equal(fromOffset.raw_result.selection.mapped_address, entry.address);
const fromNumericOffset = parseEvidence(
await call("procedure_pseudo_code", {
procedure: `0x${offset.toString(16)}`,
}),
);
assert.equal(
fromNumericOffset.normalized_result,
fromOffset.normalized_result,
);
const missingAddress = "ram:0xffffffff";
const rejected = await client.callTool(
{ name: "procedure_pseudo_code", arguments: { procedure: missingAddress } },
{ timeout: 240000 },
);
assert.equal(rejected.isError, true);
assert.ok(
JSON.stringify(rejected.content).includes(`Ghidra image base: ${base}`),
);
assert.ok(JSON.stringify(rejected.content).includes("list_procedures"));
assert.ok(
JSON.stringify(rejected.content).includes("inspect_native_load_image"),
);
const pseudo = parseEvidence(
await call("procedure_pseudo_code", { procedure: addition.address }),
);
assert.match(pseudo.normalized_result, /\+\s*(?:7|0x7)\b/u);
const retained = await call("binary_session");
assert.equal(retained.analysis_run.run_id, active.analysis_run.run_id);
await call("close_binary");
const cli = async (args) =>
exec(
process.execPath,
[entrypoint, ...args, "--provider", "ghidra", "--json"],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cliOverview = parseEvidence(
JSON.parse((await cli(["analyze", path])).stdout),
);
assert.deepEqual(cliOverview.normalized_result, overview.normalized_result);
let cliFailure;
try {
await cli(["decompile", path, missingAddress]);
} catch (cause) {
cliFailure = cause;
}
assert.ok(cliFailure && typeof cliFailure.stdout === "string");
const error = JSON.parse(cliFailure.stdout);
assert.equal(error.code, "invalid_request");
assert.ok(JSON.stringify(error).includes(`Ghidra image base: ${base}`));
for (const command of [
"rea search <path> . --kind procedures --mode regex",
"rea analyze <path>",
"rea inspect-native-load-image <path>",
])
assert.ok(JSON.stringify(error).includes(command));
const cliProcedures = parseEvidence(
JSON.parse(
(
await cli([
"search",
path,
".",
"--kind",
"procedures",
"--mode",
"regex",
])
).stdout,
),
);
const cliEntry = cliProcedures.normalized_result.find(
(item) => item.value === "rea_pie_entry",
);
assert.ok(cliEntry);
assert.equal(cliEntry.address, entry.address);
const cliImage = parseEvidence(
JSON.parse((await cli(["inspect-native-load-image", path])).stdout),
);
assert.equal(cliImage.normalized_result.observations.image_base, base);
const cliPseudo = parseEvidence(
JSON.parse((await cli(["decompile", path, cliEntry.address])).stdout),
);
const cliOffset = parseEvidence(
JSON.parse(
(await cli(["decompile", path, `--file-offset=0x${offset.toString(16)}`]))
.stdout,
),
);
assert.equal(cliOffset.normalized_result, fromOffset.normalized_result);
assert.deepEqual(cliOffset.locations, fromOffset.locations);
assert.match(cliPseudo.normalized_result, /rea_pie_add/u);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
report = {
ok: true,
mocked: false,
provider: image.provider,
compiler: version.stdout.split("\n")[0],
sha256,
declared_elf_entry: declaredEntry,
loaded_entry: entry.address,
observed_image_base: base,
cli_mcp_overview_parity: true,
failed_lookup_guidance: true,
cli_recovery_commands: true,
loaded_decompilation: true,
file_offset_decompilation: true,
address_file_offset_round_trip: true,
source_unchanged: true,
runtime_cleanup: "complete",
};
} catch (cause) {
primaryFailure = { cause };
if (stderr) process.stderr.write(stderr);
}
const cleanupFailures = [];
// Await provider-owned cleanup before the SDK can terminate its MCP parent.
if (connected) {
try {
await call("close_binary");
} catch (cause) {
cleanupFailures.push(cause);
}
}
try {
await client.close();
} catch (cause) {
cleanupFailures.push(cause);
}
try {
await transport.close();
} catch (cause) {
cleanupFailures.push(cause);
}
try {
assert.deepEqual(await readdir(runtime), []);
verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
} catch (cause) {
cleanupFailures.push(cause);
}
if (cleanupFailures.length === 0) {
try {
await rm(workspace, { recursive: true, force: true });
} catch (cause) {
cleanupFailures.push(cause);
}
}
if (cleanupFailures.length > 0) {
process.stderr.write(
`Retained verifier workspace after cleanup failure: ${workspace}\n`,
);
throw new AggregateError(
primaryFailure === undefined
? cleanupFailures
: [primaryFailure.cause, ...cleanupFailures],
"Ghidra address-context verifier cleanup failed",
);
}
if (primaryFailure !== undefined) throw primaryFailure.cause;
process.stdout.write(
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
);
async function call(name, args = {}) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
return ["open_binary", "binary_session", "close_binary"].includes(name)
? requireMcpOperationResult(result, name)
: result.structuredContent;
}

View File

@ -28,6 +28,7 @@ import {
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
import { verifyGhidraFileOffsets } from "./lib/real-ghidra-file-offsets.mjs";
const verifierRun = createVerifierRun();
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
@ -300,6 +301,11 @@ try {
}
if (report === undefined)
throw new Error("Windows Ghidra report was not produced");
report.file_offsets = await verifyGhidraFileOffsets(
targetPath,
resolve(root, "scripts", "rea.mjs"),
architecture === "x86" ? "0x401010" : "0x140001010",
);
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`,
);

View File

@ -114,7 +114,7 @@ def shared_blocks(root, path, source):
}
from website_i18n import language_markup
selector, alternates = language_markup(root, path.relative_to(site).as_posix())
values["language_picker"] = selector if not metadata.noindex else ""
values["language_picker"] = indent(selector, " ") if not metadata.noindex else ""
values["alternates"] = alternates if not metadata.noindex else ""
templates = root / "website/templates"
return {

View File

@ -21,8 +21,8 @@ import {
parseFunctionDossier,
parseListCount,
parseRelatedAddresses,
parseSegmentImageBase,
parseSegments,
parseSegmentImageBase,
} from "../domain/hopperValues.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@ -46,6 +46,7 @@ import { traceCallPath } from "./CallPathTracing.js";
import { resolveProcedureAddress } from "./ProcedureAddressResolution.js";
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
/**
* Composes direct provider operations into bounded reverse-engineering tools.
* Direct callers use `execute`; adapters that own validation use
@ -528,24 +529,29 @@ export class EnhancedTools {
const segments = parseSegments(segmentsResult.value);
if (!segments.ok) return segments;
const imageBase = parseSegmentImageBase(segmentsResult.value);
const procedureCount = parseListCount(proceduresResult.value, "procedures");
if (!procedureCount.ok) return procedureCount;
const stringCount = parseListCount(stringsResult.value, "strings");
if (!stringCount.ok) return stringCount;
const imageBase = parseSegmentImageBase(segments.value);
return ok({
document: document.value,
segments: segments.value.map(({ name, start, end }) => ({
name,
start,
end,
length: addressDistance(start, end),
})),
...(imageBase === undefined ? {} : { image_base: imageBase }),
segments: segments.value.map(
({ name, start, end, image_base, address_space }) => ({
name,
start,
end,
length: addressDistance(start, end),
...(image_base === undefined ? {} : { image_base }),
...(address_space === undefined ? {} : { address_space }),
}),
),
segment_count: segments.value.length,
procedure_count: procedureCount.value,
string_count: stringCount.value,
...(imageBase === undefined ? {} : { image_base: imageBase }),
});
}

View File

@ -18,11 +18,11 @@ export const workflowAnalysisProfile = (
): AnalysisProfileCommitment =>
createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
upstream_analysis_profile: upstream,
...(operation === "binary_overview" ||
operation === "batch_decompile" ||
operation === "get_call_graph"
? { workflow_revision: 2 }
: {}),
...(operation === "binary_overview"
? { workflow_revision: 3 }
: operation === "batch_decompile" || operation === "get_call_graph"
? { workflow_revision: 2 }
: {}),
});
/** Provider identity for deterministic artifact inventories. */

View File

@ -8,11 +8,11 @@ import {
type NativeCommandRunner,
} from "../native/CommandRunner.js";
import {
lipoSubtypeBits,
parseLipoArchitectures,
type LipoArchitecture,
} from "../native/parsers/lipo.js";
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readMachoImage, type ReadAt } from "./apple/MachoLoadCommandReader.js";
import {
@ -471,8 +471,10 @@ const lipoSubtypeMatches = (
): boolean => {
if (reported.cpu_subtype_code !== null)
return reported.cpu_subtype_code === observedCpuSubtype;
const bits = lipoSubtypeBits(reported.cpu_subtype);
return bits === null || bits === (observedCpuSubtype & 0x00ffffff);
return (
reported.cpu_subtype_base === null ||
reported.cpu_subtype_base === machoCpuSubtypeBase(observedCpuSubtype)
);
};
/**

View File

@ -585,6 +585,11 @@ const normalizeDestinationPath = (
"path",
`Windows destination cannot materialize artifact path as a regular file: ${path}; ':' denotes alternate data stream syntax.`,
);
if (platform === "win32" && /[\u0001-\u001f]/u.test(path))
throw new ArtifactReaderFailure(
"path",
`Windows destination cannot materialize artifact path: ${JSON.stringify(path)}; Windows names cannot contain U+0001-U+001F.`,
);
return path;
};

View File

@ -255,10 +255,29 @@ const decodeZipEntryText = (
encoding: string,
type: "filename" | "comment",
): string | undefined => {
if (type !== "filename" || encoding !== "utf-8") return undefined;
return decodeStrictZipUtf8(bytes, "ZIP filename is not valid UTF-8");
if (type !== "filename") return undefined;
if (encoding === "utf-8")
return decodeStrictZipUtf8(bytes, "ZIP filename is not valid UTF-8");
return encoding === "cp437" ? decodeCp437(bytes) : undefined;
};
/** CP437 characters for bytes 0x80-0xFF. */
const CP437_HIGH_HALF =
"ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u00A0";
/**
* Decode a legacy ZIP name with the Unicode CP437 mapping, which Python's
* zipfile also uses: bytes below 0x80 are ASCII. zip.js shows 0x01-0x1F and
* 0x7F as IBM PC display glyphs, which turns the macOS folder icon file
* `Icon\r` into `Icon♪`.
*/
const decodeCp437 = (bytes: Uint8Array): string =>
Array.from(bytes, (byte) =>
byte < 0x80
? String.fromCharCode(byte)
: CP437_HIGH_HALF.charAt(byte - 0x80),
).join("");
const zipEntryPath = (entry: Entry): string => {
const field = entry.extraFieldUnicodePath;
if (field?.valid !== true) return entry.filename;

View File

@ -134,7 +134,14 @@ export const decodeNibArchive = (
const value = values.get(index);
if (value === undefined) continue;
const key = keys[value.key_index];
if (key !== undefined) fields[key] = value.value;
if (key !== undefined)
// Archive keys are data, including the legacy __proto__ setter name.
Object.defineProperty(fields, key, {
value: value.value,
enumerable: true,
configurable: true,
writable: true,
});
}
return [{ id, class_name: className, values: fields }];
});
@ -519,7 +526,8 @@ const decodeUtf8 = (
label: string,
): string => {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(
// These are length-prefixed names, not standalone text documents.
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.subarray(start, end),
);
} catch (cause: unknown) {

View File

@ -162,6 +162,19 @@ it("rejects identical duplicate core fields before schema validation", () => {
expect(() => decodeHarCapture(text, [])).toThrow("duplicate");
});
it("names the violated HAR schema constraint without restating capture values", () => {
expect(() =>
decodeHarCapture(
JSON.stringify({
log: { creator: { name: "x", version: "1" }, entries: [] },
}),
[],
),
).toThrow(
"HAR schema validation failed: required (must have required property 'version').",
);
});
it("preserves prototype-named producer members through the capture boundary", () => {
const text = JSON.stringify(historicalHar()).replace(
'"cache":{}',

View File

@ -23,9 +23,13 @@ export const createHarValidator = (): ((value: unknown) => void) => {
allowMitmproxyAbsentPostData(value);
if (!validate(value)) {
const error = validate.errors?.[0];
// AJV messages name the violated constraint, such as the missing
// property, without restating any captured value.
throw new CaptureFormatError(
"format",
`HAR schema validation failed: ${error?.keyword ?? "unknown constraint"}.`,
error === undefined
? "HAR schema validation failed: unknown constraint."
: `HAR schema validation failed: ${error.keyword} (${error.message ?? "no detail"}).`,
error?.instancePath ?? "",
);
}

View File

@ -62,15 +62,17 @@ export const registerApktoolCommands = (
.describe(
"Project strings from res/values-<locale> instead of the default",
),
"no-strings": z
strings: z
.boolean()
.default(false)
.describe("Skip projecting string resources"),
.default(true)
.describe(
"Include string resources; use --no-strings to skip projecting them",
),
}),
run: ({ args, options }) =>
execute(CLI_COMMANDS.decodeAndroidResources, "decode_android_resources", {
path: args.path,
include_strings: !options["no-strings"],
include_strings: options.strings,
...(options.locale === undefined ? {} : { locale: options.locale }),
}),
});

View File

@ -137,6 +137,13 @@ const registerDecompileCommand = (
.describe(
"Procedure name or address; use --procedure=<value> for leading dashes",
),
"file-offset": z
.string()
.regex(/^0x[0-9a-fA-F]+$/u)
.optional()
.describe(
"Ghidra source-file byte offset; mapped internally to an analysis address",
),
snapshot: z
.string()
.min(1)
@ -152,8 +159,17 @@ const registerDecompileCommand = (
{
procedure: resolveCliAnalysisSelector(
args.address,
options.procedure,
"procedure",
options["file-offset"] === undefined
? options.procedure
: resolveCliAnalysisSelector(
options.procedure,
`file-offset:${options["file-offset"]}`,
"file-offset",
"procedure_pseudo_code",
),
options["file-offset"] === undefined
? "procedure"
: "file-offset",
"procedure_pseudo_code",
),
},

View File

@ -181,7 +181,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"procedure_pseudo_code",
"Decompile one analyzed procedure by symbol name or provider-normalized address. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null; request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities.",
"Decompile one analyzed procedure by symbol name or provider-normalized address. Ghidra also accepts file-offset:0x... selectors and falls back to a unique source-file mapping when a 0x-prefixed number resolves to no procedure; existing analysis addresses take precedence and ambiguous mappings fail explicitly. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null; request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities.",
z.object({ procedure, document }),
),
official(

View File

@ -357,18 +357,20 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
binary_overview: evidenceResultOf(
z.object({
document: z.string(),
image_base: z.string().min(1).optional(),
segments: z.array(
z.object({
name: z.string(),
start: z.string(),
end: z.string(),
length: z.number().min(0),
image_base: z.string().exactOptional(),
address_space: z.string().exactOptional(),
}),
),
segment_count: z.number().int().min(0),
procedure_count: z.number().int().min(0),
string_count: z.number().int().min(0),
image_base: z.string().min(1).optional(),
}),
),
analyze_function: functionDossierOutput,

View File

@ -16,6 +16,7 @@ import {
import {
DYLIB_RESOLUTION_LIMITATIONS,
deriveFindings,
type TracedRootSlice,
} from "./dylibResolutionFindings.js";
/**
@ -568,6 +569,7 @@ export const traceDylibLoading = async (
for (const path of request.unclassified ?? [])
images.set(path, await view.image(path));
const roots: DylibTrace["roots"][number][] = [];
const rootSlices: TracedRootSlice[] = [];
const edges: Edge[] = [];
const withoutArchitecture: string[] = [];
const environmentRoots = new Set<string>();
@ -597,6 +599,7 @@ export const traceDylibLoading = async (
)
environmentRoots.add(root);
roots.push({ image: root, architecture: slice.architecture });
rootSlices.push({ image: root, slice });
edges.push(
...(await traceProcess(
{
@ -641,7 +644,7 @@ export const traceDylibLoading = async (
facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [],
})),
edges,
findings: deriveFindings(edges, roots, images),
findings: deriveFindings(edges, rootSlices),
coverage: {
status:
unparsed.length === 0 &&

View File

@ -1,14 +1,18 @@
import type {
DylibEdge as Edge,
DylibFinding as Finding,
DylibTrace,
MachoImageFacts,
MachoSlice,
} from "./dylibResolution.js";
/** One traced process root with the exact slice it was traced from. */
export interface TracedRootSlice {
readonly image: string;
readonly slice: MachoSlice;
}
export const deriveFindings = (
edges: readonly Edge[],
roots: DylibTrace["roots"],
images: ReadonlyMap<string, MachoImageFacts>,
roots: readonly TracedRootSlice[],
): Finding[] => {
const findings: Finding[] = [];
edges.forEach((edge, index) => {
@ -32,15 +36,11 @@ export const deriveFindings = (
explanation: `The modeled dyld search checks ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A compatible Mach-O placed at an earlier modeled path could take precedence over that fallback, subject to unmodeled search inputs and code-signing library validation; library validation is not evaluated here (see inspect_signature).${edge.loader_conditional ? " This loader loads only conditionally; if it never loads, this search never occurs." : ""}${edge.resolution.status === "conditional" ? " Unmodeled environment overrides or earlier unknown candidates may take precedence; placing an image at this rpath does not establish which image dyld will load." : ""}`,
});
});
for (const { image, architecture } of roots) {
const facts = images.get(image);
const slice =
facts?.status === "parsed"
? facts.slices.find(
(candidate) => candidate.architecture === architecture,
)
: undefined;
const environment = slice?.dyld_environment ?? [];
// Slices can share an architecture name (arm64e and arm64e.x1), so each
// finding uses the slice its root was traced from, not a name lookup.
for (const { image, slice } of roots) {
const { architecture } = slice;
const environment = slice.dyld_environment;
if (environment.length > 0)
findings.push({
kind: "dyld-environment-present",
@ -48,7 +48,7 @@ export const deriveFindings = (
image,
basis: "derived",
explanation:
slice?.file_type === "execute"
slice.file_type === "execute"
? `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); any image-selection overrides they request are not modeled. Diagnostic settings alone do not alter resolution.`
: `${image} (${architecture}) contains LC_DYLD_ENVIRONMENT (${environment.join(", ")}). Dyld reads these commands only from the main executable; entries in this non-executable root are observations and do not alter resolution.`,
});

View File

@ -8,7 +8,6 @@ import {
parseNames,
parseProcedures,
parseRelatedAddresses,
parseSegmentImageBase,
parseSegments,
} from "./hopperValues.js";
@ -66,19 +65,6 @@ describe("Hopper boundary values", () => {
ok: true,
value: ["fixture"],
});
expect(
parseSegmentImageBase([
{ image_base: "0x100000" },
{ image_base: "0x100000" },
]),
).toBe("0x100000");
expect(
parseSegmentImageBase([
{ image_base: "0x100000" },
{ image_base: "0x200000" },
]),
).toBeUndefined();
expect(parseSegmentImageBase([{ name: "__TEXT" }])).toBeUndefined();
});
it.each([

View File

@ -20,6 +20,8 @@ export interface SegmentSummary {
readonly readable: boolean | null;
readonly writable: boolean | null;
readonly executable: boolean | null;
readonly image_base?: string;
readonly address_space?: string;
}
/** Return a bounded byte distance between canonical addresses in one space. */
@ -58,10 +60,9 @@ const segmentSchema = z.object({
readable: z.boolean().nullable(),
writable: z.boolean().nullable(),
executable: z.boolean().nullable(),
image_base: z.string().min(1).exactOptional(),
address_space: z.string().min(1).exactOptional(),
});
const segmentImageBaseSchema = z
.object({ image_base: z.string().min(1) })
.passthrough();
const unavailableAnalysisFactSchema = z
.object({ available: z.literal(false), reason: z.string() })
.strict();
@ -415,7 +416,7 @@ export const parseRelatedAddresses = (
: invalid(`${relation} list`, parsed.error);
};
/** Parse direct or wrapped Hopper segment records. */
/** Preserve reported address context in direct or wrapped provider segments. */
export const parseSegments = (
value: JsonValue,
): Result<readonly SegmentSummary[], HopperProtocolError> => {
@ -427,14 +428,15 @@ export const parseSegments = (
: invalid("segment list", parsed.error);
};
/** Return a provider-reported image base only when every segment agrees on it. */
export const parseSegmentImageBase = (value: JsonValue): string | undefined => {
const parsed = z
.array(segmentImageBaseSchema)
.safeParse(unwrapProperty(value, "segments"));
if (!parsed.success) return undefined;
const bases = new Set(parsed.data.map(({ image_base }) => image_base));
return bases.size === 1 ? parsed.data[0]?.image_base : undefined;
/** Return a reported overview base only when every parsed segment agrees. */
export const parseSegmentImageBase = (
segments: readonly SegmentSummary[],
): string | undefined => {
const base = segments[0]?.image_base;
return base !== undefined &&
segments.every((segment) => segment.image_base === base)
? base
: undefined;
};
/** Parse direct or wrapped Hopper document names. */

View File

@ -0,0 +1,78 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const registration = (runtime: string, callback: string, chunk = 1) =>
"(globalThis." +
runtime +
" = globalThis." +
runtime +
" || []).push([[" +
chunk +
"], {a: function() {}}, " +
callback +
"]);";
it.each(["webpackChunkFixture", "rspackChunkFixture"])(
"keeps differing callback facts for %s independent of source order",
(runtime) => {
const first = registration(
runtime,
'function(r) { r(7); r.e("chunk-a"); }',
);
const second = registration(
runtime,
'function(r) { r(8); r.e("chunk-b"); }',
);
for (const source of [first + "\n" + second, second + "\n" + first]) {
const result = analyzeJavaScriptStaticSource(source);
expect(result.parse_status).toBe("complete");
expect(result.bundler_registrations).toHaveLength(2);
expect(
result.bundler_registrations.flatMap((r) => r.entry_module_keys).sort(),
).toEqual(["7", "8"]);
expect(
result.bundler_registrations.flatMap((r) => r.async_chunk_keys).sort(),
).toEqual(["chunk-a", "chunk-b"]);
}
expect(
analyzeJavaScriptStaticSource(first + "\n" + first).bundler_registrations,
).toHaveLength(1);
expect(
analyzeJavaScriptStaticSource(first).bundler_registrations[0],
).toMatchObject({
entry_module_keys: ["7"],
async_chunk_keys: ["chunk-a"],
});
expect(
analyzeJavaScriptStaticSource(second).bundler_registrations[0],
).toMatchObject({
entry_module_keys: ["8"],
async_chunk_keys: ["chunk-b"],
});
},
);
it.each([
["function(r) { r(7); }", "function(r) { r(8); }"],
['function(r) { r.e("a"); }', 'function(r) { r.e("b"); }'],
["function(r) {}", "function(s) {}"],
["function(r) {}", "function(r) { r(dynamic); }"],
["function(r) {}", "function(r) { r.e(dynamic); }"],
])("retains callback metadata differences: %s / %s", (left, right) => {
const result = analyzeJavaScriptStaticSource(
registration("webpackChunkFixture", left) +
"\n" +
registration("webpackChunkFixture", right),
);
expect(result.bundler_registrations).toHaveLength(2);
});
it("retains distinct chunk identities with identical callbacks", () => {
const callback = "function(r) { r(7); }";
const result = analyzeJavaScriptStaticSource(
registration("webpackChunkFixture", callback, 1) +
"\n" +
registration("webpackChunkFixture", callback, 2),
);
expect(result.bundler_registrations).toHaveLength(2);
});

View File

@ -691,6 +691,14 @@ export const registrationKey = (
registration.runtime,
registration.chunk_keys,
String(registration.unknown_chunk_keys),
// Equal factory tables can be registered with different runtime callbacks.
// Both early collection and finalization must retain those recovered facts.
registration.runtime_require_name,
registration.runtime_module_cache_status,
registration.entry_module_keys,
String(registration.unknown_entry_module_keys),
registration.async_chunk_keys,
String(registration.unknown_async_chunk_keys),
registration.modules.map(({ module_key: key, source_sha256: digest }) => [
key,
digest,

View File

@ -265,6 +265,10 @@ type OperandResult = {
type OperandReader = (il: Buffer, offset: number) => OperandResult;
// String(-0) loses a CIL constant's sign in both anchors and normalized hashes.
const floatingPointOperand = (value: number): string =>
Object.is(value, -0) ? "-0" : String(value);
const FIXED_OPERAND_READERS = new Map<string, OperandReader>([
[
"none",
@ -315,7 +319,7 @@ const FIXED_OPERAND_READERS = new Map<string, OperandReader>([
(il, offset) => ({
next: offset + 4,
anchorKind: "constant",
value: String(il.readFloatLE(offset)),
value: floatingPointOperand(il.readFloatLE(offset)),
}),
],
[
@ -323,7 +327,7 @@ const FIXED_OPERAND_READERS = new Map<string, OperandReader>([
(il, offset) => ({
next: offset + 8,
anchorKind: "constant",
value: String(il.readDoubleLE(offset)),
value: floatingPointOperand(il.readDoubleLE(offset)),
}),
],
[

View File

@ -123,6 +123,8 @@ export const resolveGhidraAnalysisProfile = async (
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
string_inventory_evidence: "defined-data-coverage-v1",
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
procedure_file_offset_resolution:
"address-first-unique-target-source-mapping-v2",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_references: "exact-address-incoming-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v1",
@ -212,6 +214,8 @@ export const resolveGhidraAnalysisProfile = async (
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
string_inventory_evidence: "defined-data-coverage-v1",
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
procedure_file_offset_resolution:
"address-first-unique-target-source-mapping-v2",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_references: "exact-address-incoming-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v1",

View File

@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runDoctor, type DoctorHost } from "../application/Doctor.js";
import { createDoctorHostFixture } from "../application/Doctor.fixture.js";
import { providerRegistrationEnvironment } from "../application/SetupRegistrationEnvironment.js";
import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js";
import * as windowsAuthority from "../process/WindowsAuthority.js";
import {
inspectGhidraInstallation,
type GhidraInstallationHost,
@ -173,6 +174,16 @@ describe("Ghidra doctor integration", () => {
});
describe("Windows Ghidra doctor authority", () => {
beforeEach(() => {
vi.spyOn(windowsAuthority, "hasWindowsNativeAuthority").mockReturnValue(
false,
);
vi.spyOn(
windowsAuthority,
"windowsNativeAuthorityUnavailableReason",
).mockReturnValue(WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON);
});
afterEach(() => vi.restoreAllMocks());
it("distinguishes a valid Windows installation from unavailable native authority", async () => {
const ghidra = inspection({
installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC",

View File

@ -42,6 +42,30 @@ const parseFunctionValueFixture = (
};
describe("Ghidra function-analysis result values", () => {
it("preserves file-offset resolution in Evidence locations and rejects inexact offsets", () => {
const selection = {
file_offset: 0x210,
mapped_address: "0x140001010",
procedure_address: "0x140001010",
};
const wire = { value: "return 42;", limitations: [], selection };
expect(parseGhidraFunctionResult("procedure_assembly", wire)).toMatchObject(
{ ok: false, error: { _tag: "AnalysisOutputError" } },
);
const parsed = parseGhidraFunctionResult("procedure_pseudo_code", wire);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.locations).toEqual([
{ kind: "file-offset", offset: 0x210 },
{ kind: "address", address: "0x140001010" },
{ kind: "address", address: "0x140001010" },
]);
expect(
parseGhidraFunctionResult("procedure_pseudo_code", {
...wire,
selection: { ...selection, file_offset: Number.MAX_SAFE_INTEGER + 1 },
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
it.each([...observationOperations])(
"requires the function-observation limitations envelope for %s",
(operation) => {

View File

@ -26,6 +26,7 @@ import {
} from "../domain/jsonValue.js";
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
import { nativeValueFlowSchema } from "../domain/native/nativeValueFlow.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import { err, ok, type Result } from "../domain/result.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import {
@ -399,11 +400,19 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
const functionObservationEnvelope = z.strictObject({
value: z.unknown(),
limitations: z.array(z.string()),
selection: z
.strictObject({
file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
mapped_address: ghidraCanonicalAddressSchema,
procedure_address: ghidraCanonicalAddressSchema,
})
.exactOptional(),
});
export interface ParsedGhidraFunctionResult {
readonly value: JsonValue;
readonly limitations: readonly string[];
readonly locations?: readonly EvidenceLocation[];
}
/** Require exact, bounded Java-bridge function output before creating Evidence. */
@ -414,15 +423,22 @@ export const parseGhidraFunctionResult = (
let observation: {
readonly value: unknown;
readonly limitations: readonly string[];
readonly selection?: z.infer<
typeof functionObservationEnvelope
>["selection"];
};
if (functionBoundaryObservationOperations.has(operation)) {
const envelope = functionObservationEnvelope.safeParse(value);
if (!envelope.success)
if (
!envelope.success ||
(operation !== "procedure_pseudo_code" &&
envelope.data.selection !== undefined)
)
return err(
new AnalysisOutputError(
operation,
"Ghidra bridge omitted the function observation envelope",
{ cause: envelope.error },
envelope.success ? undefined : { cause: envelope.error },
),
);
observation = envelope.data;
@ -434,6 +450,24 @@ export const parseGhidraFunctionResult = (
? ok({
value: jsonValueSchema.parse(parsed.data),
limitations: observation.limitations,
...(observation.selection === undefined
? {}
: {
locations: [
{
kind: "file-offset" as const,
offset: observation.selection.file_offset,
},
{
kind: "address" as const,
address: observation.selection.mapped_address,
},
{
kind: "address" as const,
address: observation.selection.procedure_address,
},
],
}),
})
: err(
new AnalysisOutputError(

View File

@ -32,6 +32,7 @@ import {
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
@ -678,10 +679,12 @@ export const createGhidraProviderClient = (input: {
normalized: JsonValue,
rawResult: JsonValue,
observationLimitations: readonly string[] = [],
locations: readonly EvidenceLocation[] = [],
) => {
const relationshipCoverage = relationshipCoverageFor(operation);
return createAnalysisExecution(normalized, committedProfile.provider, {
rawResult,
locations,
analysisProfile: committedProfile,
...(relationshipCoverage === undefined ? {} : { relationshipCoverage }),
limitations: [
@ -776,11 +779,13 @@ export const createGhidraProviderClient = (input: {
);
let normalized: JsonValue;
let observationLimitations: readonly string[] = [];
let observationLocations: readonly EvidenceLocation[] = [];
if (isGhidraFunctionOperation(operation)) {
const result = parseGhidraFunctionResult(operation, called.value);
if (!result.ok) return result;
normalized = result.value.value;
observationLimitations = result.value.limitations;
observationLocations = result.value.locations ?? [];
} else {
const result = parseGhidraInventoryResult(operation, called.value);
if (!result.ok) return result;
@ -875,6 +880,7 @@ export const createGhidraProviderClient = (input: {
normalized,
called.value,
observationLimitations,
observationLocations,
),
);
},

View File

@ -7,9 +7,13 @@ import {
fatImage,
machoImage,
} from "../artifacts/apple/MachoImage.fixture.js";
import { parseMachoLayout } from "./AppleMachoSelection.js";
import { parseMachoLayout, selectMachoSlice } from "./AppleMachoSelection.js";
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
/** macOS 27 system binaries ship CPU_SUBTYPE_ARM64E_X1 (12) with the PAC ABI bit. */
const ARM64E_X1 = { type: 0x0100000c, subtype: 0x8000000c } as const;
const X86_64H = { type: 0x01000007, subtype: 8 } as const;
describe("native Mach-O slice selection", () => {
it("preserves high-bit section-name bytes instead of ASCII-folding metadata names", () => {
const segment = Buffer.alloc(152);
@ -68,6 +72,48 @@ describe("native Mach-O slice selection", () => {
/not aligned/u,
);
});
it.each([
{ layout: "arm64e, arm64e.x1", cpus: [CPU.arm64e, ARM64E_X1], index: 0 },
{ layout: "arm64e.x1, arm64e", cpus: [ARM64E_X1, CPU.arm64e], index: 1 },
{ layout: "arm64, arm64e.x1", cpus: [CPU.arm64, ARM64E_X1], index: 0 },
{ layout: "arm64e.x1, arm64", cpus: [ARM64E_X1, CPU.arm64], index: 1 },
{
layout: "x86_64, arm64e, arm64e.x1",
cpus: [CPU.x86_64, CPU.arm64e, ARM64E_X1],
index: 1,
},
{
layout: "x86_64h, x86_64",
cpus: [X86_64H, CPU.x86_64],
index: 1,
architecture: "x86_64",
},
])(
"selects one slice when FAT slices $layout share a CPU type",
({ cpus, index, architecture = "arm64" }) => {
const fat = Buffer.from(
fatImage(cpus.map((cpu) => ({ cpu, bytes: machoImage({ cpu }) }))),
);
expect(selectMachoSlice(fat, architecture).fatDeclaration).toMatchObject({
index,
cpuSubtype: cpus[index]?.subtype,
});
expect(parseMachoLayout(fat, architecture).segments).toEqual([]);
},
);
it("rejects slices of the requested CPU type that share a subtype", () => {
const fat = fatImage(
[CPU.arm64e, CPU.arm64e].map((cpu) => ({
cpu,
bytes: machoImage({ cpu }),
})),
);
expect(() => parseMachoLayout(Buffer.from(fat), "arm64")).toThrow(
/Ambiguous FAT architecture slice/u,
);
});
});
describe("native Mach-O fixup load command boundaries", () => {

View File

@ -1,7 +1,13 @@
import type { FixupCommands } from "./AppleMachoFixups.js";
import {
MACHO_ARCHITECTURE_POLICIES,
machoSubtypeRank,
} from "./MachoArchitectureSelection.js";
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import {
readFatSliceDeclarations,
validateFatSlice,
type FatSliceDeclaration,
} from "../domain/apple/machoContainer.js";
/** One mapped Mach-O segment with file-backed VA range. */
@ -90,32 +96,44 @@ export const selectMachoSlice = (
const headerEnd = 8 + count * stride;
if (count > 128 || headerEnd > bytes.length)
throw malformed("Malformed FAT architecture table");
const cpu = MACHO_CPU_TYPE[architecture];
if (cpu === undefined) throw invalid("Requested FAT architecture is absent");
const policy = MACHO_ARCHITECTURE_POLICIES[architecture];
if (policy === undefined)
throw invalid("Requested FAT architecture is absent");
const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), {
count,
wide: fat64,
littleEndian,
});
if (declarations.status !== "parsed") throw malformed(declarations.reason);
let selected: MachoSlice | undefined;
let best: FatSliceDeclaration | undefined;
let bestRank = Number.POSITIVE_INFINITY;
let ambiguous = false;
for (const declaration of declarations.slices) {
const issue = validateFatSlice(declaration, bytes.length, headerEnd);
if (issue !== null) throw malformed(issue);
if (declaration.cpuType !== cpu) continue;
if (selected !== undefined)
throw invalid("Ambiguous FAT architecture slice");
selected = {
slice: declaration.offset,
sliceEnd: declaration.offset + declaration.size,
fatDeclaration: {
...declaration,
tableEnd: headerEnd,
},
};
if (declaration.cpuType !== policy.cpuType) continue;
const rank = machoSubtypeRank(policy, declaration.cpuSubtype);
if (best !== undefined && rank >= bestRank) {
// Equal ranks keep FAT table order unless the slices share a subtype.
if (
rank === bestRank &&
machoCpuSubtypeBase(declaration.cpuSubtype) ===
machoCpuSubtypeBase(best.cpuSubtype)
)
ambiguous = true;
continue;
}
best = declaration;
bestRank = rank;
ambiguous = false;
}
if (selected === undefined)
throw invalid("Requested FAT architecture is absent");
if (best === undefined) throw invalid("Requested FAT architecture is absent");
if (ambiguous) throw invalid("Ambiguous FAT architecture slice");
const selected: MachoSlice = {
slice: best.offset,
sliceEnd: best.offset + best.size,
fatDeclaration: { ...best, tableEnd: headerEnd },
};
if (selected.slice + 4 > bytes.length)
throw malformed("Truncated Mach-O header");
const sliceMagic = bytes.readUInt32LE(selected.slice);
@ -126,13 +144,6 @@ export const selectMachoSlice = (
return selected;
};
/** CPU types admitted by `machArchitecture` in the domain binary target parser. */
const MACHO_CPU_TYPE: Readonly<Record<string, number>> = {
x86: 7,
x86_64: 0x01000007,
arm: 12,
arm64: 0x0100000c,
};
const MH_MAGIC = 0xfeedface;
const MH_CIGAM = 0xcefaedfe;

View File

@ -0,0 +1,80 @@
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import type { LipoArchitecture } from "./parsers/lipo.js";
interface MachoArchitecturePolicy {
readonly cpuType: number;
readonly subtypes: readonly {
readonly subtype: number;
readonly name: string;
}[];
readonly variantName?: RegExp;
}
/** One preference order for FAT subtype identities and Apple tool slice names. */
export const MACHO_ARCHITECTURE_POLICIES: Readonly<
Record<string, MachoArchitecturePolicy>
> = {
x86: { cpuType: 7, subtypes: [{ subtype: 3, name: "i386" }] },
x86_64: {
cpuType: 0x01000007,
subtypes: [{ subtype: 3, name: "x86_64" }],
},
arm: { cpuType: 12, subtypes: [], variantName: /^arm$/u },
arm64: {
cpuType: 0x0100000c,
subtypes: [
{ subtype: 0, name: "arm64" },
{ subtype: 2, name: "arm64e" },
{ subtype: 1, name: "arm64v8" },
],
variantName: /^arm64e\.[A-Za-z0-9_]+$/u,
},
};
/** Unlisted subtypes follow the preferred slices in FAT table order. */
export const machoSubtypeRank = (
policy: MachoArchitecturePolicy,
subtype: number,
): number => {
const rank = policy.subtypes.findIndex(
(candidate) => candidate.subtype === machoCpuSubtypeBase(subtype),
);
return rank < 0 ? policy.subtypes.length : rank;
};
/** Select a tool spelling using the same preference order as FAT selection. */
export const selectMachoArchitectureName = (
policy: MachoArchitecturePolicy,
available: readonly LipoArchitecture[],
): string | undefined => {
let selected: string | undefined;
let bestRank = Number.POSITIVE_INFINITY;
for (const architecture of available) {
if (
architecture.cpu_type_code !== null &&
architecture.cpu_type_code !== policy.cpuType
)
continue;
const namedSubtype = policy.subtypes.find(
({ name }) => name === architecture.name,
);
const recognizedName =
namedSubtype !== undefined || policy.variantName?.test(architecture.name);
if (
!recognizedName &&
(architecture.cpu_type_code === null ||
architecture.cpu_subtype_base === null)
)
continue;
// Parsed identity is authoritative. Names support thin/partial lipo records.
const subtype = architecture.cpu_subtype_base ?? namedSubtype?.subtype;
const rank =
subtype === undefined
? policy.subtypes.length
: machoSubtypeRank(policy, subtype);
if (rank >= bestRank) continue;
selected = architecture.name;
bestRank = rank;
}
return selected;
};

View File

@ -1,10 +1,18 @@
import { describe, expect, it } from "vitest";
import {
CPU,
LC,
dylibCommand,
fatImage,
machoImage,
} from "../artifacts/apple/MachoImage.fixture.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { err, ok } from "../domain/result.js";
import { parseMachoLayout, selectMachoSlice } from "./AppleMachoSelection.js";
import type { NativeCommandCapture } from "./CommandRunner.js";
import { inspectNativeMacho } from "./NativeMachoInspection.js";
import {
@ -78,6 +86,62 @@ describe("Mach-O tool architecture selection", () => {
"/fixture/app",
]);
});
it.each([
{ names: ["arm64v8", "arm64e"], selected: "arm64e" },
{ names: ["arm64e", "arm64v8"], selected: "arm64e" },
{ names: ["arm64v8", "arm64e", "arm64"], selected: "arm64" },
{ names: ["arm64v8"], selected: "arm64v8" },
{ names: ["arm64v8", "arm64e.x1"], selected: "arm64v8" },
{ names: ["arm64e.x1", "arm64v8"], selected: "arm64v8" },
{ names: ["arm64v8", "arm64e.v1"], selected: "arm64e.v1" },
{ names: ["arm64e.v1", "arm64v8"], selected: "arm64e.v1" },
] as const)(
"keeps inspection and dispatch on $selected for FAT order $names",
async ({ names, selected }) => {
const cpus = {
arm64: CPU.arm64,
arm64v8: { type: CPU.arm64.type, subtype: 1 },
arm64e: CPU.arm64e,
"arm64e.x1": { type: CPU.arm64.type, subtype: 0x8000000c },
"arm64e.v1": { type: CPU.arm64.type, subtype: 0x81000002 },
};
const slices = names.map((name) => {
const cpu = cpus[name];
return {
cpu,
bytes: machoImage({
cpu,
commands: [dylibCommand(LC.LOAD_DYLIB, `/fixture/${name}.dylib`)],
}),
};
});
const fat = Buffer.from(fatImage(slices));
const lipo = names
.map(
(name, index) =>
`architecture ${name}\n cputype ${cpus[name].type}\n cpusubtype ${cpus[name].subtype}\n offset ${(index + 1) * 4096}\n size ${slices[index]?.bytes.length}\n align 2^12\n`,
)
.join("");
const { commands } = await inspect(lipo, "arm64");
expect(commands.find(({ tool }) => tool === "otool")?.args).toEqual([
"-h",
"-l",
"-arch",
selected.split(".")[0],
"/fixture/app",
]);
expect(commands.find(({ tool }) => tool === "dyld_info")?.args).toContain(
selected,
);
expect(selectMachoSlice(fat, "arm64").slice).toBe(
(names.findIndex((name) => name === selected) + 1) * 4096,
);
expect(parseMachoLayout(fat, "arm64").fixups.dylibs).toEqual([
`/fixture/${selected}.dylib`,
]);
},
);
});
describe("Mach-O UUID reconciliation", () => {

View File

@ -9,8 +9,15 @@ import {
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import type { NativeCommandCapture } from "./CommandRunner.js";
import {
MACHO_ARCHITECTURE_POLICIES,
selectMachoArchitectureName,
} from "./MachoArchitectureSelection.js";
import { parseDyldSymbols } from "./parsers/dyldInfo.js";
import { parseLipoArchitectures } from "./parsers/lipo.js";
import {
parseLipoArchitectures,
type LipoArchitecture,
} from "./parsers/lipo.js";
import { withoutEchoedPathHeader } from "./parsers/echoedPath.js";
import { parseOtoolLoadCommands } from "./parsers/otool.js";
import { reconcileMachoUuid } from "./parsers/machoUuid.js";
@ -68,8 +75,7 @@ export const inspectNativeMacho = async (
const lipo = captures.find(({ tool }) => tool === "lipo");
if (lipo === undefined) throw new TypeError("Missing lipo capture");
const architectures = parseLipoArchitectures(lipo.stdout);
const availableArchitectures = architectures.map(({ name }) => name);
const selection = selectArchitecture(context.target, availableArchitectures);
const selection = selectArchitecture(context.target, architectures);
if (selection.status === "absent")
return err(
new AnalysisOutputError(
@ -397,25 +403,24 @@ type ArchitectureSelection =
const selectArchitecture = (
target: BinaryTarget,
available: readonly string[],
available: readonly LipoArchitecture[],
): ArchitectureSelection => {
if (target.kind !== "executable" || target.availableArchitectures.length < 2)
// BinaryTarget lists CPU families and lipo lists slices. A universal file
// can hold several slices of one family (arm64e and arm64e.x1), and slice
// tools without -arch print every slice.
if (
target.kind !== "executable" ||
(target.availableArchitectures.length < 2 && available.length < 2)
)
return { status: "unfiltered" };
const normalized =
target.architecture === "x86" ? "i386" : target.architecture;
if (available.includes(normalized))
return { status: "selected", architecture: normalized };
// BinaryTarget intentionally normalizes ARM64 CPU subtypes. Recover the
// concrete arm64e slice name from lipo before invoking slice-aware tools.
if (normalized === "arm64") {
const arm64e = available.find((architecture) => architecture === "arm64e");
if (arm64e !== undefined)
return { status: "selected", architecture: arm64e };
const variant = available.find((architecture) =>
/^arm64e\.[A-Za-z0-9_]+$/u.test(architecture),
);
if (variant !== undefined)
return { status: "selected", architecture: variant };
}
const policy = MACHO_ARCHITECTURE_POLICIES[target.architecture];
const selected =
policy === undefined
? available.find(({ name }) => name === normalized)?.name
: selectMachoArchitectureName(policy, available);
if (selected !== undefined)
return { status: "selected", architecture: selected };
return { status: "absent", architecture: normalized };
};

View File

@ -16,14 +16,11 @@ export const parseCodeSignature = (
unsigned: boolean,
executablePaths: readonly string[] = [],
): Omit<InspectSignature, "provenance"> => {
const { identifier, fields } = splitEchoedValues(
output.replaceAll("\r\n", "\n"),
executablePaths,
);
const { identifier, fields } = splitEchoedValues(output, executablePaths);
const values = new Map<string, string>();
const authorities: string[] = [];
const cdhashes: string[] = [];
for (const line of fields.split("\n")) {
for (const line of fields.split(/\r?\n/u)) {
if (line.startsWith("CodeDirectory ")) {
values.set("CodeDirectory", line.slice("CodeDirectory ".length));
continue;
@ -65,12 +62,20 @@ const splitEchoedValues = (
text: string,
executablePaths: readonly string[],
): { readonly identifier: string | undefined; readonly fields: string } => {
const executable = executablePaths
.map((path) => `Executable=${path}\n`)
.find((line) => text.startsWith(line));
let rest = text;
if (executable !== undefined) rest = text.slice(executable.length);
else if (text.startsWith("Executable=")) {
// Frame the exact source path before interpreting record separators. A CRLF
// inside that path or the signing identifier is payload, not a line ending.
let newline: string | undefined;
for (const path of executablePaths) {
const prefix = `Executable=${path}`;
if (!text.startsWith(prefix)) continue;
const separator = /^\r?\n/u.exec(text.slice(prefix.length))?.[0];
if (separator === undefined) continue;
newline = separator;
rest = text.slice(prefix.length + separator.length);
break;
}
if (rest === text && text.startsWith("Executable=")) {
const identifierLine = text.indexOf("\nIdentifier=");
rest = identifierLine < 0 ? text : text.slice(identifierLine + 1);
}
@ -79,8 +84,17 @@ const splitEchoedValues = (
// Later fields come from the signature itself, so the last `Format=` line
// is the one codesign printed after the identifier.
const format = rest.lastIndexOf("\nFormat=");
const lineEnd = rest.indexOf("\n");
const end = format >= 0 ? format : lineEnd >= 0 ? lineEnd : rest.length;
// Without an exact path, use the producer-owned Format record's terminator.
// Looking inside Identifier instead would confuse literal CRLF payload with
// record framing, or strip a literal trailing CR from an LF-delimited value.
if (newline === undefined && format >= 0)
newline = /\r?\n/u.exec(rest.slice(format + 1))?.[0];
const boundary = format >= 0 ? format : rest.indexOf("\n");
const crlf = newline === undefined || newline === "\r\n";
const end =
boundary < 0
? rest.length
: boundary - (crlf && rest[boundary - 1] === "\r" ? 1 : 0);
return {
identifier: rest.slice("Identifier=".length, end),
fields: rest.slice(end),

View File

@ -59,6 +59,7 @@ architecture arm64e
cpu_type_code: null,
cpu_subtype: "CPU_SUBTYPE_FUTURE",
cpu_subtype_code: null,
cpu_subtype_base: null,
},
]);
});
@ -85,8 +86,29 @@ architecture arm64e
{
cpu_subtype: "CPU_SUBTYPE_X86_ALL",
cpu_subtype_code: null,
cpu_subtype_base: 3,
capabilities: null,
},
]);
});
it.each([
{ capabilities: "PTR_AUTH_VERSION USERSPACE 1", code: 0x81000002 },
{ capabilities: "", code: null },
])(
"retains an arm64e base subtype with capabilities '$capabilities'",
({ capabilities, code }) => {
expect(
parseLipoArchitectures(
`architecture arm64e.v1\n cputype CPU_TYPE_ARM64\n cpusubtype CPU_SUBTYPE_ARM64E\n capabilities ${capabilities}\n`,
),
).toMatchObject([
{
cpu_type_code: 0x0100000c,
cpu_subtype_code: code,
cpu_subtype_base: 2,
},
]);
},
);
});

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { machoCpuSubtypeBase } from "../../domain/apple/machoCpuIdentity.js";
const architectureSchema = z.object({
name: z.string().min(1),
@ -6,6 +7,7 @@ const architectureSchema = z.object({
cpu_subtype: z.string().nullable(),
cpu_type_code: z.number().int().nonnegative().nullable(),
cpu_subtype_code: z.number().int().nonnegative().nullable(),
cpu_subtype_base: z.number().int().nonnegative().nullable(),
capabilities: z.string().nullable(),
file_offset: z.number().int().min(0).nullable(),
size: z.number().int().min(0).nullable(),
@ -30,6 +32,7 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
cpu_subtype: null,
cpu_type_code: null,
cpu_subtype_code: null,
cpu_subtype_base: null,
capabilities: null,
file_offset: null,
size: null,
@ -54,6 +57,7 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
current.cpusubtype,
current.capabilities,
),
cpu_subtype_base: cpuSubtypeBase(current.cpusubtype),
capabilities: current.capabilities ?? null,
file_offset: integer(current.offset),
size: integer(current.size),
@ -158,10 +162,10 @@ const cpuSubtypeCode = (
};
/** The subtype bits a lipo record names, without its capability byte. */
export const lipoSubtypeBits = (value: string | null): number | null => {
if (value === null) return null;
const cpuSubtypeBase = (value: string | undefined): number | null => {
if (value === undefined) return null;
const subtype = unsigned32(value) ?? CPU_SUBTYPES[value];
return subtype === undefined ? null : subtype & 0x00ffffff;
return subtype === undefined ? null : machoCpuSubtypeBase(subtype);
};
/** lipo names the LIB64 capability and prints other capability bits in hex. */

View File

@ -0,0 +1,61 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"retains both repeated chunk callback observations through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-bundler-callbacks-");
const source = [
'(globalThis.webpackChunkFixture = globalThis.webpackChunkFixture || []).push([[1], {a: function() {}}, function(r) { r(7); r.e("chunk-a"); }]);',
'(globalThis.webpackChunkFixture = globalThis.webpackChunkFixture || []).push([[1], {a: function() {}}, function(r) { r(8); r.e("chunk-b"); }]);',
].join("\n");
await writeFile(join(root, "bundle.js"), source);
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
const chunk = graph.nodes.find(({ kind }) => kind === "javascript-chunk");
for (const [index, entry, asyncChunk] of [
[0, "7", "chunk-a"],
[1, "8", "chunk-b"],
] as const) {
expect(chunk?.observations).toContainEqual(
expect.objectContaining({
properties: expect.objectContaining({
entry_module_keys: [entry],
async_chunk_keys: [asyncChunk],
}),
evidence: expect.objectContaining({
location: expect.objectContaining({
value: expect.objectContaining({
source: "bundle.js",
start: { line: index + 1, column: 0 },
}),
}),
}),
}),
);
expect(graph.edges).toContainEqual(
expect.objectContaining({
source_node_id: chunk?.node_id,
properties: expect.objectContaining({
kind: "bundler-entry-module",
module_key: entry,
resolution_status: "not-found",
}),
}),
);
expect(graph.edges).toContainEqual(
expect.objectContaining({
source_node_id: chunk?.node_id,
properties: expect.objectContaining({
kind: "bundler-async-chunk",
chunk_key: asyncChunk,
resolution_status: "not-found",
}),
}),
);
}
},
);

View File

@ -0,0 +1,110 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { expect } from "vitest";
import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"preserves literal NIB fields and UTF-8 names through CLI and stdio MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-nib-field-identity-");
const app = join(root, "Fixture.app");
const contents = join(app, "Contents");
await mkdir(join(contents, "Resources"), { recursive: true });
await mkdir(join(contents, "MacOS"));
await writeFile(
join(contents, "MacOS/App"),
thinMach(0xfeedfacf, 0x0100000c),
);
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string><key>CFBundleIdentifier</key><string>dev.rea.nib-fields</string></dict></plist>",
);
const archive = encodeNibArchiveFixture({
classes: ["UIView", "\uFEFFCustomView"],
objects: [
{
classIndex: 0,
values: {
["__proto__"]: { ref: 1 },
constructor: 7,
enabled: true,
"\uFEFFenabled": false,
},
},
{ classIndex: 1, values: { ["__proto__"]: 23 } },
],
});
const path = join(contents, "Resources/Panel.nib");
await writeFile(path, archive);
const result = await cli.run({
arguments: ["decode-interface-builder", app, "--json"],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(result.exitCode, result.stdout + result.stderr).toBe(0);
const contract = toolContract("decode_interface_builder");
const evidence = contract.outputSchema.parse(result.json);
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
env: {
PATH: process.env.PATH ?? "",
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
stderr: "pipe",
});
const client = new Client({ name: "nib-field-identity", version: "1" });
try {
await client.connect(transport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: app },
});
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const response = await client.callTool({
name: contract.name,
arguments: {},
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcp = contract.outputSchema.parse(response.structuredContent);
expect(mcp.normalized_result).toEqual(evidence.normalized_result);
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
const report = interfaceBuilderAnalysisSchema.parse(
evidence.normalized_result,
);
expect(report.documents[0]?.archive_sha256).toBe(
createHash("sha256").update(archive).digest("hex"),
);
const objects = report.graph.nodes.filter(
({ attributes }) => attributes.interface_builder_object_id !== undefined,
);
expect(objects).toHaveLength(2);
expect(objects[0]?.attributes.nibValues).toEqual({
["__proto__"]: { objectID: "1" },
constructor: 7,
enabled: true,
"\uFEFFenabled": false,
});
expect(objects[1]?.attributes).toMatchObject({
class_name: "\uFEFFCustomView",
nibValues: { ["__proto__"]: 23 },
});
expect(await readFile(path)).toEqual(archive);
},
60_000,
);

View File

@ -12,6 +12,38 @@ const selectors = [
] as const;
describe("CLI analysis selector boundaries", () => {
cliTest(
"accepts file offsets and rejects mixed coordinate selectors before opening a target",
async ({ cli }) => {
const selected = await cli.run({
arguments: [
"decompile",
"does-not-exist",
"--file-offset=0x210",
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(selected.json).toMatchObject({ code: "target_unavailable" });
const conflicting = await cli.run({
arguments: [
"decompile",
"does-not-exist",
"0x140001010",
"--file-offset=0x210",
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(conflicting.json).toMatchObject({
code: "invalid_request",
details: {
operation: "procedure_pseudo_code",
issues: [{ path: ["file-offset"], reason: "invalid_value" }],
},
});
},
);
cliTest(
"rejects missing and conflicting selectors before opening a target",
async ({ cli }) => {

View File

@ -0,0 +1,85 @@
import { expect, it } from "vitest";
import { Cli } from "incur";
import pino from "pino";
import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { registerApktoolCommands } from "../../../src/cli/apktoolCommands.js";
import { presentOmittableDefaults } from "../../../src/cli/omittableDefaults.js";
import type { ApktoolRequest } from "../../../src/domain/apktool/apktoolResourceAnalysis.js";
import { ok } from "../../../src/domain/result.js";
const invoke = async (argv: readonly string[]) => {
const requests: ApktoolRequest[] = [];
const provider: ApktoolResourceAnalysisPort = {
async inspectAvailability() {
throw new Error("unexpected provider probe");
},
async close() {},
async execute(request) {
requests.push(request);
return ok(
createAnalysisExecution(
{ strings: [] },
{ id: "apktool", name: "Apktool", version: null },
),
);
},
};
const cli = Cli.create("rea", { sync: false });
registerApktoolCommands(cli, pino({ level: "silent" }), {}, provider);
presentOmittableDefaults(cli);
let stdout = "";
let exitCode = 0;
await cli.serve([...argv], {
env: {},
exit: (code) => {
exitCode = code;
},
stdout: (text) => {
stdout += text;
},
});
return { exitCode, stdout, requests };
};
it("dispatches --no-strings as include_strings false", async () => {
const result = await invoke([
"decode-android-resources",
"/targets/app.apk",
"--no-strings",
"--json",
]);
expect(result.exitCode).toBe(0);
expect(result.requests).toEqual([
{
operation: "decode_android_resources",
input: { path: "/targets/app.apk", include_strings: false },
},
]);
});
it("keeps string projection enabled when the flag is omitted", async () => {
const result = await invoke([
"decode-android-resources",
"/targets/app.apk",
"--json",
]);
expect(result.exitCode).toBe(0);
expect(result.requests).toEqual([
{
operation: "decode_android_resources",
input: { path: "/targets/app.apk", include_strings: true },
},
]);
});
it("documents the supported negative flag in command help", async () => {
const result = await invoke(["decode-android-resources", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--no-strings");
});

View File

@ -4,11 +4,13 @@ import { dirname, join } from "node:path";
import { describe, expect } from "vitest";
import {
CPU,
FILE_TYPE,
LC,
buildVersionCommand,
dylibCommand,
dyldEnvironmentCommand,
fatImage,
machoImage,
rpathCommand,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
@ -471,6 +473,64 @@ cliTest(
},
);
cliTest(
"reports each same-named slice's own embedded dyld environment",
async ({ cli }) => {
// arm64e and arm64e.x1 slices both report the architecture name arm64e.
// A plain x86_64 slice keeps the bundle openable on x64 hosts.
for (const [settings, expected] of [
[[null, "DYLD_PRINT_RPATHS=1"], ["DYLD_PRINT_RPATHS=1"]],
[
["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"],
["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"],
],
] as const) {
const app = await fixtureApp();
await writeFiles(app, {
"Contents/MacOS/App": fatImage([
...[CPU.arm64e, CPU.arm64eX1].map((cpu, index) => {
const setting = settings[index];
return {
cpu,
bytes: machoImage({
cpu,
commands:
setting === null || setting === undefined
? []
: [dyldEnvironmentCommand(setting)],
}),
};
}),
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
]),
});
const result = await cli.run({
arguments: [
"trace-dylib-resolution",
app,
"--root",
"Contents/MacOS/App",
"--json",
],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
parseEvidence(result.json).normalized_result,
);
expect(
trace.roots.filter(({ architecture }) => architecture === "arm64e"),
).toHaveLength(2);
const environments = trace.findings
.filter(({ kind }) => kind === "dyld-environment-present")
.map(({ explanation }) =>
expected.filter((setting) => explanation.includes(setting)),
);
expect(environments).toEqual(expected.map((setting) => [setting]));
}
},
);
cliTest(
"retains definitive load findings for an explicitly selected app library root",
async ({ cli }) => {

View File

@ -21,14 +21,14 @@ const adHocProgram = async (directory: string, name: string) => {
describe.skipIf(process.platform !== "darwin")(
"native signature CLI with echoed path text",
() => {
cliTest(
"does not read signature fields from the file name codesign echoes",
async ({ cli }) => {
cliTest.for(["\n", "\r\n"])(
"does not read signature fields from the file name codesign echoes (%j)",
async (newline, { cli }) => {
const directory = await createTestTempDirectory("rea-signature-");
const forged = "Developer ID Application: Example (ABCDE12345)";
const program = await adHocProgram(
directory,
`tool\nAuthority=${forged}\nTeamIdentifier=ABCDE12345`,
`tool${newline}Authority=${forged}${newline}Identifier=forged${newline}TeamIdentifier=ABCDE12345`,
);
const unsignedName = await adHocProgram(
directory,
@ -75,7 +75,7 @@ describe.skipIf(process.platform !== "darwin")(
normalized_result: {
identifier: expect.stringMatching(
new RegExp(
`^tool\\nAuthority=${forged.replace(/[()]/gu, "\\$&")}\\nTeamIdentifier=ABCDE12345-`,
`^tool${newline}Authority=${forged.replace(/[()]/gu, "\\$&")}${newline}Identifier=forged${newline}TeamIdentifier=ABCDE12345-`,
"u",
),
),

View File

@ -38,8 +38,19 @@ it("completes an empty ZIP extraction with an empty identity", async () => {
expect(await readdir(output)).toEqual([]);
});
const legacyControlNames = [
["legacy ASCII control", Buffer.from("Icon\r"), false, "Icon\r"],
[
"legacy CP437 control",
Buffer.from([0x82, 0x09, 0x7f, 0x2e, 0x61]),
false,
"é\t\u007F.a",
],
] as const;
it.each([
["legacy CP437", Buffer.from([0x82, 0x2e, 0x74, 0x78, 0x74]), false, "é.txt"],
...legacyControlNames,
[
"EFS with leading U+FEFF",
Buffer.from([0xef, 0xbb, 0xbf, 0x61, 0x62]),
@ -68,6 +79,38 @@ it.each([
},
);
it.skipIf(process.platform === "win32").each(legacyControlNames)(
"extracts %s ZIP members under their stored names",
async (_label, rawName, utf8Flag, logicalPath) => {
const root = await createTestTempDirectory("rea-zip-extracted-name-");
const path = join(root, "names.zip");
await writeSingleEntryZip(path, "x.txt");
const archive = await readFile(path);
await writeFile(
path,
rewriteSingleEntryName(archive, "x.txt", rawName, utf8Flag),
);
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
const output = join(root, "output");
const result = await withArtifactClient(target.value, (client) =>
client.execute("extract_artifact", { output_root: output }),
);
if (!result.ok) throw result.error;
const extraction = artifactExtractionResultSchema.parse(
result.value.result,
);
expect(
extraction.artifacts.map(({ relative_path }) => relative_path),
).toEqual([logicalPath]);
expect(await readdir(output)).toEqual([logicalPath]);
expect(await readFile(join(output, logicalPath), "utf8")).toBe(
"member bytes",
);
},
);
it("preserves valid Info-ZIP Unicode Path overrides", async () => {
const root = await createTestTempDirectory("rea-zip-unicode-path-");
const path = join(root, "unicode-path.zip");

View File

@ -1,4 +1,5 @@
import {
chmod,
cp,
mkdir,
readFile,
@ -79,6 +80,7 @@ async function classify(
head: string,
event = "pull_request",
full = false,
skipFull = false,
) {
const output = join(directory, "scope-output");
await writeFile(output, "");
@ -93,6 +95,7 @@ async function classify(
HEAD_SHA: head,
GITHUB_EVENT_NAME: event,
CI_FULL: String(full),
CI_SKIP_FULL: String(skipFull),
GITHUB_OUTPUT: output,
},
},
@ -100,6 +103,21 @@ async function classify(
return z.record(z.string(), z.unknown()).parse(JSON.parse(result.stdout));
}
it("reuses only scheduled qualification and preserves every explicit full request", async () => {
const { directory, base } = await fixture();
const reused = await classify(directory, base, base, "schedule", false, true);
expect(reused.tier).toBe("reused");
const { CI_FLAGS } = await loadScopes();
for (const flag of CI_FLAGS) expect(reused[flag]).toBe(false);
for (const event of ["workflow_dispatch", "pull_request", "schedule"])
expect(
(await classify(directory, base, base, event, true, true)).tier,
).toBe("full");
expect(
(await classify(directory, base, base, "push", false, true)).tier,
).toBe("fast");
});
it("keeps every translated root README and authored guide on the documentation lane", async () => {
const { directory, base } = await fixture();
const readmes = (await readdir(new URL("../../../", import.meta.url))).filter(
@ -119,6 +137,64 @@ it("keeps every translated root README and authored guide on the documentation l
});
});
it.each([
"job-timings.mjs",
"job-timings-batch.mjs",
"test-report.mjs",
"test-timing-reporter.mjs",
"test-history.mjs",
])(
"keeps portable CI reporter %s on static checks and portable tests",
async (name) => {
const { directory, base } = await fixture();
const head = await change(directory, [`scripts/ci/${name}`]);
const plan = await classify(directory, base, head);
const { CI_FLAGS } = await loadScopes();
expect(plan.code).toBe(true);
for (const flag of CI_FLAGS)
if (flag !== "code") expect(plan[flag]).toBe(false);
},
);
it("preserves a provider lane when a portable CI reporter changes with provider source", async () => {
const { directory, base } = await fixture();
const head = await change(directory, [
"scripts/ci/test-report.mjs",
"src/evm/Example.ts",
]);
expect(await classify(directory, base, head)).toMatchObject({
code: true,
evm: true,
browser: false,
package: false,
});
});
it("keeps unknown CI scripts on complete qualification", async () => {
const { directory, base } = await fixture();
const head = await change(directory, ["scripts/ci/new-qualification.mjs"]);
const plan = await classify(directory, base, head);
const { CI_FLAGS } = await loadScopes();
for (const flag of CI_FLAGS) expect(plan[flag]).toBe(true);
});
it("emits an observational owner proposal without changing ordinary or full portable execution", async () => {
const { directory, base } = await fixture();
const head = await change(directory, ["src/evm/Example.ts"]);
expect(await classify(directory, base, head)).toMatchObject({
code: true,
testSelection: { mode: "candidate", owners: ["evm"] },
});
expect(
await classify(directory, base, head, "pull_request", true),
).toMatchObject({
code: true,
testSelection: { mode: "full", owners: [] },
});
const output = await readFile(join(directory, "scope-output"), "utf8");
expect(output).toContain('test_selection={"version":1,"mode":"full"');
});
it("does not charge a documentation PR for unrelated implementation updates on main", async () => {
const { directory, base } = await fixture();
await git(directory, ["switch", "-c", "docs/change"]);
@ -162,13 +238,148 @@ it("retains complete checks when implementation is renamed into documentation",
});
});
it("retains the complete main-push baseline even for documentation", async () => {
it("keeps main pushes scoped when a successful ancestor baseline is available", async () => {
const { directory, base } = await fixture();
const head = await change(directory, ["README_ja.md"]);
expect(await classify(directory, base, head, "push")).toMatchObject({
tier: "fast",
code: false,
package: false,
docs: true,
matrix: {
include: [
{ os: "ubuntu-latest", platform: "linux", architecture: "x64" },
],
},
});
});
it("includes changes from earlier cancelled pushes since the last successful baseline", async () => {
const { directory, base } = await fixture();
await change(directory, ["src/evm/client.ts"]);
const head = await change(directory, ["README.md"]);
expect(await classify(directory, base, head, "push")).toMatchObject({
tier: "fast",
code: true,
evm: true,
runtime: true,
package: false,
});
});
it.each(["", "not-a-commit"])(
"fails closed to all lanes for a missing or invalid push baseline (%s)",
async (base) => {
const { directory } = await fixture();
const head = await change(directory, ["README.md"]);
const plan = await classify(directory, base, head, "push");
expect(plan).toMatchObject({
tier: "fast",
code: true,
package: true,
evm: true,
browser: true,
reasons: expect.arrayContaining([expect.stringContaining("baseline")]),
});
const { CI_FLAGS } = await loadScopes();
for (const flag of CI_FLAGS) expect(plan[flag]).toBe(true);
},
);
it("selects only a trusted successful ancestor from recent CI push runs", async () => {
const { directory, base } = await fixture();
const head = await change(directory, ["src/evm/client.ts"]);
const bin = join(directory, "fake-bin");
await mkdir(bin);
const gh = join(bin, "gh");
await writeFile(gh, '#!/bin/sh\ncat "$GH_FIXTURE_RESPONSE"\n');
await chmod(gh, 0o755);
const response = join(directory, "runs.json");
await writeFile(
response,
JSON.stringify({
workflow_runs: [
{
event: "push",
conclusion: "success",
head_branch: "main",
head_repository: { full_name: "untrusted/fork" },
head_sha: base,
},
{
event: "push",
conclusion: "success",
head_branch: "main",
head_repository: { full_name: "owner/repo" },
head_sha: base,
},
],
}),
);
const output = join(directory, "baseline-output");
const result = await execFileAsync(
process.execPath,
[new URL("../../../scripts/ci/baseline.mjs", import.meta.url).pathname],
{
cwd: directory,
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
GH_FIXTURE_RESPONSE: response,
CI_REPOSITORY: "owner/repo",
CI_DEFAULT_BRANCH: "main",
HEAD_SHA: head,
GITHUB_OUTPUT: output,
},
},
);
expect(JSON.parse(result.stdout)).toEqual({ base, reason: "" });
expect(await readFile(output, "utf8")).toBe(`base=${base}\nreason=\n`);
});
it("returns an empty baseline when GitHub lookup fails", async () => {
const { directory, base } = await fixture();
const head = await change(directory, ["src/evm/client.ts"]);
const bin = join(directory, "fake-bin");
await mkdir(bin);
const gh = join(bin, "gh");
await writeFile(gh, "#!/bin/sh\nexit 1\n");
await chmod(gh, 0o755);
const result = await execFileAsync(
process.execPath,
[new URL("../../../scripts/ci/baseline.mjs", import.meta.url).pathname],
{
cwd: directory,
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
CI_REPOSITORY: "owner/repo",
CI_DEFAULT_BRANCH: "main",
HEAD_SHA: head,
},
},
);
expect(JSON.parse(result.stdout)).toMatchObject({
base: "",
reason: expect.stringContaining("Could not read"),
});
expect(head).not.toBe(base);
});
it("fails closed when the successful push baseline is not an ancestor", async () => {
const { directory, base } = await fixture();
await git(directory, ["switch", "-c", "unrelated"]);
const unrelated = await change(directory, ["other.txt"]);
await git(directory, ["switch", "main"]);
const head = await change(directory, ["README.md"]);
expect(await classify(directory, unrelated, head, "push")).toMatchObject({
tier: "fast",
code: true,
package: true,
evm: true,
browser: true,
});
expect(head).not.toBe(base);
});
it("fails classification rather than treating an invalid Git comparison as a scope", async () => {
@ -208,7 +419,8 @@ it("retains native Inspector verification on every documented package host", asy
);
expect(packageInspector.if).toBeUndefined();
expect(
(await classify((await fixture()).directory, "", "", "push")).matrix,
(await classify((await fixture()).directory, "", "", "workflow_dispatch"))
.matrix,
).toEqual({
include: [
{ os: "ubuntu-latest", platform: "linux", architecture: "x64" },
@ -228,7 +440,14 @@ it("retains native Inspector verification on every documented package host", asy
it.each([
[
"src/evm/client.ts",
{ code: true, runtime: true, evm: true, browser: false, package: false },
{
code: true,
runtime: true,
evm: true,
browser: false,
package: false,
sqlite: false,
},
],
[
"src/browser/execution/evaluate.ts",
@ -349,6 +568,38 @@ it.each([
"src/process/ProcessOwnership.test.ts",
{ code: true, package: true, apple: false, evm: false, browser: false },
],
[
"src/sqlite/SqliteDatabaseProvider.ts",
{ code: true, sqlite: true, runtime: true, package: false },
],
[
"src/application/sqlite/SqliteDatabaseService.test.ts",
{ code: true, sqlite: true, runtime: false },
],
[
"tests/boundary/sqlite/sqliteDatabaseProvider.test.ts",
{ code: true, sqlite: true, runtime: false },
],
[
"tests/fixtures/sqlite/database.ts",
{ code: true, sqlite: true, runtime: false },
],
["tests/acceptance/sqliteDatabaseCli.test.ts", { code: true, sqlite: true }],
[
"tests/boundary/mcp/serverLifecycleMcp.test.ts",
{ code: true, sqlite: true },
],
["src/process/OwnedCommand.ts", { code: true, sqlite: true, runtime: true }],
["src/server/createServer.ts", { code: true, sqlite: true, runtime: true }],
[
"src/contracts/toolContracts.ts",
{ code: true, sqlite: true, runtime: true },
],
[
"src/domain/analysisErrorProjection.ts",
{ code: true, sqlite: true, runtime: true },
],
["src/domain/javascript/example.ts", { code: true, sqlite: false }],
])("selects the appropriate lanes for %s", async (path, expected) => {
const { directory, base } = await fixture();
const head = await change(directory, [path]);
@ -391,6 +642,52 @@ it("scopes package script edits while retaining broad dependency checks", async
});
});
it.each(["test:sqlite", "test:sqlite:run"])(
"routes %s script changes to the SQLite runtime matrix",
async (script) => {
const { directory } = await fixture();
const base = await change(
directory,
["package.json"],
JSON.stringify({ scripts: { [script]: "vitest run" } }),
);
const head = await change(
directory,
["package.json"],
JSON.stringify({
scripts: { [script]: "vitest run --reporter=default" },
}),
);
expect(await classify(directory, base, head)).toMatchObject({
code: true,
sqlite: true,
evm: false,
package: false,
});
},
);
it("keeps unrelated test script changes off the SQLite runtime matrix", async () => {
const { directory } = await fixture();
const base = await change(
directory,
["package.json"],
JSON.stringify({ scripts: { "test:domain": "vitest run src/domain" } }),
);
const head = await change(
directory,
["package.json"],
JSON.stringify({
scripts: { "test:domain": "vitest run src/domain --reporter=default" },
}),
);
expect(await classify(directory, base, head)).toMatchObject({
code: true,
sqlite: false,
evm: false,
});
});
it.each([
["browser", "real-browser"],
["binary_layout", "real-binary-layout"],
@ -407,7 +704,9 @@ it.each([
async (scope, name) => {
const workflow = z
.object({
on: z.object({ workflow_call: z.null() }).passthrough(),
on: z
.object({ workflow_call: z.object({}).passthrough().nullable() })
.passthrough(),
})
.parse(
parse(
@ -447,8 +746,16 @@ it("rejects skipped selected work while allowing unselected lanes to skip", asyn
const command = fileURLToPath(
new URL("../../../scripts/ci/required.mjs", import.meta.url),
);
const { CI_FLAGS } = await loadScopes();
const jobs = {
changes: { result: "success", outputs: { evm: "true" } },
changes: {
result: "success",
outputs: {
...Object.fromEntries(CI_FLAGS.map((flag) => [flag, "false"])),
tier: "fast",
evm: "true",
},
},
static: { result: "success" },
test: { result: "success" },
evm: { result: "success" },
@ -484,7 +791,10 @@ it.each([
"src/process/capture/example.test.ts",
{ code: true, runtime: false, browser: false, package: false },
],
[".nvmrc", { code: true, package: true, browser: true, evm: true }],
[
".nvmrc",
{ code: true, package: true, browser: true, evm: true, sqlite: true },
],
[
"tsconfig.build.json",
{ code: true, package: true, browser: true, evm: true },
@ -621,8 +931,10 @@ it.each(["workflow_dispatch", "schedule"])(
async (event) => {
const { directory, base } = await fixture();
expect(await classify(directory, base, base, event)).toMatchObject({
tier: "full",
code: true,
package: true,
sqlite: true,
evm: true,
browser: true,
});
@ -636,7 +948,14 @@ it("honors ci:full for successive PR revisions", async () => {
for (const head of [first, second]) {
expect(
await classify(directory, base, head, "pull_request", true),
).toMatchObject({ code: true, package: true, evm: true, browser: true });
).toMatchObject({
tier: "full",
code: true,
package: true,
sqlite: true,
evm: true,
browser: true,
});
}
});
@ -676,7 +995,7 @@ const INTEL_HOST = {
architecture: "x64",
};
it("keeps macOS x64 package verification for ci:full and non-PR events", async () => {
it("keeps macOS x64 package verification only for full qualification", async () => {
const { directory, base } = await fixture();
const head = await change(directory, ["src/windows/Example.ts"]);
const matrix = z.object({ include: z.array(z.unknown()) });
@ -687,7 +1006,8 @@ it("keeps macOS x64 package verification for ci:full and non-PR events", async (
expect(pullRequest).toHaveLength(3);
expect(pullRequest).not.toContainEqual(INTEL_HOST);
expect(await hosts("pull_request", true)).toContainEqual(INTEL_HOST);
for (const event of ["push", "schedule", "workflow_dispatch"])
expect(await hosts("push", false)).not.toContainEqual(INTEL_HOST);
for (const event of ["schedule", "workflow_dispatch"])
expect(await hosts(event, false)).toContainEqual(INTEL_HOST);
});
@ -700,6 +1020,7 @@ const workflowFile = async (name: string): Promise<unknown> =>
);
const scopesSchema = z.object({
CI_FLAGS: z.array(z.string()),
SCOPES: z.record(z.string(), z.array(z.string())),
MANUAL_VERIFIERS: z.array(z.string()),
});

View File

@ -0,0 +1,243 @@
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
executeWorkflowFixture,
workflowGit,
} from "../../support/workflowGit.js";
const reporter = fileURLToPath(
new URL("../../../scripts/ci/job-timings.mjs", import.meta.url),
);
it("merges paginated jobs, reports elapsed metrics, and preserves raw responses", async () => {
const fixture = await createFixture([
{
total_count: 4,
jobs: [
{
name: "Build package",
workflow_name: "CI",
status: "completed",
conclusion: "success",
created_at: "2026-10-13T00:00:00Z",
started_at: "2026-10-13T00:00:10Z",
completed_at: "2026-10-13T00:01:10Z",
steps: [
{
name: "Install dependencies (npm ci)",
started_at: "2026-10-13T00:00:10Z",
completed_at: "2026-10-13T00:00:40Z",
},
{
name: "Upload artifact",
started_at: "2026-10-13T00:00:40Z",
completed_at: "2026-10-13T00:00:41Z",
},
],
},
],
},
{
total_count: 4,
jobs: [
{
name: "Queued lane",
status: "queued",
created_at: "2026-10-13T00:00:00Z",
started_at: "2026-10-13T00:00:00Z",
completed_at: null,
steps: [],
},
{
name: "Running lane",
status: "in_progress",
created_at: "2026-10-13T00:00:00Z",
started_at: "2026-10-13T00:00:15Z",
completed_at: null,
steps: [
{
name: "Download artifacts",
started_at: "2026-10-13T00:00:20Z",
completed_at: "2026-10-13T00:00:22Z",
},
],
},
{
name: "Skipped lane",
status: "completed",
conclusion: "skipped",
created_at: "2026-10-13T00:00:00Z",
started_at: "2026-10-13T00:00:00Z",
completed_at: "2026-10-13T00:00:00Z",
steps: [],
},
],
},
]);
const result = await runReporter(fixture);
const report = JSON.parse(
await readFile(join(fixture.outputDirectory, "report.json"), "utf8"),
);
expect(report.run.snapshot).toContain("partial");
expect(report.counts).toMatchObject({
jobs: 4,
completed: 2,
skipped: 1,
queueUnknown: 2,
executionUnknown: 3,
completedExecutionUnknown: 0,
});
expect(report.aggregates.queueSeconds).toMatchObject({
count: 2,
p50: 10,
p95: 15,
});
expect(report.aggregates.completedExecutionSeconds).toMatchObject({
count: 1,
p50: 60,
p95: 60,
});
expect(report.aggregates.summedCompletedJobElapsedSeconds).toMatchObject({
value: 60,
knownCount: 1,
unknownCount: 0,
label: expect.stringContaining("not CPU time"),
});
expect(report.jobs[1].queue).toEqual({
seconds: null,
reason: "job start is not established by its status",
});
expect(report.jobs[3].queue).toEqual({
seconds: null,
reason: "job was skipped",
});
expect(report.jobs[3].execution).toEqual({
seconds: null,
reason: "job was skipped",
});
expect(report.stepCosts.npmCi[0].duration.seconds).toBe(30);
expect(report.stepCosts.artifactUpload[0].duration.seconds).toBe(1);
expect(report.stepCosts.artifactDownload[0].duration.seconds).toBe(2);
expect(report.stepCostInterpretation).toContain(
"not total artifact transfer costs",
);
expect(report.cpuSeconds).toBeUndefined();
expect(result.stdout).toContain("4 job timing records");
expect(
await readFile(join(fixture.outputDirectory, "jobs.json"), "utf8"),
).toBe(fixture.raw);
const summary = await readFile(fixture.summary, "utf8");
expect(summary).toContain("point-in-time snapshot");
expect(summary).toContain("unknown");
expect(summary).toContain("not CPU time");
expect(summary).toContain("60 s");
expect(summary).toContain("not total artifact transfer costs");
const args = JSON.parse(await readFile(fixture.args, "utf8"));
expect(args).toEqual([
"api",
"--paginate",
"--slurp",
"repos/example/rea/actions/runs/42/attempts/2/jobs?per_page=100",
]);
});
it("marks invalid and out-of-order timestamps unknown instead of emitting negative durations", async () => {
const fixture = await createFixture([
{
jobs: [
{
name: "Invalid dates",
status: "completed",
created_at: "not-a-date",
started_at: "2026-10-13T00:00:10Z",
completed_at: "2026-10-13T00:00:05Z",
steps: [],
},
],
},
]);
await runReporter(fixture);
const report = JSON.parse(
await readFile(join(fixture.outputDirectory, "report.json"), "utf8"),
);
expect(report.jobs[0].queue).toEqual({
seconds: null,
reason: "invalid timestamp",
});
expect(report.jobs[0].execution).toEqual({
seconds: null,
reason: "timestamps out of order",
});
expect(report.counts.completedExecutionUnknown).toBe(1);
expect(report.aggregates.summedCompletedJobElapsedSeconds).toMatchObject({
value: null,
unknownCount: 1,
});
});
it("fails on malformed pages or a failed GitHub API request", async () => {
const malformed = await createFixture([{ jobs: "not-an-array" }]);
await expect(runReporter(malformed)).rejects.toThrow();
await expect(
readFile(join(malformed.outputDirectory, "jobs.json"), "utf8"),
).resolves.toBe(malformed.raw);
const failedApi = await createFixture([], { exitCode: 1 });
await expect(runReporter(failedApi)).rejects.toThrow();
});
async function createFixture(
pages: unknown[],
{ exitCode = 0 }: { exitCode?: number } = {},
) {
const directory = await createTestTempDirectory("rea-ci-job-timings-");
await workflowGit(directory, ["init", "--initial-branch=main"]);
const fakeBin = join(directory, "bin");
await mkdir(fakeBin);
const raw = JSON.stringify(pages);
const rawPath = join(directory, "gh-response.json");
const args = join(directory, "gh-args.json");
await writeFile(rawPath, raw);
const fakeGh = join(fakeBin, "gh");
await writeFile(
fakeGh,
`#!/usr/bin/env node\nconst fs = require("node:fs");\nfs.writeFileSync(process.env.CI_GH_ARGS, JSON.stringify(process.argv.slice(2)));\nprocess.stdout.write(fs.readFileSync(process.env.CI_GH_RESPONSE, "utf8"));\nprocess.exitCode = ${String(exitCode)};\n`,
);
await chmod(fakeGh, 0o755);
const outputDirectory = join(directory, ".cache", "ci-job-timings");
const summary = join(directory, "summary.md");
await writeFile(summary, "");
return {
directory,
fakeBin,
raw,
rawPath,
args,
outputDirectory,
summary,
};
}
function runReporter(fixture: Awaited<ReturnType<typeof createFixture>>) {
return executeWorkflowFixture(process.execPath, [reporter], {
cwd: fixture.directory,
env: {
...process.env,
PATH: `${fixture.fakeBin}${delimiter}${process.env.PATH ?? ""}`,
CI_REPOSITORY: "example/rea",
CI_JOB_TIMINGS_DIRECTORY: fixture.outputDirectory,
CI_GH_ARGS: fixture.args,
CI_GH_RESPONSE: fixture.rawPath,
CI_TIMING_RUN_ID: "42",
CI_TIMING_RUN_ATTEMPT: "2",
GITHUB_STEP_SUMMARY: fixture.summary,
},
});
}

View File

@ -0,0 +1,288 @@
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { executeWorkflowFixture } from "../../support/workflowGit.js";
const batchScript = fileURLToPath(
new URL("../../../scripts/ci/job-timings-batch.mjs", import.meta.url),
);
it("collects the latest attempt for recent completed CI and Release runs into a bounded manifest", async () => {
const recent = new Date().toISOString();
const fixture = await createFixture([
{
workflow_runs: [
{
id: 42,
run_attempt: 3,
status: "completed",
conclusion: "failure",
created_at: new Date(Date.parse(recent) + 1_000).toISOString(),
},
{
id: 41,
run_attempt: 1,
status: "completed",
conclusion: "cancelled",
created_at: recent,
},
],
},
{
workflow_runs: [
{
id: 43,
run_attempt: 2,
status: "completed",
conclusion: "success",
created_at: new Date(Date.parse(recent) + 2_000).toISOString(),
},
],
},
[
{
jobs: [
{
name: "Release lane",
status: "completed",
conclusion: "success",
created_at: recent,
started_at: recent,
completed_at: new Date(Date.parse(recent) + 50_000).toISOString(),
steps: [],
},
],
},
],
[
{
jobs: [
{
name: "Failed lane",
status: "completed",
conclusion: "failure",
created_at: recent,
started_at: recent,
completed_at: new Date(Date.parse(recent) + 55_000).toISOString(),
steps: [],
},
],
},
],
[
{
jobs: [
{
name: "Cancelled lane",
status: "completed",
conclusion: "cancelled",
created_at: recent,
started_at: recent,
completed_at: new Date(Date.parse(recent) + 30_000).toISOString(),
steps: [],
},
],
},
],
]);
const result = await runBatch(fixture);
const manifest = JSON.parse(
await readFile(
join(fixture.outputDirectory, "batch-manifest.json"),
"utf8",
),
);
const calls = JSON.parse(await readFile(fixture.calls, "utf8"));
expect(result.stdout).toContain("3 collected runs");
expect(
await readFile(join(fixture.directory, "summary.md"), "utf8"),
).toContain("collected 3 of 3");
expect(manifest.selection).toMatchObject({
lookbackHours: 24,
maxRunsPerWorkflow: 100,
exhaustive: false,
});
expect(manifest.coverage).toMatchObject({
workflowsQueried: ["CI", "Release"],
selectedRuns: 3,
collectedRuns: 3,
});
expect(manifest.truncation).toEqual([]);
expect(manifest.errors).toEqual([]);
expect(manifest.runs).toMatchObject([
{ id: 43, runAttempt: 2, conclusion: "success" },
{ id: 42, runAttempt: 3, conclusion: "failure" },
{ id: 41, runAttempt: 1, conclusion: "cancelled" },
]);
expect(manifest.runs).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "collected",
rawPath: expect.any(String),
reportPath: expect.any(String),
}),
]),
);
expect(calls).toContainEqual([
"api",
"--paginate",
"--slurp",
"repos/example/rea/actions/runs/42/attempts/3/jobs?per_page=100",
]);
expect(calls).toContainEqual([
"api",
"--paginate",
"--slurp",
"repos/example/rea/actions/runs/41/attempts/1/jobs?per_page=100",
]);
const failedReport = JSON.parse(
await readFile(
join(fixture.outputDirectory, "runs/42-3/report.json"),
"utf8",
),
);
expect(failedReport.jobs[0].conclusion).toBe("failure");
expect(failedReport.jobs[0].execution.seconds).toBe(55);
});
it("records listing and per-run errors without claiming complete coverage", async () => {
const recent = new Date().toISOString();
const fixture = await createFixture([
{
workflow_runs: [
{
id: 7,
run_attempt: 2,
status: "completed",
conclusion: "success",
created_at: recent,
},
],
},
{ error: true },
{ jobs: "malformed" },
]);
await runBatch(fixture);
const manifest = JSON.parse(
await readFile(
join(fixture.outputDirectory, "batch-manifest.json"),
"utf8",
),
);
expect(manifest.selection.exhaustive).toBe(false);
expect(manifest.errors).toMatchObject([
{ scope: "workflow:Release" },
{ scope: "run" },
]);
expect(manifest.runs[0]).toMatchObject({
id: 7,
runAttempt: 2,
status: "error",
});
});
it("retains a partial manifest when its collection budget is exhausted", async () => {
const fixture = await createFixture([
{
workflow_runs: [8, 9].map((id) => ({
id,
run_attempt: 1,
status: "completed",
created_at: new Date().toISOString(),
})),
},
{ workflow_runs: [] },
]);
await executeWorkflowFixture(
process.execPath,
[
"--input-type=module",
"-e",
`
const { collectJobTimingsBatch } = await import(${JSON.stringify(pathToFileURL(batchScript).href)});
let clock = Date.now();
await collectJobTimingsBatch({
repository: "example/rea",
outputDirectory: process.env.CI_JOB_TIMINGS_DIRECTORY,
now: () => clock,
collect: async () => {
clock += 240001;
throw new Error("fixture request exhausted the remaining budget");
},
});
`,
],
{ cwd: fixture.directory, env: fixtureEnvironment(fixture) },
);
const manifest = JSON.parse(
await readFile(
join(fixture.outputDirectory, "batch-manifest.json"),
"utf8",
),
);
expect(manifest.coverage).toMatchObject({
selectedRuns: 2,
collectedRuns: 0,
});
expect(manifest.runs).toHaveLength(1);
expect(manifest.runs[0]).toMatchObject({ status: "error" });
expect(manifest.truncation.join(" ")).toContain("Time budget");
});
async function createFixture(responses: unknown[]) {
const directory = await createTestTempDirectory("rea-ci-job-timings-batch-");
const fakeBin = join(directory, "bin");
await mkdir(fakeBin);
const responsePath = join(directory, "responses.json");
const calls = join(directory, "calls.json");
await writeFile(responsePath, JSON.stringify(responses));
await writeFile(calls, "[]");
const fakeGh = join(fakeBin, "gh");
await writeFile(
fakeGh,
`#!/usr/bin/env node
const fs = require("node:fs");
const calls = JSON.parse(fs.readFileSync(process.env.CI_GH_CALLS, "utf8"));
calls.push(process.argv.slice(2));
fs.writeFileSync(process.env.CI_GH_CALLS, JSON.stringify(calls));
const responses = JSON.parse(fs.readFileSync(process.env.CI_GH_RESPONSES, "utf8"));
const response = responses[calls.length - 1];
if (response && response.error) { process.stderr.write("fixture API error"); process.exitCode = 1; }
else process.stdout.write(JSON.stringify(response));
`,
);
await chmod(fakeGh, 0o755);
return {
directory,
fakeBin,
responses: responsePath,
calls,
outputDirectory: join(directory, ".cache", "ci-job-timings"),
};
}
function runBatch(fixture: Awaited<ReturnType<typeof createFixture>>) {
return executeWorkflowFixture(process.execPath, [batchScript], {
cwd: fixture.directory,
env: fixtureEnvironment(fixture),
});
}
function fixtureEnvironment(
fixture: Awaited<ReturnType<typeof createFixture>>,
) {
return {
...process.env,
PATH: `${fixture.fakeBin}${delimiter}${process.env.PATH ?? ""}`,
CI_REPOSITORY: "example/rea",
CI_JOB_TIMINGS_DIRECTORY: fixture.outputDirectory,
CI_GH_CALLS: fixture.calls,
CI_GH_RESPONSES: fixture.responses,
GITHUB_STEP_SUMMARY: join(fixture.directory, "summary.md"),
};
}

View File

@ -0,0 +1,98 @@
import { readFile } from "node:fs/promises";
import { expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
import { executeWorkflowFixture } from "../../support/workflowGit.js";
async function workflow(name: string) {
return z
.object({
on: z.record(z.string(), z.unknown()),
permissions: z.record(z.string(), z.string()),
concurrency: z.object({ group: z.string() }).optional(),
jobs: z.record(
z.string(),
z.object({
name: z.string().optional(),
if: z.string().optional(),
uses: z.string().optional(),
with: z.record(z.string(), z.string()).optional(),
steps: z
.array(
z.object({
run: z.string().optional(),
uses: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
}),
)
.optional(),
}),
),
})
.parse(
parse(
await readFile(
new URL(`../../../.github/workflows/${name}`, import.meta.url),
"utf8",
),
),
);
}
it("refreshes title validation on edits without checking out PR code or cancelling CI", async () => {
const title = await workflow("pr-title.yml");
expect(title.on.pull_request).toMatchObject({
types: ["opened", "synchronize", "reopened", "edited"],
});
expect(title.permissions).toEqual({});
expect(title.concurrency?.group).not.toBe(
(await workflow("ci.yml")).concurrency?.group,
);
const jobs = Object.values(title.jobs);
expect(jobs).toHaveLength(1);
expect(jobs[0]?.name).toBe("Conventional PR title");
expect(jobs[0]?.if).toBeUndefined(); // A skipped check must not green-light an invalid title on body edits.
const steps = jobs[0]?.steps ?? [];
expect(steps).toHaveLength(1);
expect(steps[0]?.uses).toBeUndefined();
expect(steps[0]?.env).toEqual({
PR_TITLE: "${{ github.event.pull_request.title }}",
});
const command = z.string().parse(steps[0]?.run);
for (const title of [
"fix(ci): repair workflow",
"feat!: changed contract",
"docs: literal $(exit 91) `exit 92` text",
]) {
await expect(
executeWorkflowFixture("bash", ["-c", command], {
env: { ...process.env, PR_TITLE: title },
}),
).resolves.toMatchObject({ stderr: "" });
}
for (const title of ["", "Fix CI", "feat: "]) {
await expect(
executeWorkflowFixture("bash", ["-c", command], {
env: { ...process.env, PR_TITLE: title },
}),
).rejects.toMatchObject({ code: 1 });
}
expect((await workflow("ci.yml")).jobs["pr-title"]).toBeUndefined();
});
it("qualifies only ci:full label additions at the caller's merge revision", async () => {
const full = await workflow("ci-full-pr.yml");
expect(full.on.pull_request).toMatchObject({ types: ["labeled"] });
expect(full.permissions).toEqual({ contents: "read", actions: "read" });
expect(Object.values(full.jobs)).toEqual([
{
name: "Qualify labeled PR revision",
if: "github.event.label.name == 'ci:full'",
uses: "./.github/workflows/ci.yml",
with: { source_sha: "${{ github.sha }}" },
},
]);
// Metadata events must not create skipped replacements for the main CI checks.
expect(full.jobs.required).toBeUndefined();
expect(full.concurrency).toBeUndefined();
});

View File

@ -0,0 +1,375 @@
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
executeWorkflowFixture,
workflowGit,
} from "../../support/workflowGit.js";
const qualificationScript = fileURLToPath(
new URL("../../../scripts/ci/qualification.mjs", import.meta.url),
);
const requiredScript = fileURLToPath(
new URL("../../../scripts/ci/required.mjs", import.meta.url),
);
const now = Date.now();
const validSha = "a".repeat(40);
const repository = "example/rea";
function artifact(overrides: Record<string, unknown> = {}) {
return {
id: 42,
name: "ci-full-qualification-v1",
expired: false,
created_at: new Date(now - 60_000).toISOString(),
workflow_run: {
id: 7,
repository_id: 1,
head_repository_id: 1,
head_branch: "main",
head_sha: validSha,
},
...overrides,
};
}
function run(overrides: Record<string, unknown> = {}) {
return {
id: 7,
status: "completed",
conclusion: "success",
path: ".github/workflows/ci.yml",
event: "schedule",
created_at: new Date(now - 120_000).toISOString(),
head_sha: validSha,
head_branch: "main",
head_repository: { full_name: repository },
...overrides,
};
}
async function admissionFixture(
candidate: Record<string, unknown> = artifact(),
candidateRun: Record<string, unknown> = run(),
options: { apiError?: boolean; malformed?: boolean; force?: boolean } = {},
) {
const directory = await createTestTempDirectory("rea-ci-qualification-");
const bin = join(directory, "bin");
await mkdir(bin);
const fixturePath = join(directory, "api.json");
await writeFile(
fixturePath,
options.malformed
? "not-json"
: JSON.stringify({ artifacts: [candidate], run: candidateRun }),
);
const gh = join(bin, "gh");
await writeFile(
gh,
[
"#!/bin/sh",
'if [ "$GH_API_ERROR" = "true" ]; then exit 1; fi',
'case "$*" in',
' *actions/artifacts*) cat "$GH_FIXTURE_RESPONSE" ;;',
" *actions/runs/7*) node -e 'const x=require(process.env.GH_FIXTURE_RESPONSE); process.stdout.write(JSON.stringify(x.run))' ;;",
" *) exit 2 ;;",
"esac",
"",
].join("\n"),
);
await chmod(gh, 0o755);
const output = join(directory, "github-output");
await writeFile(output, "");
const env = {
...process.env,
PATH: `${bin}:${process.env.PATH ?? ""}`,
GH_FIXTURE_RESPONSE: fixturePath,
GH_API_ERROR: String(options.apiError ?? false),
CI_REPOSITORY: repository,
CI_REPOSITORY_ID: "1",
CI_DEFAULT_BRANCH: "main",
HEAD_SHA: validSha,
GITHUB_RUN_ID: "100",
GITHUB_OUTPUT: output,
...(options.force ? { CI_FORCE_FULL: "true" } : {}),
};
return { directory, output, env };
}
async function decide(
candidate?: Record<string, unknown>,
candidateRun?: Record<string, unknown>,
options?: { apiError?: boolean; malformed?: boolean; force?: boolean },
) {
const fixture = await admissionFixture(candidate, candidateRun, options);
const result = await executeWorkflowFixture(
process.execPath,
[qualificationScript],
{
cwd: fixture.directory,
env: fixture.env,
},
);
return {
...fixture,
decision: z
.record(z.string(), z.unknown())
.parse(JSON.parse(result.stdout)),
outputText: await readFile(fixture.output, "utf8"),
};
}
it("reuses only a recent trusted full qualification for the exact default-branch SHA", async () => {
const { decision, outputText } = await decide();
expect(decision).toMatchObject({ run_full: false, run_id: 7 });
expect(Object.keys(decision).sort()).toEqual([
"reason",
"run_full",
"run_id",
]);
expect(outputText).toContain("run_full=false\n");
expect(outputText).toContain("run_id=7\n");
});
const rejectedCandidates: Array<
[string, () => Record<string, unknown>, () => Record<string, unknown>]
> = [
[
"old",
() =>
artifact({
created_at: new Date(now - 24 * 60 * 60 * 1000 - 1).toISOString(),
}),
run,
],
[
"future",
() => artifact({ created_at: new Date(now + 60_000).toISOString() }),
run,
],
["expired", () => artifact({ expired: true }), run],
[
"foreign repository",
() =>
artifact({
workflow_run: { ...artifact().workflow_run, repository_id: 2 },
}),
run,
],
[
"foreign branch",
() =>
artifact({
workflow_run: { ...artifact().workflow_run, head_branch: "other" },
}),
run,
],
[
"foreign SHA",
() =>
artifact({
workflow_run: { ...artifact().workflow_run, head_sha: "b".repeat(40) },
}),
run,
],
[
"untrusted workflow",
artifact,
() => run({ path: ".github/workflows/release.yml" }),
],
["failed run", artifact, () => run({ conclusion: "failure" })],
[
"old producer rerun with a fresh artifact",
artifact,
() =>
run({ created_at: new Date(now - 25 * 60 * 60 * 1000).toISOString() }),
],
["wrong workflow event", artifact, () => run({ event: "push" })],
];
it.each(rejectedCandidates)(
"runs full qualification for a %s candidate",
async (_name, makeArtifact, makeRun) => {
const { decision } = await decide(makeArtifact(), makeRun());
expect(decision.run_full).toBe(true);
},
);
it.each([
["API error", { apiError: true }],
["malformed query response", { malformed: true }],
] as const)("fails closed on %s", async (_name, options) => {
const { decision } = await decide(undefined, undefined, options);
expect(decision.run_full).toBe(true);
});
it("lets the daily force-full setting bypass the GitHub API", async () => {
const { decision } = await decide(undefined, undefined, {
force: true,
apiError: true,
});
expect(decision).toMatchObject({ run_full: true });
expect(decision.reason).toContain("Forced");
});
async function workflowContract() {
const workflowPath = new URL(
"../../../.github/workflows/ci.yml",
import.meta.url,
);
const parsed = parse(await readFile(workflowPath, "utf8"));
return z
.object({
jobs: z.record(
z.string(),
z.object({ needs: z.unknown().optional() }).passthrough(),
),
})
.parse(parsed).jobs;
}
async function requiredRun(
changes: Record<string, string>,
overrides: { failed?: string; runAttempt?: string; event?: string } = {},
) {
const directory = await createTestTempDirectory("rea-ci-receipt-");
await workflowGit(directory, ["init", "--initial-branch=main"]);
await writeFile(join(directory, "fixture.txt"), "qualified source\n");
await workflowGit(directory, ["add", "."]);
await workflowGit(directory, ["commit", "-m", "fixture source"]);
const head = await workflowGit(directory, ["rev-parse", "HEAD"]);
const jobs = await workflowContract();
const results: Record<
string,
{ result: string; outputs?: Record<string, string> }
> = {};
for (const name of z.array(z.string()).parse(jobs.required?.needs)) {
results[name] = {
result:
name === overrides.failed
? "failure"
: changes.tier === "reused"
? "skipped"
: "success",
};
}
results.changes = { result: "success", outputs: changes };
const receiptDirectory = join(directory, ".cache", "receipt");
const output = join(directory, "github-output");
await writeFile(output, "");
const env = {
...process.env,
CI_JOBS: JSON.stringify(results),
CI_QUALIFICATION_DIRECTORY: receiptDirectory,
GITHUB_EVENT_NAME: overrides.event ?? "schedule",
GITHUB_SHA: head,
GITHUB_RUN_ID: "100",
GITHUB_RUN_ATTEMPT: overrides.runAttempt ?? "1",
GITHUB_REPOSITORY: repository,
GITHUB_OUTPUT: output,
};
const result = executeWorkflowFixture(process.execPath, [requiredScript], {
cwd: directory,
env,
});
return { directory, head, output, result };
}
it("writes a full receipt only after every canonical flag and workflow need pass", async () => {
const scopesPath = new URL("../../../scripts/ci/scopes.mjs", import.meta.url);
const { CI_FLAGS } = await import(scopesPath.href);
const changes = Object.fromEntries(
CI_FLAGS.map((flag: string) => [flag, "true"]),
);
changes.tier = "full";
const { directory, head, output, result } = await requiredRun(changes);
await expect(result).resolves.toMatchObject({ stderr: "" });
const receiptPath = join(directory, ".cache", "receipt", "receipt.json");
const receipt = z
.object({
sourceSha: z.string(),
runId: z.number(),
runAttempt: z.number(),
})
.parse(JSON.parse(await readFile(receiptPath, "utf8")));
expect(receipt).toMatchObject({ sourceSha: head, runId: 100, runAttempt: 1 });
expect(await readFile(output, "utf8")).toContain(
`receipt_path=${receiptPath}\n`,
);
expect(await readFile(output, "utf8")).toContain(
"receipt_name=ci-full-qualification-v1\n",
);
});
it.each([
["failed selected lane", { failed: "sqlite-runtime" }],
["partial full scope", {}],
] as const)(
"does not issue a receipt after %s",
async (caseName, overrides) => {
const scopesPath = new URL(
"../../../scripts/ci/scopes.mjs",
import.meta.url,
);
const { CI_FLAGS } = await import(scopesPath.href);
const changes = Object.fromEntries(
CI_FLAGS.map((flag: string) => [flag, "true"]),
);
changes.tier = "full";
if (caseName === "partial full scope") changes.sqlite = "false";
const outcome = await requiredRun(changes, overrides);
await expect(outcome.result).rejects.toMatchObject({ code: 1 });
await expect(
readFile(join(outcome.directory, ".cache", "receipt", "receipt.json")),
).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(outcome.output, "utf8")).not.toContain("receipt_");
},
);
it("never writes a fresh receipt for a reused scheduled qualification", async () => {
const scopesPath = new URL("../../../scripts/ci/scopes.mjs", import.meta.url);
const { CI_FLAGS } = await import(scopesPath.href);
const changes = Object.fromEntries(
CI_FLAGS.map((flag: string) => [flag, "false"]),
);
changes.tier = "reused";
changes.qualification_run_id = "7";
const outcome = await requiredRun(changes);
const result = await outcome.result;
await expect(
readFile(join(outcome.directory, ".cache", "receipt", "receipt.json")),
).rejects.toMatchObject({ code: "ENOENT" });
expect(result.stdout).toContain("Qualification reused from run 7");
expect(await readFile(outcome.output, "utf8")).not.toContain("receipt_");
});
it.each([
["missing receipt run", { qualification_run_id: "" }, "schedule"],
["selected validation lane", { sqlite: "true" }, "schedule"],
["manual dispatch", {}, "workflow_dispatch"],
] as const)(
"rejects qualification reuse with %s",
async (_name, override, event) => {
const { CI_FLAGS } = await import(
new URL("../../../scripts/ci/scopes.mjs", import.meta.url).href
);
const changes: Record<string, string> = {
...Object.fromEntries(CI_FLAGS.map((flag: string) => [flag, "false"])),
tier: "reused",
qualification_run_id: "7",
...override,
};
const outcome = await requiredRun(changes, { event });
await expect(outcome.result).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("scheduled admission receipt"),
});
expect(await readFile(outcome.output, "utf8")).not.toContain("receipt_");
},
);

View File

@ -26,7 +26,19 @@ const workflow = async (filename = "ci.yml") =>
),
);
const scopeFlags = async () =>
z
.object({ CI_FLAGS: z.array(z.string()) })
.parse(
await import(
new URL("../../../scripts/ci/scopes.mjs", import.meta.url).href
),
).CI_FLAGS;
it("binds SQLite compatibility jobs to the build's prepared runtime artifact and root layout", async () => {
expect((await workflow()).jobs["sqlite-runtime"]?.if).toBe(
"needs.changes.outputs.sqlite == 'true'",
);
const steps = z.array(
z.object({
uses: z.string().optional(),
@ -52,6 +64,77 @@ it("binds SQLite compatibility jobs to the build's prepared runtime artifact and
expect(downloads[0]?.with).toEqual({ name, path: "." });
});
it("exports every canonical scope with read authority for admission and keeps telemetry outside the gate", async () => {
const { jobs } = await workflow();
const CI_FLAGS = await scopeFlags();
const outputs = z.record(z.string(), z.string()).parse(jobs.changes?.outputs);
expect(jobs.changes?.permissions).toEqual({
contents: "read",
actions: "read",
});
expect(jobs.build?.permissions).toEqual({
contents: "read",
actions: "read",
});
for (const flag of CI_FLAGS)
expect(outputs[flag]).toBe(`\${{ steps.plan.outputs.${flag} }}`);
const steps = z
.array(
z.object({
run: z.string().optional(),
if: z.string().optional(),
}),
)
.parse(jobs.required?.steps);
const telemetry = steps.find((step) =>
step.run?.includes("scripts/ci/job-timings.mjs"),
);
expect(telemetry).toBeUndefined();
const timings = z
.object({
on: z.object({
schedule: z.array(z.object({ cron: z.string() })),
workflow_dispatch: z.unknown(),
workflow_run: z.unknown().optional(),
}),
permissions: z.record(z.string(), z.string()),
jobs: z.object({
report: z.object({
steps: z.array(
z.object({
uses: z.string().optional(),
run: z.string().optional(),
with: z.record(z.string(), z.unknown()).optional(),
env: z.record(z.string(), z.unknown()).optional(),
}),
),
}),
}),
})
.parse(
parse(
await readFile(
new URL("../../../.github/workflows/ci-timings.yml", import.meta.url),
"utf8",
),
),
);
expect(timings.on.workflow_run).toBeUndefined();
expect(timings.on.schedule).toHaveLength(1);
expect(timings.permissions).toEqual({ contents: "read", actions: "read" });
const checkout = timings.jobs.report.steps.find((step) =>
step.uses?.startsWith("actions/checkout@"),
);
expect(checkout?.with).toEqual({
ref: "${{ github.event.repository.default_branch }}",
"persist-credentials": false,
});
const report = timings.jobs.report.steps.find((step) =>
step.run?.includes("scripts/ci/job-timings-batch.mjs"),
);
expect(report?.env?.CI_REPOSITORY).toBe("${{ github.repository }}");
});
const requiredNeeds = async () => {
const { jobs } = await workflow();
return { jobs, needs: z.array(z.string()).parse(jobs.required?.needs) };
@ -64,6 +147,240 @@ it("makes the required gate wait for every CI lane, including future additions",
);
});
it("holds native and provider fan-out behind static validation while portable feedback stays independent", async () => {
const { jobs } = await workflow();
const earlyJobs = new Set([
"changes",
"static",
"build",
"test-shard",
"test",
"docs",
"required",
]);
for (const [name, job] of Object.entries(jobs)) {
if (earlyJobs.has(name)) continue;
expect(job.needs, `${name} must wait for static validation`).toContain(
"static",
);
expect(job.if).not.toMatch(/always\(|cancelled\(/u);
}
expect(jobs["test-shard"]?.needs).not.toContain("static");
});
it("binds provider build reuse to the current run while keeping standalone dispatches independent", async () => {
const { jobs } = await workflow();
const consumers = [
"browser",
"binary_layout",
"evm",
"javascript_recovery",
"recorded_crash",
"web_network",
"web_runtime",
"web_source_map",
];
const buildTerms = z
.string()
.parse(jobs.build?.if)
.split("||")
.map((term) => term.trim());
// Provider workflow edits select their lane without code=true. The build
// producer must still run, without selecting the portable test shards.
expect(new Set(buildTerms)).toEqual(
new Set(
["code", ...consumers].map(
(scope) => `needs.changes.outputs.${scope} == 'true'`,
),
),
);
expect(jobs["test-shard"]?.if).toBe("needs.changes.outputs.code == 'true'");
for (const name of consumers) {
const caller = jobs[name];
expect(caller?.needs).toContain("build");
expect(caller?.with).toEqual({ build_cache_artifact: "rea-build-cache" });
const path = z.string().parse(caller?.uses);
const called = z
.object({
on: z.object({
workflow_call: z.object({
inputs: z.record(z.string(), z.unknown()),
}),
workflow_dispatch: z.unknown(),
}),
jobs: z.record(
z.string(),
z.object({
steps: z.array(
z.object({
uses: z.string().optional(),
with: z.record(z.string(), z.unknown()).optional(),
}),
),
}),
),
})
.parse(
parse(
await readFile(new URL(`../../../${path}`, import.meta.url), "utf8"),
),
);
expect(called.on.workflow_call.inputs.build_cache_artifact).toMatchObject({
type: "string",
default: "",
});
expect(Object.hasOwn(called.on, "workflow_dispatch")).toBe(true);
for (const job of Object.values(called.jobs))
expect(
job.steps.find(
(step) => step.uses === "./.github/actions/portable-build-cache",
)?.with,
).toEqual({ build_cache_artifact: "${{ inputs.build_cache_artifact }}" });
}
const action = z
.object({
runs: z.object({
steps: z.array(
z.object({
id: z.string().optional(),
uses: z.string().optional(),
if: z.string().optional(),
with: z.record(z.string(), z.unknown()).optional(),
"continue-on-error": z.boolean().optional(),
}),
),
}),
})
.parse(
parse(
await readFile(
new URL(
"../../../.github/actions/portable-build-cache/action.yml",
import.meta.url,
),
"utf8",
),
),
);
const download = action.runs.steps.find(
(step) => step.uses === "actions/download-artifact@v4",
);
expect(download?.with).toEqual({
name: "${{ inputs.build_cache_artifact }}",
path: ".turbo/cache/",
});
expect(download?.if).toBe("inputs.build_cache_artifact != ''");
expect(download?.["continue-on-error"]).not.toBe(true);
for (const step of action.runs.steps.filter(
(step) => step.id === "plan" || step.uses?.startsWith("actions/cache"),
))
expect(step.if).toContain("inputs.build_cache_artifact == ''");
});
it("runs workflow validation inside required static checks when selected", async () => {
const { jobs } = await workflow();
const steps = z
.array(z.object({ run: z.string().optional(), if: z.string().optional() }))
.parse(jobs.static?.steps);
const validation = steps.filter((step) => step.run?.includes("actionlint"));
expect(validation).toHaveLength(1);
expect(validation[0]?.if).toBe("needs.changes.outputs.workflows == 'true'");
expect(jobs.workflows).toBeUndefined();
});
it("leaves cancellation ownership with CI so child lanes cannot cancel another tier", async () => {
const { jobs } = await workflow();
const children = Object.values(jobs).flatMap((job) =>
typeof job.uses === "string" && job.uses.startsWith("./.github/workflows/")
? [job.uses]
: [],
);
expect(children.length).toBeGreaterThan(0);
for (const path of children) {
const child = z
.object({ concurrency: z.unknown().optional() })
.parse(
parse(
await readFile(new URL(`../../../${path}`, import.meta.url), "utf8"),
),
);
expect(
child.concurrency,
`${path} must use caller scheduling`,
).toBeUndefined();
}
});
it("keeps fast feedback separate from complete qualification and release proof", async () => {
const ci = z
.object({
on: z.object({
push: z.unknown(),
pull_request: z.unknown(),
schedule: z.array(z.object({ cron: z.string() })),
workflow_call: z.object({
inputs: z.object({
source_sha: z.object({
required: z.literal(true),
type: z.literal("string"),
}),
}),
}),
}),
concurrency: z.object({
group: z.string(),
"cancel-in-progress": z.string(),
}),
jobs: z.record(
z.string(),
z.object({
if: z.string().optional(),
steps: z
.array(
z.object({
run: z.string().optional(),
if: z.string().optional(),
with: z.record(z.string(), z.unknown()).optional(),
}),
)
.optional(),
}),
),
})
.parse(
parse(
await readFile(
new URL("../../../.github/workflows/ci.yml", import.meta.url),
"utf8",
),
),
);
expect(ci.on.schedule).toEqual([
{ cron: "23 0-2,4-23 * * *" },
{ cron: "23 3 * * *" },
]);
expect(ci.concurrency.group).toContain("'qualification' || 'feedback'");
expect(ci.concurrency.group).toContain("inputs.source_sha ||");
expect(ci.concurrency.group).toContain("github.ref");
expect(ci.concurrency["cancel-in-progress"]).toBe(
"${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}",
);
expect(ci.jobs.required?.if).toBe("${{ always() }}");
const shards = ci.jobs["test-shard"]?.steps ?? [];
const fast = shards.find((step) => step.run?.includes("npm run test:run"));
const full = shards.find((step) => step.run?.includes("test:ci:shard:run"));
expect(fast?.if).toBe("needs.changes.outputs.tier == 'fast'");
expect(fast?.run).toContain("--reporter=blob");
expect(fast?.run).not.toContain("--coverage");
expect(full?.if).toBe("needs.changes.outputs.tier == 'full'");
const proof = ci.jobs["package-e2e"]?.steps?.find(
(step) => step.with?.name === "verified-release-package",
);
expect(proof?.if).toContain("needs.changes.outputs.tier == 'full'");
expect(proof?.if).toContain("matrix.platform == 'linux'");
expect(proof?.if).toContain("matrix.architecture == 'x64'");
});
it.each([
["true", "success", true],
["true", "failure", false],
@ -71,34 +388,119 @@ it.each([
["true", "skipped", false],
["true", "missing", false],
["false", "skipped", true],
["false", "failure", false],
] as const)(
"enforces the SQLite required gate for code=%s and result=%s",
async (code, result, passes) => {
"enforces mapped required scopes for selected=%s and result=%s",
async (selected, result, passes) => {
const workflow = await requiredNeeds();
// GitHub exposes only direct dependencies in the required job's needs context.
const needs: Record<
string,
{ result: string; outputs?: Record<string, string> }
> = Object.fromEntries(
workflow.needs.map((name) => [name, { result: "success" }]),
);
needs.changes = { result: "success", outputs: { code } };
if (result === "missing") delete needs["sqlite-runtime"];
else if ("sqlite-runtime" in needs) needs["sqlite-runtime"] = { result };
const execution = executeWorkflowFixture(
for (const [scope, lane] of [
["sqlite", "sqlite-runtime"],
["workflows", "static"],
] as const) {
// GitHub exposes only direct dependencies in the required job's needs context.
const needs: Record<
string,
{ result: string; outputs?: Record<string, string> }
> = Object.fromEntries(
workflow.needs.map((name) => [name, { result: "success" }]),
);
needs.changes = {
result: "success",
outputs: {
...Object.fromEntries(
(await scopeFlags()).map((flag) => [flag, "false"]),
),
tier: "fast",
code: "true",
[scope]: selected,
},
};
if (result === "missing") delete needs[lane];
else needs[lane] = { result };
const execution = executeWorkflowFixture(
process.execPath,
[
fileURLToPath(
new URL("../../../scripts/ci/required.mjs", import.meta.url),
),
],
{ env: { ...process.env, CI_JOBS: JSON.stringify(needs) } },
);
// Static checks remain required even without workflow edits.
const expected = passes && !(lane === "static" && result !== "success");
if (expected)
await expect(execution).resolves.toMatchObject({ stderr: "" });
else
await expect(execution).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(lane),
});
}
},
);
it("rejects incomplete or malformed classification even when available jobs succeeded", async () => {
const flags = await scopeFlags();
const valid = {
...Object.fromEntries(flags.map((flag) => [flag, "false"])),
tier: "fast",
};
const run = (outputs: unknown) =>
executeWorkflowFixture(
process.execPath,
[
fileURLToPath(
new URL("../../../scripts/ci/required.mjs", import.meta.url),
),
],
{ env: { ...process.env, CI_JOBS: JSON.stringify(needs) } },
{
env: {
...process.env,
CI_JOBS: JSON.stringify({
changes: { result: "success", outputs },
static: { result: "success" },
test: { result: "success" },
}),
CI_QUALIFICATION_DIRECTORY: "",
},
},
);
if (passes) await expect(execution).resolves.toMatchObject({ stderr: "" });
else
await expect(execution).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("sqlite-runtime"),
});
},
);
await expect(run(valid)).resolves.toMatchObject({ stderr: "" });
// Metadata is not a scope, even when its string happens to be "true".
await expect(
run({ ...valid, qualification_run_id: "true" }),
).resolves.toMatchObject({ stderr: "" });
for (const outputs of [
undefined,
null,
{},
{ ...valid, tier: undefined },
{ ...valid, tier: "" },
{ ...valid, tier: "unknown" },
]) {
await expect(run(outputs)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("valid tier"),
});
}
for (const flag of flags) {
await expect(run({ ...valid, [flag]: undefined })).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(`scope outputs: ${flag}`),
});
}
for (const value of ["", "TRUE", "yes", true, false, 0, null, [], {}]) {
await expect(run({ ...valid, sqlite: value })).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("scope outputs: sqlite"),
});
}
for (const tier of ["full", "reused"]) {
await expect(
run({ ...valid, tier, sqlite: undefined }),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("scope outputs: sqlite"),
});
}
});

View File

@ -0,0 +1,188 @@
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { executeWorkflowFixture } from "../../support/workflowGit.js";
const script = fileURLToPath(
new URL("../../../scripts/ci/test-history.mjs", import.meta.url),
);
const repository = "example/rea";
const repositoryId = 1;
const now = Date.now();
function artifact(id: number, overrides: Record<string, unknown> = {}) {
return {
id: id + 100,
name: "test-timings",
expired: false,
created_at: new Date(now - 60_000).toISOString(),
workflow_run: {
id,
repository_id: repositoryId,
head_repository_id: repositoryId,
head_branch: "main",
},
...overrides,
};
}
function run(id: number, overrides: Record<string, unknown> = {}) {
return {
id,
status: "completed",
conclusion: "success",
path: ".github/workflows/ci.yml",
event: "schedule",
created_at: new Date(now - 120_000).toISOString(),
head_branch: "main",
head_repository: { full_name: repository },
...overrides,
};
}
async function invoke(
artifacts: unknown[],
runs: Record<number, unknown>,
options: {
apiError?: boolean;
repository?: string;
repositoryId?: string;
} = {},
) {
const directory = await createTestTempDirectory("rea-ci-test-history-");
const fakeBin = join(directory, "bin");
await mkdir(fakeBin);
const fixture = join(directory, "api.json");
const calls = join(directory, "api-calls");
const output = join(directory, "github-output");
await writeFile(fixture, JSON.stringify({ artifacts, runs }));
await writeFile(output, "");
await writeFile(calls, "");
const fakeGh = join(fakeBin, "gh");
await writeFile(
fakeGh,
[
"#!/bin/sh",
'printf \'%s\\n\' "$*" >> "$GH_API_CALLS"',
'if [ "$GH_API_ERROR" = "true" ]; then exit 1; fi',
'if [ "$4" = "repos/example/rea/actions/artifacts" ]; then',
' node -e \'const fs=require("node:fs"); const x=JSON.parse(fs.readFileSync(process.env.GH_FIXTURE_RESPONSE,"utf8")); process.stdout.write(JSON.stringify({artifacts:x.artifacts}));\'',
"else",
' run_id="${4##*/}"',
' node -e \'const fs=require("node:fs"); const x=JSON.parse(fs.readFileSync(process.env.GH_FIXTURE_RESPONSE,"utf8")); process.stdout.write(JSON.stringify(x.runs[Number(process.argv[1])] ?? {}));\' "$run_id"',
"fi",
"",
].join("\n"),
);
await chmod(fakeGh, 0o755);
const node24 = dirname(process.execPath);
const result = await executeWorkflowFixture(process.execPath, [script], {
cwd: directory,
env: {
...process.env,
PATH: [fakeBin, node24, process.env.PATH ?? ""].join(delimiter),
GH_FIXTURE_RESPONSE: fixture,
GH_API_CALLS: calls,
GH_API_ERROR: String(options.apiError ?? false),
GH_TOKEN: "fixture-token",
CI_REPOSITORY: options.repository ?? repository,
CI_REPOSITORY_ID: options.repositoryId ?? String(repositoryId),
CI_DEFAULT_BRANCH: "main",
GITHUB_OUTPUT: output,
},
});
return {
result,
output: await readFile(output, "utf8"),
calls: await readFile(calls, "utf8"),
};
}
it("selects the latest trusted full run that actually published timing history", async () => {
const { result, output, calls } = await invoke(
[
artifact(15),
artifact(14),
artifact(13, {
workflow_run: { ...artifact(13).workflow_run, head_repository_id: 2 },
}),
artifact(12, {
workflow_run: { ...artifact(12).workflow_run, head_branch: "feature" },
}),
artifact(11),
],
{
15: run(15, { conclusion: "failure" }),
14: run(14, { path: ".github/workflows/release.yml" }),
11: run(11, { event: "workflow_dispatch" }),
},
);
expect(JSON.parse(result.stdout)).toEqual({ run_id: "11" });
expect(output).toBe("run-id=11\n");
expect(calls).toContain("name=test-timings");
expect(calls).toContain("per_page=100");
expect(calls.match(/actions\/runs\//gu)).toHaveLength(3);
expect(calls).not.toContain("actions/workflows");
});
it("rejects expired, foreign, old, and non-full run candidates", async () => {
const { result, output } = await invoke(
[
artifact(15, { expired: true }),
artifact(14, {
workflow_run: { ...artifact(14).workflow_run, head_repository_id: 2 },
}),
artifact(13, {
created_at: new Date(now - 31 * 24 * 60 * 60 * 1000).toISOString(),
}),
artifact(12),
],
{
12: run(12, { event: "pull_request" }),
},
);
expect(JSON.parse(result.stdout)).toEqual({ run_id: "" });
expect(output).toBe("run-id=\n");
});
it("writes an empty run id when GitHub history is unavailable", async () => {
const { result, output } = await invoke([], {}, { apiError: true });
expect(JSON.parse(result.stdout)).toEqual({ run_id: "" });
expect(output).toBe("run-id=\n");
});
it("bounds run lookups to five artifacts even when inventory has more", async () => {
const ids = [25, 24, 23, 22, 21, 20, 19];
const { result, calls } = await invoke(
ids.map((id) => artifact(id)),
Object.fromEntries(
ids.map((id, index) => [
id,
run(id, index < 5 ? { conclusion: "failure" } : {}),
]),
),
);
expect(JSON.parse(result.stdout)).toEqual({ run_id: "" });
expect(calls.match(/actions\/runs\//gu)).toHaveLength(5);
});
it("returns empty history without querying for an invalid repository identity", async () => {
const { result, output, calls } = await invoke(
[],
{},
{
repository: "owner/repo?per_page=100",
repositoryId: "not-an-id",
},
);
expect(JSON.parse(result.stdout)).toEqual({ run_id: "" });
expect(output).toBe("run-id=\n");
expect(calls).toBe("");
});

View File

@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@ -8,8 +8,11 @@ import { expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
import { planTestSelection } from "../../../scripts/ci/test-selection.mjs";
import { parseTestDurations } from "../../ciTestSequencer.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { workflowGit } from "../../support/workflowGit.js";
const execute = promisify(execFile);
const script = new URL("../../../scripts/ci/test-report.mjs", import.meta.url);
@ -53,6 +56,23 @@ it("collects failed-shard diagnostics before enforcing the shard result", async
expect(upload).toBeGreaterThan(merge);
expect(gate).toBeGreaterThan(upload);
expect(steps[merge]?.if).not.toContain("needs.test-shard.result");
const merges = steps.filter((step) => step.run?.includes("--reporter=json"));
expect(merges).toHaveLength(2);
for (const step of merges)
expect(step.run).toContain(
"--reporter=./scripts/ci/test-timing-reporter.mjs",
);
expect(steps[upload]?.with?.path).toContain("test-timings.json");
expect(steps[upload]?.with).toMatchObject({ "include-hidden-files": true });
expect(steps[upload]?.with?.path).toContain(
".cache/ci-test-selection/report.json",
);
const summary = steps.find((step) =>
step.run?.includes("scripts/ci/test-report.mjs"),
);
expect(summary?.env?.CI_TEST_SELECTION).toBe(
"${{ needs.changes.outputs.test_selection }}",
);
const command = steps[gate]?.run;
if (command === undefined) throw new Error("Missing shard result gate");
for (const result of ["failure", "cancelled", "skipped", ""]) {
@ -77,11 +97,67 @@ it("keeps diagnosis available after merge failure and publishes only complete su
const history = steps.find((step) => step.with?.name === "test-timings");
expect(history?.if).toContain("success()");
expect(history?.if).toContain("needs.test-shard.result == 'success'");
expect(history?.if).toContain("needs.changes.outputs.tier == 'full'");
expect(history?.with?.path).toBe(".cache/ci-test-history/test-results.json");
const download = (await workflow()).build!.steps.find(
const buildSteps = (await workflow()).build!.steps;
const lookup = buildSteps.find((step) =>
step.run?.includes("test-history.mjs"),
);
expect(lookup?.env).toMatchObject({
CI_REPOSITORY: "${{ github.repository }}",
CI_REPOSITORY_ID: "${{ github.repository_id }}",
CI_DEFAULT_BRANCH: "${{ github.event.repository.default_branch }}",
});
const download = buildSteps.find(
(step) => step.name === "Download previous timing report",
);
expect(download?.with?.name).toBe(history?.with?.name);
expect(download?.with?.["run-id"]).toBe(
"${{ steps.history.outputs.run-id }}",
);
});
it("rejects successful shard reports with missing or duplicate portable files", async () => {
const root = await createTestTempDirectory("rea-ci-report-inventory-");
await workflowGit(root, ["init"]);
await mkdir(join(root, "tests"));
for (const name of ["first", "second"])
await writeFile(join(root, `tests/${name}.test.ts`), "// fixture\n");
await workflowGit(root, ["add", "tests"]);
const run = async (names: readonly string[]) => {
await writeFile(
join(root, "test-timings.json"),
JSON.stringify({
version: 1,
success: true,
files: names.map((name) => ({ name, durationMs: 1 })),
}),
);
return execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
SHARD_RESULT: "success",
MERGE_RESULT: "success",
CI_VERIFY_TEST_INVENTORY: "true",
},
});
};
await expect(
run(["tests/first.test.ts", "tests/second.test.ts"]),
).resolves.toMatchObject({ stderr: "" });
for (const names of [
["tests/first.test.ts"],
["tests/first.test.ts", "tests/second.test.ts", "tests/unplanned.test.ts"],
["tests/first.test.ts", "tests/first.test.ts", "tests/second.test.ts"],
]) {
await expect(run(names)).rejects.toMatchObject({
stderr: expect.stringContaining("Incomplete portable test inventory"),
});
await expect(
readFile(join(root, ".cache/ci-test-history/test-results.json")),
).rejects.toMatchObject({ code: "ENOENT" });
}
});
it("runs the existing onboarding and Python producer guards from their selected CI owners", async () => {
@ -116,20 +192,25 @@ it("exports portable timing history and removes it on a failed, partial, or inva
const summary = join(root, "summary.md");
const history = join(root, ".cache/ci-test-history/test-results.json");
const report = {
version: 1,
success: true,
numFailedTests: 0,
testResults: [
files: [
{
name: join(root, "tests/slow.test.ts"),
startTime: 10,
endTime: 110,
assertionResults: [{ large: "not timing data" }],
durationMs: 100,
phases: {
execution: 20,
preparation: 20,
environment: 10,
imports: 40,
setup: 10,
},
},
{ name: join(root, "tests/fast.test.ts"), startTime: 20, endTime: 30 },
{ name: join(root, "tests/fast.test.ts"), durationMs: 10 },
],
};
const run = async (value: unknown, shards = "success", merge = "success") => {
await writeFile(join(root, "test-results.json"), JSON.stringify(value));
await writeFile(join(root, "test-timings.json"), JSON.stringify(value));
await writeFile(summary, "");
return execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
@ -143,7 +224,8 @@ it("exports portable timing history and removes it on a failed, partial, or inva
};
await run(report);
const contents = await readFile(history, "utf8");
expect(contents).not.toContain("assertionResults");
expect(contents).not.toContain("phases");
expect(contents).not.toContain("startTime");
expect(contents).not.toContain(root);
expect(
parseTestDurations(contents, resolve(root, "different-checkout")),
@ -154,7 +236,7 @@ it("exports portable timing history and removes it on a failed, partial, or inva
]),
);
for (const [value, shards, merge] of [
[{ ...report, success: false, numFailedTests: 1 }, "failure", "failure"],
[{ ...report, success: false }, "failure", "failure"],
[report, "cancelled", "success"],
[report, "success", "failure"],
] as const) {
@ -167,8 +249,211 @@ it("exports portable timing history and removes it on a failed, partial, or inva
await expect(
run({
...report,
testResults: [{ name: "../outside.test.ts", startTime: 1, endTime: 3 }],
files: [{ name: "../outside.test.ts", durationMs: 2 }],
}),
).rejects.toMatchObject({ code: 1 });
await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" });
await run(report);
await expect(
run({
...report,
files: [{ name: "tests/invalid.test.ts", durationMs: -1 }],
}),
).rejects.toMatchObject({ code: 1 });
await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" });
});
it("recovers complete module costs from real blob reports without executing tests again", async () => {
const root = await createTestTempDirectory("rea-ci-module-timing-");
const config = join(root, "vitest.config.mjs");
await writeFile(
config,
'export default { test: { globals: true, include: ["sample.test.js"], maxWorkers: 1 } };',
);
await writeFile(
join(root, "sample.test.js"),
[
'import { writeFileSync } from "node:fs";',
"beforeAll(() => new Promise(resolve => setTimeout(resolve, 80)));",
"afterAll(() => new Promise(resolve => setTimeout(resolve, 80)));",
'it("has cheap assertions and expensive outer hooks", () => { writeFileSync("executed", "yes"); expect(1).toBe(1); });',
].join("\n"),
);
const vitest = fileURLToPath(
new URL("../../../node_modules/vitest/vitest.mjs", import.meta.url),
);
const reporter = fileURLToPath(
new URL("../../../scripts/ci/test-timing-reporter.mjs", import.meta.url),
);
const run = (args: string[]) =>
execute(process.execPath, [vitest, ...args, "--config", config], {
cwd: root,
env: process.env,
timeout: 20_000,
});
await run(["run", "--reporter=blob"]);
expect(await readFile(join(root, "executed"), "utf8")).toBe("yes");
await rm(join(root, "executed"));
await run([
"--merge-reports",
`--reporter=${reporter}`,
"--reporter=json",
"--outputFile.json=body.json",
]);
await expect(readFile(join(root, "executed"))).rejects.toMatchObject({
code: "ENOENT",
});
const report = z
.object({
version: z.literal(1),
success: z.literal(true),
files: z.array(
z.object({
name: z.string(),
durationMs: z.number(),
phases: z.record(z.string(), z.number()),
}),
),
})
.parse(JSON.parse(await readFile(join(root, "test-timings.json"), "utf8")));
expect(
JSON.parse(await readFile(join(root, "test-timings.json"), "utf8")),
).toMatchObject({
reason: "passed",
unhandledErrors: 0,
files: [{ state: "passed" }],
});
expect(report.files).toHaveLength(1);
const file = report.files[0]!;
const json = z
.object({
testResults: z.array(
z.object({ startTime: z.number(), endTime: z.number() }),
),
})
.parse(JSON.parse(await readFile(join(root, "body.json"), "utf8")));
const body = json.testResults[0]!;
expect(
file.phases.execution! - (body.endTime - body.startTime),
).toBeGreaterThan(100);
expect(file.phases.imports).toBeGreaterThan(0);
expect(file.phases.preparation).toBeGreaterThan(0);
expect(file.durationMs).toBeCloseTo(
Object.values(file.phases).reduce((sum, value) => sum + value, 0),
);
await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: { ...process.env, SHARD_RESULT: "success", MERGE_RESULT: "success" },
});
expect(
parseTestDurations(
await readFile(
join(root, ".cache/ci-test-history/test-results.json"),
"utf8",
),
root,
)?.get("sample.test.js"),
).toBe(file.durationMs);
await writeFile(
join(root, "sample.test.js"),
'it("fails", () => { throw new Error("fixture failure"); });',
);
await expect(run(["run", "--reporter=blob"])).rejects.toMatchObject({
code: 1,
});
await expect(
run(["--merge-reports", `--reporter=${reporter}`]),
).rejects.toMatchObject({ code: 1 });
const failed = JSON.parse(
await readFile(join(root, "test-timings.json"), "utf8"),
) as unknown;
expect(failed).toMatchObject({
version: 1,
success: false,
reason: "failed",
unhandledErrors: 0,
files: [{ state: "failed" }],
});
// Even a caller accidentally marking the merge successful cannot publish this report.
await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: { ...process.env, SHARD_RESULT: "success", MERGE_RESULT: "success" },
});
await expect(
readFile(join(root, ".cache/ci-test-history/test-results.json")),
).rejects.toMatchObject({ code: "ENOENT" });
});
it("records failed omissions from full reports without treating partial or unhandled outcomes as parity", async () => {
const root = await createTestTempDirectory("rea-ci-selection-report-");
await workflowGit(root, ["init"]);
const paths = [
"src/sqlite/provider.test.ts",
"src/evm/provider.test.ts",
"tests/boundary/mcp/workflow.test.ts",
];
for (const path of paths) {
await mkdir(join(root, path, ".."), { recursive: true });
await writeFile(join(root, path), "// fixture\n");
}
await workflowGit(root, ["add", "src", "tests"]);
const timing = {
version: 1,
success: false,
reason: "failed",
unhandledErrors: 0,
files: paths.map((name, index) => ({
name,
durationMs: 10,
state: index === 1 ? "failed" : "passed",
})),
};
const run = async (report: unknown) => {
await writeFile(join(root, "test-timings.json"), JSON.stringify(report));
await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
SHARD_RESULT: "failure",
MERGE_RESULT: "failure",
CI_VERIFY_TEST_INVENTORY: "true",
CI_TEST_SELECTION: JSON.stringify(
planTestSelection(["src/sqlite/Provider.ts"], "pull_request", "fast"),
),
GITHUB_SHA: "merge-sha",
CI_PR_HEAD_SHA: "pr-head-sha",
},
});
return JSON.parse(
await readFile(
join(root, ".cache/ci-test-selection/report.json"),
"utf8",
),
) as unknown;
};
expect(await run(timing)).toMatchObject({
observational: true,
complete: true,
selected: [paths[0], paths[2]],
omitted: [paths[1]],
missedFailures: [paths[1]],
executionSha: "merge-sha",
prHeadSha: "pr-head-sha",
});
for (const report of [
{ ...timing, files: timing.files.slice(0, 1) },
{ ...timing, unhandledErrors: 1 },
{ ...timing, reason: "interrupted" },
])
expect(await run(report)).toMatchObject({ complete: false });
await expect(run({ ...timing, files: "invalid" })).rejects.toMatchObject({
code: 1,
});
await expect(
readFile(join(root, ".cache/ci-test-selection/report.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(
readFile(join(root, ".cache/ci-test-history/test-results.json")),
).rejects.toMatchObject({ code: "ENOENT" });
});

View File

@ -17,24 +17,24 @@ describe("CI test history", () => {
const root = resolve("/workspace/rea");
const durations = parseTestDurations(
JSON.stringify({
version: 1,
success: true,
numFailedTests: 0,
testResults: [
files: [
{
name: join(root, "tests/slow.test.ts"),
startTime: 10,
endTime: 110,
durationMs: 100,
},
{
name: join(root, "tests/slow.test.ts"),
startTime: 20,
endTime: 80,
durationMs: 60,
},
{
name: resolve(root, "../outside.test.ts"),
startTime: 10,
endTime: 1000,
durationMs: 990,
},
{ name: "tests/negative.test.ts", durationMs: -1 },
{ name: "tests/zero.test.ts", durationMs: 0 },
{ name: "tests/invalid.test.ts", durationMs: "100" },
null,
],
}),
@ -43,7 +43,20 @@ describe("CI test history", () => {
expect(durations).toEqual(new Map([["tests/slow.test.ts", 100]]));
expect(
parseTestDurations('{"success": false, "numFailedTests": 1}', root),
parseTestDurations('{"version": 1, "success": false, "files": []}', root),
).toBeUndefined();
// Legacy test-body spans must not be mixed with complete module costs.
expect(
parseTestDurations(
JSON.stringify({
success: true,
numFailedTests: 0,
testResults: [
{ name: "tests/legacy.test.ts", startTime: 0, endTime: 100 },
],
}),
root,
),
).toBeUndefined();
expect(parseTestDurations("not json", root)).toBeUndefined();
expect(parseTestDurations("null", root)).toBeUndefined();

View File

@ -0,0 +1,137 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
executeWorkflowFixture,
workflowGit,
} from "../../support/workflowGit.js";
const validator = fileURLToPath(
new URL("../../../scripts/ci/verified-package.mjs", import.meta.url),
);
type Fixture = Awaited<ReturnType<typeof createFixture>>;
type FixtureOptions = {
override?: Record<string, string | number>;
manifestContents?: string;
writeManifest?: boolean;
};
it("validates a tarball against the exact checkout and current workflow attempt", async () => {
const fixture = await createFixture();
const result = await validate(fixture);
expect(result.stdout.trim()).toBe(
join(fixture.artifactDirectory, "package.tgz"),
);
expect(await readFile(fixture.output, "utf8")).toBe(
`tarball=${join(fixture.artifactDirectory, "package.tgz")}\n`,
);
});
it("rejects a workflow SHA that differs from the checked out HEAD", async () => {
const fixture = await createFixture();
await expect(validate(fixture, "c".repeat(40))).rejects.toThrow();
});
it.each([
["wrong checkout SHA", { sourceSha: "c".repeat(40) }],
["wrong workflow run", { runId: 124 }],
["future workflow attempt", { runAttempt: 3 }],
["wrong verifier platform", { platform: "darwin" }],
["wrong verifier architecture", { architecture: "arm64" }],
["wrong package digest", { sha256: "c".repeat(64) }],
])("rejects %s", async (_label, override) => {
const fixture = await createFixture({ override });
await expect(validate(fixture)).rejects.toThrow();
});
it("accepts an earlier successful attempt from the same workflow run", async () => {
const fixture = await createFixture({ override: { runAttempt: 1 } });
await expect(validate(fixture)).resolves.toMatchObject({
stdout: expect.any(String),
});
});
it.each([
["malformed manifest", "{"],
["manifest with missing fields", JSON.stringify({ schemaVersion: 1 })],
])("rejects %s", async (_label, contents) => {
const fixture = await createFixture({ manifestContents: contents });
await expect(validate(fixture)).rejects.toThrow();
});
it("rejects an absent manifest", async () => {
const fixture = await createFixture({ writeManifest: false });
await expect(validate(fixture)).rejects.toThrow();
});
async function createFixture({
override = {},
manifestContents,
writeManifest = true,
}: FixtureOptions = {}) {
const directory = await createTestTempDirectory("rea-verified-package-");
await workflowGit(directory, ["init", "--initial-branch=main"]);
await writeFile(
join(directory, "package.json"),
'{"name":"fixture","version":"1.2.3"}\n',
);
await workflowGit(directory, ["add", "package.json"]);
await workflowGit(directory, [
"commit",
"-m",
"test: verified package fixture",
]);
const sourceSha = await workflowGit(directory, ["rev-parse", "HEAD"]);
const artifactDirectory = join(directory, "artifact");
await mkdir(artifactDirectory);
const archive = "verified tarball fixture";
await writeFile(join(artifactDirectory, "package.tgz"), archive);
const manifest = {
schemaVersion: 1,
sourceSha,
runId: 123,
runAttempt: 2,
platform: "linux",
architecture: "x64",
packageVersion: "1.2.3",
nodeVersion: process.version,
sha256: createHash("sha256").update(archive).digest("hex"),
...override,
};
if (writeManifest)
await writeFile(
join(artifactDirectory, "manifest.json"),
manifestContents ?? JSON.stringify(manifest),
);
const output = join(directory, "github-output");
await writeFile(output, "");
return { directory, sourceSha, artifactDirectory, output };
}
function validate(fixture: Fixture, sourceSha = fixture.sourceSha) {
return executeWorkflowFixture(
process.execPath,
[validator, "validate", fixture.artifactDirectory],
{
cwd: fixture.directory,
env: {
...process.env,
GITHUB_SHA: sourceSha,
GITHUB_RUN_ID: "123",
GITHUB_RUN_ATTEMPT: "2",
GITHUB_OUTPUT: fixture.output,
},
},
);
}

View File

@ -649,9 +649,8 @@ it.skipIf(process.platform === "win32").each([
const sourceSha = await candidate(f, valid ? "6.0.0" : "5.1.0");
const workflow = z
.object({
jobs: z.record(
z.string(),
z.object({
jobs: z.object({
[job]: z.object({
steps: z.array(
z.object({
name: z.string().optional(),
@ -659,7 +658,7 @@ it.skipIf(process.platform === "win32").each([
}),
),
}),
),
}),
})
.parse(
parse(

View File

@ -34,6 +34,12 @@ async function readReleaseWorkflow() {
"release-proposal": jobSchema,
"release-please": jobSchema,
"merged-release": jobSchema,
qualification: z.object({
if: z.string(),
needs: z.array(z.string()),
uses: z.string(),
with: z.record(z.string(), z.string()),
}),
publish: jobSchema,
"publish-mcp": jobSchema,
}),
@ -271,10 +277,19 @@ it("binds npm and MCP publication to the same immutable release SHA", async () =
release_created: "${{ steps.release.outputs.release_created }}",
sha: "${{ steps.release.outputs.sha }}",
});
expect(workflow.jobs.publish.if).toContain("inputs.phase == 'publish'");
expect(workflow.jobs.publish.if).toContain(
expect(workflow.jobs.qualification.if).toContain("inputs.phase == 'publish'");
expect(workflow.jobs.qualification.if).toContain(
"needs.release-please.outputs.release_created",
);
expect(workflow.jobs.qualification.uses).toBe("./.github/workflows/ci.yml");
expect(workflow.jobs.qualification.with).toEqual({
source_sha:
"${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}",
});
expect(workflow.jobs.publish.needs).toContain("qualification");
expect(workflow.jobs.publish.if).toContain(
"needs.qualification.result == 'success'",
);
expect(workflow.jobs["publish-mcp"].if).toContain(
"needs.publish.result == 'success'",
);
@ -309,7 +324,7 @@ it("binds npm and MCP publication to the same immutable release SHA", async () =
)?.run;
expect(publishCommand).toContain("scripts/release-npm-tag.mjs");
expect(publishCommand).toContain(
'npm publish --access public --tag "${tag}"',
'npm publish "${VERIFIED_PACKAGE_TARBALL}" --access public --tag "${tag}"',
);
for (const [version, tag] of [
["6.1.0", "latest"],
@ -644,7 +659,7 @@ it("routes website verification through the central PR policy", async () => {
// Publishing is irreversible. Keep the release authority invariant as a static
// boundary check; it is not proof that package installation or publishing works.
it("requires package verification before publishing and retains the registry canary", async () => {
it("requires qualified package validation before publishing and retains the registry canary", async () => {
const workflow = z
.object({
jobs: z.object({
@ -663,7 +678,7 @@ it("requires package verification before publishing and retains the registry can
);
const commands = workflow.jobs.publish.steps.map(({ run }) => run ?? "");
const verify = commands.findIndex((command) =>
command.includes("npm run verify:package"),
command.includes("scripts/ci/verified-package.mjs validate"),
);
const publish = commands.findIndex((command) =>
command.includes("npm publish"),

View File

@ -489,4 +489,28 @@ describe("safe artifact output Windows stream paths", () => {
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readdir(parent)).toEqual([]);
});
it.each(["Folder/Icon\r", "Fold\ter/file.txt"])(
"refuses Windows control characters before creating %j",
async (path) => {
const parent = await createTestTempDirectory("rea-safe-output-control-");
const output = join(parent, "published");
const tree = await SafeOutputTree.create(output, "win32");
const bytes = Buffer.from("regular file content");
await expect(
tree.write(path, Readable.from(bytes), {
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.byteLength,
}),
).rejects.toMatchObject({
reason: "path",
message: `Windows destination cannot materialize artifact path: ${JSON.stringify(path)}; Windows names cannot contain U+0001-U+001F.`,
});
expect(await readdir(output)).toEqual([]);
expect(await tree.rollback()).toEqual({
status: "complete",
residualPaths: [],
});
},
);
});

View File

@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { cp, readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
@ -78,7 +78,7 @@ it.skipIf(process.platform === "android")(
await workflowGit(root, ["init", "--initial-branch=main"]);
await workflowGit(root, ["add", "."]);
await workflowGit(root, ["commit", "-m", "test: seed cache fixture"]);
const run = () =>
const run = (cwd = root) =>
execute(
process.execPath,
[
@ -87,7 +87,7 @@ it.skipIf(process.platform === "android")(
"test:run",
"--output-logs=errors-only",
],
{ cwd: root, env: { ...process.env, TURBO_DAEMON: "false" } },
{ cwd, env: { ...process.env, TURBO_DAEMON: "false" } },
);
await run();
await writeFile(join(root, "README_zh.md"), "wrong command");
@ -104,5 +104,32 @@ it.skipIf(process.platform === "android")(
"prepared\n",
);
}
// Provider jobs receive the producer's cache in a fresh checkout, not its
// outputs. Turbo must restore identical inputs and rebuild changed ones.
const consumer = await createTestTempDirectory("rea-cache-consumer-");
await cp(root, consumer, {
recursive: true,
filter: (path) =>
basename(path) !== ".turbo" &&
!/^prepared-\d+\.txt$/u.test(basename(path)),
});
await cp(join(root, ".turbo/cache"), join(consumer, ".turbo/cache"), {
recursive: true,
});
const restored = await run(consumer);
expect(restored.stdout).toContain(`${dependencies.length} cached`);
for (const index of dependencies.keys())
expect(
await readFile(join(consumer, `prepared-${index}.txt`), "utf8"),
).toBe("prepared\n");
await writeFile(
join(consumer, "artifact-input.txt"),
"changed producer input",
);
await run(consumer);
for (const index of dependencies.keys())
expect(
await readFile(join(consumer, `prepared-${index}.txt`), "utf8"),
).toBe("prepared\nprepared\n");
},
);

View File

@ -144,14 +144,26 @@ describe("enhanced MCP tools", () => {
);
expect(result).toEqual({
document: "fixture",
image_base: "0x1000",
segments: [
{ name: "__TEXT", start: "0x1000", end: "0x1800", length: 2048 },
{ name: "__DATA", start: "0x1800", end: "0x2000", length: 2048 },
{
name: "__TEXT",
start: "0x1000",
end: "0x1800",
length: 2048,
image_base: "0x1000",
},
{
name: "__DATA",
start: "0x1800",
end: "0x2000",
length: 2048,
image_base: "0x1000",
},
],
segment_count: 2,
procedure_count: 2,
string_count: 700,
image_base: "0x1000",
});
});

View File

@ -0,0 +1,121 @@
import { afterEach, expect, it } from "vitest";
import { Ajv2020 } from "ajv/dist/2020.js";
import type { AnySchemaObject } from "ajv";
import {
closeEnhancedToolResources,
connect,
jsonResult,
} from "./enhancedToolsHarness.js";
import { observed as ok } from "../../fixtures/analysisExecution.js";
import type { SegmentSummary } from "../../../src/domain/hopperValues.js";
afterEach(closeEnhancedToolResources);
const segment = {
name: ".text",
start: "0x101000",
end: "0x101100",
readable: true,
writable: false,
executable: true,
image_base: "0x100000",
address_space: "ram",
};
const overviewClient = (segments: SegmentSummary[]) =>
connect({
execute: (name) => {
switch (name) {
case "current_document":
return Promise.resolve(ok("fixture"));
case "list_documents":
return Promise.resolve(ok(["fixture"]));
case "list_segments":
return Promise.resolve(ok(segments));
case "list_procedures":
return Promise.resolve(ok([{ address: "0x101013", value: "entry" }]));
case "list_strings":
return Promise.resolve(ok([]));
default:
throw new Error(`Unexpected operation: ${name}`);
}
},
});
it("preserves observed coordinates and main's optional-string output contract after SDK conversion", async () => {
const client = await overviewClient([segment]);
const schema = (await client.listTools()).tools.find(
({ name }) => name === "binary_overview",
)?.outputSchema;
if (schema === undefined) throw new Error("Missing overview output schema");
const validate = new Ajv2020({ strict: false }).compile(
schema as AnySchemaObject,
);
const response = await client.callTool({
name: "binary_overview",
arguments: {},
});
expect(validate(response.structuredContent)).toBe(true);
expect(jsonResult(response)).toMatchObject({
image_base: "0x100000",
segments: [
{ start: "0x101000", image_base: "0x100000", address_space: "ram" },
],
});
const absent = JSON.parse(JSON.stringify(response.structuredContent));
delete absent.normalized_result.image_base;
expect(validate(absent)).toBe(true);
absent.normalized_result.image_base = null;
expect(validate(absent)).toBe(false);
});
it("omits unreported bases without inventing zero and preserves explicit zero", async () => {
const { image_base: _base, ...unreported } = segment;
const client = await overviewClient([unreported]);
expect(
jsonResult(
await client.callTool({ name: "binary_overview", arguments: {} }),
),
).not.toHaveProperty("image_base");
const zero = await overviewClient([{ ...segment, image_base: "0x0" }]);
expect(
jsonResult(await zero.callTool({ name: "binary_overview", arguments: {} })),
).toMatchObject({ image_base: "0x0" });
});
it("keeps a partially reported base unknown while retaining the reported segment observation", async () => {
const { image_base: _base, ...unreported } = segment;
const client = await overviewClient([
segment,
{ ...unreported, name: ".data" },
]);
const result = jsonResult(
await client.callTool({ name: "binary_overview", arguments: {} }),
);
expect(result).not.toHaveProperty("image_base");
expect(result).toMatchObject({
segments: [{ image_base: "0x100000" }, { name: ".data" }],
});
});
it("retains all conflicting observations after repeated early bases without selecting one", async () => {
const { address_space: _space, ...unreported } = segment;
const client = await overviewClient([
...Array.from({ length: 40 }, () => unreported),
{
...segment,
name: ".data",
start: "other%20space:0x201000",
end: "other%20space:0x201100",
image_base: "0x200000",
address_space: "other space",
},
]);
const result = jsonResult(
await client.callTool({ name: "binary_overview", arguments: {} }),
);
expect(result).not.toHaveProperty("image_base");
expect(result).toHaveProperty("segments.length", 41);
expect(result).toHaveProperty("segments.40.image_base", "0x200000");
expect(result).toHaveProperty("segments.40.address_space", "other space");
expect(result).not.toHaveProperty("segments.0.address_space");
});

Some files were not shown because too many files have changed in this diff Show More