merge: retain main CI planning and artifact checks
This commit is contained in:
commit
de2ab343f1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}'
|
||||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ coverage/
|
|||
.vitest/
|
||||
.vitest-reports/
|
||||
test-results.xml
|
||||
test-timings.json
|
||||
.turbo/
|
||||
.cache/
|
||||
docs/.vitepress/cache/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"threshold": 3.0,
|
||||
"threshold": 3.5,
|
||||
"reporters": ["consoleFull"],
|
||||
"ignore": [
|
||||
"node_modules/**",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
245
docs/testing.md
245
docs/testing.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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]@"),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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":{}',
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}),
|
||||
],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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_");
|
||||
},
|
||||
);
|
||||
|
|
@ -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"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
|
|
@ -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" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue