Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f337cb398 | ||
|
|
fb28ec1537 | ||
|
|
490a285070 | ||
|
|
fa0a9b2d32 | ||
|
|
c8d861f976 | ||
|
|
bd2c202dd9 | ||
|
|
9f3f392eb8 | ||
|
|
f0f09950f3 | ||
|
|
3af8f847f0 | ||
|
|
747e729914 | ||
|
|
2995ec6910 | ||
|
|
ec3db37457 | ||
|
|
f1f5582ce8 | ||
|
|
3d78a57043 | ||
|
|
b06751b703 | ||
|
|
806ff57567 | ||
|
|
ca07373e44 | ||
|
|
8583a190c0 | ||
|
|
fe496bd1cf | ||
|
|
f8e95799f9 | ||
|
|
6c816af119 | ||
|
|
ab02f3c8d6 | ||
|
|
9928f357fb | ||
|
|
d13c9f840d | ||
|
|
0cf224e762 | ||
|
|
52d4ce6ced | ||
|
|
e05d3399f1 | ||
|
|
db3c9f084b | ||
|
|
0c7f4dfbd9 | ||
|
|
c02fb0ea80 | ||
|
|
96fc84f1fb | ||
|
|
b69d20a300 | ||
|
|
11f8b76257 | ||
|
|
11f1d952e7 | ||
|
|
a0317cf732 | ||
|
|
67c7a24e60 | ||
|
|
c7a890d129 | ||
|
|
b955b825fc | ||
|
|
9cf49c7a78 | ||
|
|
c923fb0dc9 | ||
|
|
ded199abcd | ||
|
|
ed881c6d2e | ||
|
|
eaa9616585 | ||
|
|
bf34d0d2fc | ||
|
|
e802441bce | ||
|
|
e7882b34f4 | ||
|
|
9049dbaf0c | ||
|
|
7fc0b5ec9c | ||
|
|
6a05248d46 | ||
|
|
20f51d7286 | ||
|
|
6bd2b28161 | ||
|
|
d9e9fb2273 | ||
|
|
b953f96c0a | ||
|
|
4276aaf18b | ||
|
|
fe8d0bdd36 | ||
|
|
9c34c091b3 | ||
|
|
b9ee921560 | ||
|
|
15463bdbe6 | ||
|
|
55e21a2eec | ||
|
|
f6775a3669 | ||
|
|
a07ceaec36 | ||
|
|
6a04f922e1 | ||
|
|
e74e4e951b | ||
|
|
68ecf3df8c | ||
|
|
ab62ee9dcf | ||
|
|
2c6a4fac4f | ||
|
|
c6acd19b6d | ||
|
|
1a1b67c888 | ||
|
|
12303812a8 | ||
|
|
f6556ad2bc | ||
|
|
5f0f25ad61 | ||
|
|
d73ed6fce2 | ||
|
|
60f771a11e | ||
|
|
650237efb0 | ||
|
|
26bffb94bf | ||
|
|
7017c4d68c | ||
|
|
141d2095f5 | ||
|
|
1b953021ea | ||
|
|
e1cd292aa6 | ||
|
|
18831491cf |
@@ -76,6 +76,9 @@ explicit path or endpoint and do not need it.
|
||||
Facts retain all-recorded-events-before-selection scope. Other qlog layouts
|
||||
and PCAP correlation are unsupported.
|
||||
- User-owned Electron runtime already open: `list_electron_targets`.
|
||||
- Packet-field discovery: `inspect_packet_fields` searches installed protocols
|
||||
and field names/types before choosing fields for `inspect_packet_capture`.
|
||||
Query/page the relevant records; registrations do not establish observed traffic.
|
||||
- Recorded packet captures: `inspect_packet_capture` with selected TShark fields,
|
||||
display filters and decode-as rules. Requires Linux, util-linux prlimit and `REA_TSHARK_COMMAND`; see
|
||||
[capture guide](https://github.com/morluto/rea/blob/main/docs/packet-captures.md).
|
||||
@@ -89,6 +92,9 @@ explicit path or endpoint and do not need it.
|
||||
[packet summaries](https://github.com/morluto/rea/blob/main/docs/pcap-analysis.md).
|
||||
Select an exact DNS question for address pages; page exhaustion does not prove
|
||||
network completeness. This reads recordings and does not acquire traffic.
|
||||
- Decoder discovery: `inspect_signal_decoders` searches installed decoder IDs
|
||||
or selected decoder help, including channels and options. Query and page the
|
||||
relevant details before choosing settings; this does not scan hardware.
|
||||
- Recorded logic/signal traces: `decode_signal_recording` with explicit decoder,
|
||||
channel and protocol settings. On Linux with util-linux prlimit, supply sigrok-cli 0.7.2 through
|
||||
`REA_SIGROK_COMMAND`; see [recording guide](https://github.com/morluto/rea/blob/main/docs/signal-recordings.md).
|
||||
|
||||
@@ -83,6 +83,14 @@ names, the native API record, and value-flow summaries. Pseudocode offsets and l
|
||||
code units; use the returned `next_offset` for continuation. Provider limitations
|
||||
and unavailable facts remain explicit; the complete parent Evidence is retained.
|
||||
|
||||
`inspect_macho` on a large framework returns megabytes of imports and exports.
|
||||
Select `detail: "summary"` for slice identity, UUID and per-collection counts,
|
||||
then page rows with `view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`
|
||||
and `normalized_result.parent_evidence_id`. Facets are entrypoints,
|
||||
architectures, build_metadata, load_commands, dependencies, imports, exports,
|
||||
segments and provenance. `source_exhaustive: false` means unreturned symbols
|
||||
may exist.
|
||||
|
||||
To see which functions or Objective-C methods a Mach-O actually calls in one
|
||||
run, use `observe_native_calls` with explicit breakpoints and a bounded
|
||||
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
|
||||
@@ -111,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
|
||||
+175
-71
@@ -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
|
||||
@@ -435,7 +522,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
|
||||
@@ -464,7 +551,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
|
||||
@@ -576,6 +663,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
|
||||
@@ -595,73 +683,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,
|
||||
@@ -674,7 +767,6 @@ jobs:
|
||||
reconstruction-readiness,
|
||||
windows-curated,
|
||||
docs,
|
||||
workflows,
|
||||
browser,
|
||||
binary_layout,
|
||||
evm,
|
||||
@@ -687,12 +779,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: |
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- run: sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark util-linux python3
|
||||
- run: npm ci
|
||||
- run: npm run build:cached
|
||||
- run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts
|
||||
- run: npm run test:focused -- tests/boundary/captures/fieldDiscovery.test.ts src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts
|
||||
- run: node scripts/verify-packet-capture.mjs
|
||||
env:
|
||||
REA_TSHARK_COMMAND: /usr/bin/tshark
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- run: sudo apt-get update && sudo apt-get install -y sigrok-cli util-linux python3
|
||||
- run: npm ci
|
||||
- run: npm run build:cached
|
||||
- run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts src/domain/signals/annotationTextWindow.test.ts tests/boundary/signals/recordingProvider.test.ts tests/boundary/signals/recordingDescriptorCleanup.test.ts tests/boundary/signals/resourceLimits.test.ts tests/boundary/mcp/signalRecordingMcp.test.ts tests/boundary/cli/signalCleanup.test.ts
|
||||
- run: npm run test:focused -- tests/boundary/signals/decoderDiscovery.test.ts src/process/LinuxResourceLimits.test.ts src/domain/signals/annotationTextWindow.test.ts tests/boundary/signals/recordingProvider.test.ts tests/boundary/signals/recordingDescriptorCleanup.test.ts tests/boundary/signals/resourceLimits.test.ts tests/boundary/mcp/signalRecordingMcp.test.ts tests/boundary/cli/signalCleanup.test.ts
|
||||
- run: node scripts/verify-signal-recording.mjs
|
||||
env:
|
||||
REA_SIGROK_COMMAND: /usr/bin/sigrok-cli
|
||||
|
||||
@@ -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/
|
||||
@@ -72,3 +73,5 @@ Thumbs.db
|
||||
|
||||
# Complete static website output, including generated language editions.
|
||||
website/_site/
|
||||
# Temporary translation output prepared before replacing the complete site.
|
||||
website/.i18n-*/
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -93,7 +93,7 @@ def project_record(state, secrets):
|
||||
safe_bytes, credential_url = transport_value(value, path, transport_url)
|
||||
content_base64 = base64.b64encode(value).decode("ascii")
|
||||
digest = hashlib.sha256(value).hexdigest()
|
||||
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest for secret in secrets)
|
||||
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest or secret in str(len(value)) for secret in secrets)
|
||||
safe_text = None
|
||||
try:
|
||||
safe_text = safe_bytes.decode("utf-8", errors="strict")
|
||||
|
||||
@@ -174,7 +174,7 @@ Resolution follows dyld:
|
||||
- `@rpath` tries each `LC_RPATH` of the loading image first, then those of the
|
||||
image that loaded it, up to the process root.
|
||||
- Dependencies load dependents-first in load-command order, and each image
|
||||
loads once per process. A later request whose install name matches an
|
||||
loads once per process. A later @rpath request whose install name matches an
|
||||
already loaded image reuses it.
|
||||
- Symlinks such as `Versions/Current` are followed inside the analyzed root.
|
||||
An escape through `..` or an absolute link is `escapes-target`.
|
||||
|
||||
@@ -298,13 +298,42 @@ subsequent wire recovery and clean shutdown. Caller timeout does not interrupt
|
||||
Hopper's synchronous native operation. Socket boundary tests deterministically
|
||||
cover active and queued expiry, late replies and timer cleanup.
|
||||
|
||||
The Linux demo lane remains a separate `verify:hopper:linux` command. Its
|
||||
lifecycle checks attempt competing CLI launches for the active target and a
|
||||
The Linux demo lane remains a separate `verify:hopper:linux` command. Hopper
|
||||
6.4.2's ELF loader did not recognize the original 4,119-byte literal or three
|
||||
non-ASCII byte strings in the measured fixtures. The lane retains complete raw
|
||||
byte checks for those literals through source-owned symbols. Recognition of the
|
||||
original full-length literal is bound to its exact source address; its decoded
|
||||
object coverage remains explicitly unverified on Linux even when recognized.
|
||||
Absent non-ASCII recognition also reports decoded-object coverage as unverified.
|
||||
Recognized non-ASCII and control-text objects must still match their source
|
||||
bytes, encoding, filters and searches.
|
||||
A separate 548-byte ASCII literal requires native fragmentation, shortened
|
||||
provider display, complete decoded fragments and dossier provenance on Linux;
|
||||
the macOS lane retains the original full-length recognition requirement. These
|
||||
are observed fixture/build capabilities, not universal Hopper length limits.
|
||||
Its lifecycle checks attempt competing CLI launches for the active target and a
|
||||
different target, requiring rejection before either can change the owning
|
||||
MCP session's documents or procedures. Lease boundary tests also preserve
|
||||
unresponsive or malformed live endpoints while allowing confirmed stale
|
||||
sockets to recover.
|
||||
|
||||
`verify:setup:linux` exercises installation on a clean supported Linux runner as
|
||||
root. Use a disposable VM: this lane installs Hopper and its Linux runtime
|
||||
dependencies through the system package manager. Set `REA_VERIFY_SERVER_COMMAND`
|
||||
to the CLI entrypoint being verified. The command builds and checks source-owned
|
||||
ELF fixtures before testing the read-only Hopper setup plan, approved
|
||||
installation, scoped doctor, real CLI/MCP analysis, rejection of a modified
|
||||
launcher, idempotent setup without requesting a reinstall, and owned child
|
||||
cleanup. Before installation and after teardown, a clean-runner process scan
|
||||
also rejects surviving Hopper demo helpers or Xvfb groups, including orphans
|
||||
whose launcher exited. This scan observes processes without signaling them.
|
||||
Hopper detection compares executable file identity through `/proc`, covering
|
||||
alternate or configured launcher paths even after an invoked symlink is removed.
|
||||
The dedicated runner must have no unrelated Hopper/Xvfb sessions. It requires
|
||||
the repository's pinned development toolchain and an
|
||||
existing host C compiler, `file`, `nm`, and `strings`. Use `verify:hopper:linux`
|
||||
instead when Hopper is already installed.
|
||||
|
||||
`verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the
|
||||
existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and
|
||||
one- and two-slice FAT32 containers, verifies exact/interior address mappings
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -57,6 +57,10 @@ Hopper installation or any file write. Declining or pressing Ctrl-C makes no
|
||||
changes. Agent configuration writes preserve unrelated entries and comments,
|
||||
create backups, use atomic replacement, and verify their result. Setup and
|
||||
uninstall retain an existing `.rea.backup` rather than replacing the first snapshot.
|
||||
If that snapshot differs from the current configuration, setup previews and
|
||||
preserves a verified `.rea.backup.<sha256>.recovery` copy of the current bytes.
|
||||
Both snapshots remain available; a failed rollback reports the current recovery
|
||||
copy's path and the restore failure.
|
||||
Setup also retains valid environment overrides in REA's existing server entry,
|
||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
|
||||
Detected provider settings replace only matching environment keys.
|
||||
|
||||
@@ -104,6 +104,15 @@ static inferences. Dynamic or ambiguous IPC is retained without a pairing edge.
|
||||
Runtime entities retain passive-CDP authority, while a successful static/runtime
|
||||
correspondence is a separately identified cross-layer inference.
|
||||
|
||||
Source module links retain their resolution mode. Static imports in `.ts` and
|
||||
`.tsx` are `compiler-dependent` when compiler options have not been retained.
|
||||
REA compares Node import and require resolution: a shared outcome remains
|
||||
available with a limitation; divergent outcomes remain unavailable with both
|
||||
conditional branches and their limitations. This models source relationships,
|
||||
not verified emitted code or execution. A declaration file found only through
|
||||
an emitted-extension fallback is retained as a candidate, not a runtime target;
|
||||
an explicitly selected path keeps its original identity.
|
||||
|
||||
## Evidence and authority
|
||||
|
||||
Every observation and edge carries all of the following:
|
||||
|
||||
@@ -23,6 +23,14 @@ artifact digest and structural graph ID. Semantic tracing requires the semantic
|
||||
relation graph and reports when it is unavailable rather than treating missing
|
||||
data as an empty graph.
|
||||
|
||||
Explicit TypeScript `import type` and `export type` declarations remain source
|
||||
facts and do not create runtime import or export relationships. Type export names
|
||||
are reported separately from value export names. Inline `type` specifiers can
|
||||
leave an empty import or re-export with side effects under
|
||||
[`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/verbatimModuleSyntax.html);
|
||||
REA retains those dependency candidates and reports the unavailable compiler
|
||||
options rather than assuming the dependency is erased.
|
||||
|
||||
The semantic graph wire format stores shared non-location provenance once in
|
||||
its required `evidence_contexts` table. Each node, relation, fingerprint, and
|
||||
unknown retains its exact `evidence.location` and names the owning context with
|
||||
|
||||
+15
-2
@@ -147,8 +147,8 @@ after target content was read.
|
||||
|
||||
## Retained application Evidence inputs
|
||||
|
||||
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, and
|
||||
`inspect_wasm_artifact` accept `detail`:
|
||||
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`,
|
||||
`inspect_wasm_artifact`, and `inspect_macho` accept `detail`:
|
||||
`complete` (default) returns the complete analysis Evidence; `summary` records
|
||||
that complete Evidence in the session and returns the derived
|
||||
`inspect_analysis_view` summary Evidence instead. Its
|
||||
@@ -218,6 +218,19 @@ attempting oversized delivery, and returns the same summary with an exact
|
||||
`parent_evidence_id` for later export or facet reads. Delivery detail does not
|
||||
reach WABT or change the original source Evidence ID.
|
||||
|
||||
Completed `inspect_macho` Evidence supports a summary and
|
||||
`view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`. Facets are
|
||||
`entrypoints`, `architectures`, `build_metadata`, `load_commands`,
|
||||
`dependencies`, `imports`, `exports`, `segments`, and `provenance`; pages return
|
||||
the original rows. The summary keeps the selected slice, header fields and UUID,
|
||||
and replaces each collection with its returned count, reported total and
|
||||
`exhaustive` flag. Each page repeats the paged collection's `source_total` and
|
||||
`source_exhaustive`; exhausting a non-exhaustive collection does not establish
|
||||
that unreturned symbols are absent. Large frameworks return megabytes of
|
||||
symbols (Electron Framework: 2.5 MB of imports and exports, 3.3 KB as a
|
||||
summary), so select `detail: "summary"` on the first call. Delivery detail
|
||||
never reaches the native tools or changes the source Evidence ID.
|
||||
|
||||
`trace_application_feature`, `trace_javascript_semantics`,
|
||||
`compare_application_versions`, `compare_source_to_bundle`, and
|
||||
`compare_javascript_export_shapes` accept complete inline application Evidence
|
||||
|
||||
@@ -128,3 +128,49 @@ Each provider admits one inspection at a time, including acquisition and cleanup
|
||||
An occupied provider returns a retryable capacity error before acquiring another
|
||||
file or worker. Closing the provider stops new admission and drains accepted work;
|
||||
failed cleanup remains owned and can be retried.
|
||||
|
||||
## Discover protocols and field names
|
||||
|
||||
`inspect_packet_fields` / `inspect-packet-fields` queries the installed TShark
|
||||
registration glossary before selecting fields for capture inspection:
|
||||
|
||||
```sh
|
||||
rea inspect-packet-fields --query usb
|
||||
rea inspect-packet-fields --kind fields --protocol usb --query idVendor
|
||||
rea inspect-packet-fields --kind fields --protocol udp --all
|
||||
```
|
||||
|
||||
`kind` defaults to `protocols`; `fields` selects field definitions. `protocol`
|
||||
selects an exact protocol abbreviation (the protocol itself or its child fields).
|
||||
`query` is a case-insensitive literal substring in name, abbreviation or field
|
||||
description. `offset` skips matching records; `limit` defaults to 50. MCP
|
||||
`limit: null` or CLI `--all` requests all remaining matches within the resource
|
||||
budget. `--all` and `--limit` conflict. The window reports examined records of
|
||||
the selected kind before filters, matching records, returned records and
|
||||
`has_more`; it never counts capture packets.
|
||||
|
||||
Returned records preserve field type, parent protocol, display base, bitmask,
|
||||
description and original glossary line number. Empty producer strings remain
|
||||
empty; protocol records have null field-only properties. Repeated abbreviations
|
||||
remain separate registrations. A listed field is an installation capability, not
|
||||
proof that the field appears in a selected recording.
|
||||
|
||||
The adapter uses the documented `tshark -G fields` grammar under its existing
|
||||
private empty profile and Linux process limits. TShark 4.6.9's full glossary was
|
||||
measured at 22,303,197 bytes; discovery admits at most **32 MiB** combined command
|
||||
output (capture inspection remains at **4 MiB**). Parsing walks lines once and
|
||||
constructs objects only for the selected page. Returned strings are copied so
|
||||
retained pages do not retain the complete glossary string. Before constructing a selected
|
||||
record it charges six times the source UTF-8 bytes plus 512 bytes against an
|
||||
**8 MiB** representation budget, covering JSON escaping and record bookkeeping.
|
||||
If that budget would be exceeded, narrow the query or page size; results are not
|
||||
silently truncated. This is a conservative admission budget, not measured heap
|
||||
usage. Parent pipe buffers and producer per-process limits remain distinct.
|
||||
|
||||
Tool/limiter identity, version, stderr and selected arguments are retained in
|
||||
Evidence. No capture, interface or device is opened. Discovery shares capture
|
||||
analysis's admission slot and cleanup owner. Each call rereads the glossary;
|
||||
installed plugins, libraries and global configuration remain trusted and are not
|
||||
fully fingerprinted. There is no cache or snapshot-consistent pagination claim.
|
||||
The real Linux lane checks public CLI/MCP queries against the direct glossary,
|
||||
including USB vendor-ID typing, negative queries and paging.
|
||||
|
||||
+12
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -106,3 +106,37 @@ One decode is admitted at a time, including acquisition and cleanup. Excess
|
||||
requests receive a retryable capacity error before acquiring another file or
|
||||
worker. Shutdown closes admission and drains accepted work; failed cleanup
|
||||
remains owned and retryable.
|
||||
|
||||
## Discover decoders before choosing settings
|
||||
|
||||
`inspect_signal_decoders` / `inspect-signal-decoders` queries the **installed**
|
||||
sigrok catalog without opening a recording or scanning devices:
|
||||
|
||||
```sh
|
||||
rea inspect-signal-decoders --query uart
|
||||
rea inspect-signal-decoders --decoder uart --query baud
|
||||
rea inspect-signal-decoders --decoder i2c --offset 0 --limit 50
|
||||
```
|
||||
|
||||
Omit `decoder` to search decoder IDs and names. Supply one exact decoder ID to
|
||||
read its literal help, including required/optional channels, options, annotation
|
||||
classes and documentation. `query` is a case-insensitive literal substring;
|
||||
matching help retains original one-based line numbers. Empty query selects all.
|
||||
`offset` skips matches; `limit` defaults to 50. MCP `limit: null` or CLI `--all`
|
||||
returns all remaining matches; CLI `--all` and `--limit` conflict.
|
||||
The window reports examined, matching and returned records and `has_more`.
|
||||
A filtered empty page is not evidence that the installation has no decoders.
|
||||
|
||||
Discovery refuses more than 65,535 line separators before constructing record
|
||||
objects (65,536-line representation budget), in addition to the 4 MiB output cap.
|
||||
Exceeding that budget is a resource failure with captured output. Returned text
|
||||
is copied so a retained page does not keep the complete producer output alive.
|
||||
|
||||
Discovery shares decoding's owned process, admission slot, resource limits,
|
||||
cleanup retries and Evidence delivery. It records the executable and limiter
|
||||
identities, version banner and warnings. Failed version checks and malformed
|
||||
producer output preserve diagnostics. Help is producer text, not an independently
|
||||
validated parameter schema. Each request probes again; it does not cache or claim
|
||||
stable pages across installation changes. Libraries/decoder modules remain
|
||||
trusted and are not fully fingerprinted. Catalog output includes a SHA-256 of
|
||||
its executable, not a digest of the complete installation.
|
||||
|
||||
+211
-34
@@ -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,
|
||||
|
||||
@@ -153,8 +153,12 @@ the private runtime, isolating Ghidra preferences, logs, and caches.
|
||||
Process creation assigns a suspended child atomically to an owned Job Object.
|
||||
Membership and kill-on-owner-close policy are verified before resume.
|
||||
Breakaway is disabled. Only the child's default file owner is normalized;
|
||||
the caller token stays unchanged. Caller CPU affinity and priority are
|
||||
preserved. Close, cancellation, timeout, startup failure, and provider failure
|
||||
the caller token stays unchanged. Caller priority is preserved, and caller CPU
|
||||
affinity whenever the caller and the child share a processor group. A child that
|
||||
Windows places in another group keeps that group's default when the caller asked
|
||||
for no bound of its own; a caller pinned to fewer processors than its group
|
||||
holds is refused there rather than widened.
|
||||
Close, cancellation, timeout, startup failure, and provider failure
|
||||
terminate the job and verify all members have settled before runtime cleanup.
|
||||
Cleanup removes admitted objects by handle and unlinks reparse entries without
|
||||
following their targets. Abrupt owner death kills the job but can leave
|
||||
|
||||
@@ -69,10 +69,22 @@ without this lane reports Windows native controls as unavailable.
|
||||
limits only for its JavaScript helpers when running on Bun, which ignores
|
||||
their V8 heap flags; Node.js launches are unchanged.
|
||||
- Only the suspended child's default file owner is normalized. Caller token
|
||||
privileges and ownership remain unchanged. Caller CPU affinity and process
|
||||
priority are preserved in the child. Environment names are sorted and
|
||||
deduplicated using Windows ordinal comparison, preserving their native Unicode
|
||||
semantics and caller-selected values.
|
||||
privileges and ownership remain unchanged. Caller process priority is
|
||||
preserved in the child, and caller CPU affinity whenever both occupy the same
|
||||
processor group. Windows chooses a new process's group itself and rejects a
|
||||
mask belonging to another group, so a child placed elsewhere keeps that
|
||||
group's default when the caller asked for no bound of its own. A caller
|
||||
deliberately pinned to fewer processors than its group holds is a constraint
|
||||
such a child cannot express, and is refused rather than widened to that
|
||||
group's default. A mask covering its group's whole active set does not
|
||||
distinguish a caller Windows placed in that group from one that selected the
|
||||
group deliberately, so such a child can still be placed elsewhere: its
|
||||
processor count stays bounded, its group placement is not preserved, and
|
||||
neither that case nor the refusal above has a verification lane, because both
|
||||
need a host whose processors span more than one group.
|
||||
Environment names are sorted and deduplicated using Windows
|
||||
ordinal comparison, preserving their native Unicode semantics and
|
||||
caller-selected values.
|
||||
- Cleanup walks locked directory objects, removes files by handle, and unlinks
|
||||
reparse entries without traversing their targets. Failed removal retains the
|
||||
root and ancestor leases for retry; a partially removed runtime accepts only
|
||||
|
||||
@@ -68,6 +68,18 @@ static ULONGLONG jobMemoryLimit(napi_env env, napi_value value) {
|
||||
return limit;
|
||||
}
|
||||
|
||||
// An affinity mask is meaningful only inside one processor group, so only a
|
||||
// process occupying exactly one group has a group a child can inherit. A
|
||||
// process whose threads span groups, or whose membership cannot be read, has no
|
||||
// single group.
|
||||
static bool singleProcessorGroup(HANDLE process, USHORT& group) {
|
||||
USHORT count = 1;
|
||||
std::array<USHORT, 1> groups{};
|
||||
if (!GetProcessGroupAffinity(process, &count, groups.data()) || count != 1) return false;
|
||||
group = groups[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
static void pipe(Handle& read, Handle& write, bool parentReads = true) {
|
||||
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
|
||||
HANDLE rawRead, rawWrite;
|
||||
@@ -168,11 +180,27 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
|
||||
TOKEN_OWNER owner{reinterpret_cast<TOKEN_USER*>(tokenUser.data())->User.Sid};
|
||||
require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)),
|
||||
"Set suspended process file owner failed", command);
|
||||
// A mask names processors inside one processor group, and Windows assigns a
|
||||
// new process to a group of its own choosing, so a mask belonging to another
|
||||
// group is rejected (ERROR_INVALID_PARAMETER). An unconstrained caller, whose
|
||||
// mask is its group's whole active set, asks for no bound the child's own
|
||||
// group cannot give, so such a child keeps that group's default and the spawn
|
||||
// proceeds. A narrower mask is a deliberate CPU constraint that a child in
|
||||
// another group cannot express, and silently widening it would hand the
|
||||
// provider tree the whole group, so that spawn fails naming the constraint.
|
||||
DWORD_PTR affinity = 0, systemAffinity = 0;
|
||||
require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity),
|
||||
"Read caller process affinity failed", command);
|
||||
if (affinity != 0)
|
||||
USHORT callerGroup = 0, childGroup = 0;
|
||||
const bool sharedGroup = singleProcessorGroup(GetCurrentProcess(), callerGroup) &&
|
||||
singleProcessorGroup(result->process.get(), childGroup) &&
|
||||
callerGroup == childGroup;
|
||||
if (affinity != 0 && sharedGroup)
|
||||
require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command);
|
||||
else
|
||||
require(affinity == 0 || affinity == systemAffinity,
|
||||
"Caller CPU affinity cannot bound an owned process in another processor group", command,
|
||||
ERROR_INVALID_PARAMETER);
|
||||
const DWORD priority = GetPriorityClass(GetCurrentProcess());
|
||||
require(priority != 0 && SetPriorityClass(result->process.get(), priority),
|
||||
"Preserve caller process priority failed", command);
|
||||
|
||||
+2
-1
@@ -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",
|
||||
@@ -207,7 +208,7 @@
|
||||
"fixtures:harmony": "node scripts/fixtures/harmony/fetch.mjs",
|
||||
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
|
||||
"verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs",
|
||||
"verify:setup:linux": "npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
|
||||
"verify:setup:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
|
||||
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
||||
"build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs",
|
||||
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
:robot: I have created a release *beep* *boop*
|
||||
---
|
||||
|
||||
|
||||
## [6.5.0](https://github.com/morluto/rea/compare/rea-agents-6.4.0...rea-agents-6.5.0) (2026-10-13)
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* **runtime:** attached fields in JavaScript runtime, browser, and Electron target discovery and inspection results may be null when discovery does not report attachment state.
|
||||
* **sourcemaps:** Browser source-map original and mapping rows expose null for unavailable source names instead of inventing an unknown-source string. New index fields are optional when reading older retained evidence.
|
||||
* **analysis:** get_call_graph normalized_result now contains { graph, limitations }; traversal-depth groups are under graph.
|
||||
* **mcp:** binary_session no longer includes server_identity.catalog.tools; tool_availability and provider capabilities use grouped facts documented in docs/mcp-contracts.md.
|
||||
|
||||
### Features
|
||||
|
||||
* add Frida instrumentation support ([#1359](https://github.com/morluto/rea/issues/1359)) ([4a4ae9b](https://github.com/morluto/rea/commit/4a4ae9b2b9712f7ac16f2d23c71b5c29b0c23d2f))
|
||||
* **analysis:** add opt-in retained inventory and WASM views ([#2122](https://github.com/morluto/rea/issues/2122)) ([58fb372](https://github.com/morluto/rea/commit/58fb372071e56da9c5df0e37593ee3f0da6b64bc)), closes [#2016](https://github.com/morluto/rea/issues/2016)
|
||||
* **android:** add opt-in package summaries and retained manifest views ([f254f5a](https://github.com/morluto/rea/commit/f254f5ac2b804b353a711264bb178f43fdf07867))
|
||||
* **browser:** inspect retained aioquic qlog JSON captures ([bb154d1](https://github.com/morluto/rea/commit/bb154d1067b0fc250452b7d468d0b0523031a16f))
|
||||
* **captures:** discover installed protocol and field definitions ([#2496](https://github.com/morluto/rea/issues/2496)) ([9c34c09](https://github.com/morluto/rea/commit/9c34c091b3b8c47f8bd18d6f0321efde2dedb92a))
|
||||
* **captures:** inspect recorded packets with bounded TShark delivery ([#2318](https://github.com/morluto/rea/issues/2318)) ([f611d8c](https://github.com/morluto/rea/commit/f611d8ce722cd36345e9ff83b9669ef2e7fe57a9))
|
||||
* **clients:** register Qoder CLI during setup ([#1572](https://github.com/morluto/rea/issues/1572)) ([1097693](https://github.com/morluto/rea/commit/109769319a4a3a5f85e8a43bb419d04aefff66a4))
|
||||
* **clients:** register Trae during setup ([#1573](https://github.com/morluto/rea/issues/1573)) ([bfa805d](https://github.com/morluto/rea/commit/bfa805decf83744bd3ecb0d1402667cf3ed5c3b2))
|
||||
* **clients:** register ZCode during setup ([#1564](https://github.com/morluto/rea/issues/1564)) ([51d6c87](https://github.com/morluto/rea/commit/51d6c87cdbf5f11eaf3cb1862befe704a81827c5))
|
||||
* **evm:** add retained interface summary delivery ([#2308](https://github.com/morluto/rea/issues/2308)) ([f76163c](https://github.com/morluto/rea/commit/f76163c2467db623f9de0bda757c9d8610ca9565)), closes [#2016](https://github.com/morluto/rea/issues/2016)
|
||||
* **fiddler:** inspect native SAZ captures ([a2d8b94](https://github.com/morluto/rea/commit/a2d8b94dd50dab8d966239dd63ab84718aa8b4b4))
|
||||
* **flutter:** identify Dart snapshot builds and inspect AOT payloads by pure APK parsing ([#1582](https://github.com/morluto/rea/issues/1582)) ([e4d3e13](https://github.com/morluto/rea/commit/e4d3e1399d9cd4932653e7015a1bf8589e56029d))
|
||||
* **ghidra:** admit bounded PSP ELF through installed Allegrex ([#1599](https://github.com/morluto/rea/issues/1599)) ([e4407db](https://github.com/morluto/rea/commit/e4407dbed5dc24851f47532f5e3d75d5627d3e7d))
|
||||
* **ghidra:** admit relocatable MIPS32r2 ELF objects ([#1950](https://github.com/morluto/rea/issues/1950)) ([290d301](https://github.com/morluto/rea/commit/290d301ec770955f313c2d48678ed0ae3b9f8a73))
|
||||
* **ghidra:** admit RISC-V ELF analysis through a pinned generic lane ([#2151](https://github.com/morluto/rea/issues/2151)) ([0baad58](https://github.com/morluto/rea/commit/0baad58e606f12a8dc6cef06253f85350378e5b1))
|
||||
* **ghidra:** admit soft-float MIPS32r2 ELF with integer-register float storage ([#1949](https://github.com/morluto/rea/issues/1949)) ([5e219de](https://github.com/morluto/rea/commit/5e219de44134d34f5a07a9cd1911fb2295bf1d88))
|
||||
* **ghidra:** admit verified MIPS16 shared ELF images ([e9fe643](https://github.com/morluto/rea/commit/e9fe6437a7da381f0aa5aa77571dcf03317122e7)), closes [#1851](https://github.com/morluto/rea/issues/1851)
|
||||
* **ghidra:** apply validated analyzer overrides before analysis ([9878bb7](https://github.com/morluto/rea/commit/9878bb7296c13c2b4838be76f6128e5fc31eda3c))
|
||||
* **go:** inspect embedded binary build metadata ([#1363](https://github.com/morluto/rea/issues/1363)) ([a1b5d51](https://github.com/morluto/rea/commit/a1b5d51a8dfb7fff6272d91270c16b95555c2643))
|
||||
* **harmony:** project HarmonyOS package inventory evidence ([#1447](https://github.com/morluto/rea/issues/1447)) ([f2150eb](https://github.com/morluto/rea/commit/f2150eb9a7ed76748238009ca6666efa52de5271))
|
||||
* **ida:** support optional native IDA Free MCP in attached mode ([#1544](https://github.com/morluto/rea/issues/1544)) ([d1aaeee](https://github.com/morluto/rea/commit/d1aaeee400e0d9911d5c53339ca3ac42dccbbba3))
|
||||
* **javascript:** analyze selected application sources ([85c1d25](https://github.com/morluto/rea/commit/85c1d2587a7d42c6e2108405c802fe7f7b046c15)), closes [#1848](https://github.com/morluto/rea/issues/1848)
|
||||
* **javascript:** recover code-split bundles from multiple inputs ([#1686](https://github.com/morluto/rea/issues/1686)) ([12c4160](https://github.com/morluto/rea/commit/12c416005f9d2abf438f4df4447972424d781b65))
|
||||
* **javascript:** resolve exact package import aliases ([#1862](https://github.com/morluto/rea/issues/1862)) ([d28b41e](https://github.com/morluto/rea/commit/d28b41ea4e93bb4c4d360a3bf2291ca83bb2b378))
|
||||
* **managed:** add retained metadata overview and pages ([#2413](https://github.com/morluto/rea/issues/2413)) ([82cfcee](https://github.com/morluto/rea/commit/82cfcee19f18b0eeae9a330ba04e0411cdf85225))
|
||||
* **managed:** select declaring types in inspect_managed_members ([a149d18](https://github.com/morluto/rea/commit/a149d183efd08b0ad7c2a35c99f2394d905621d5)), closes [#1878](https://github.com/morluto/rea/issues/1878)
|
||||
* **mcp:** add opt-in paging for retained unknown heads ([9a18251](https://github.com/morluto/rea/commit/9a18251426f891e6a24856b715ef8002ab8b9610))
|
||||
* **mcp:** derive an API surface view from retained web network captures ([#1891](https://github.com/morluto/rea/issues/1891)) ([880a603](https://github.com/morluto/rea/commit/880a603fe89db2132fd05a35b33a8c459673beda))
|
||||
* **native:** admit raw binaries with caller-supplied architecture and entry ([#1868](https://github.com/morluto/rea/issues/1868)) ([7c207c7](https://github.com/morluto/rea/commit/7c207c79512f6baa70f4ed9d7d5334ab2d62f3ac))
|
||||
* **native:** expose typed incoming references at exact addresses ([#1715](https://github.com/morluto/rea/issues/1715)) ([cdd9fe7](https://github.com/morluto/rea/commit/cdd9fe716774c9e5abd121b70e9fd5e1eb91d2b8))
|
||||
* **pcap:** add bounded capture summaries through CLI and MCP ([9581e06](https://github.com/morluto/rea/commit/9581e067ad306aab842024e47f2dd18ac8056859))
|
||||
* **server:** frame analysis-target content as untrusted data ([#1908](https://github.com/morluto/rea/issues/1908)) ([85e1242](https://github.com/morluto/rea/commit/85e124253ac93dd443be6bbcee0939d525b90d4e))
|
||||
* **setup:** register DeepSeek Harness through setup, doctor and uninstall ([#1929](https://github.com/morluto/rea/issues/1929)) ([d9d2a17](https://github.com/morluto/rea/commit/d9d2a173819ce8e2e7f09eb584ec6ff7b4ee3480))
|
||||
* **signals:** decode recordings with bounded sigrok output ([#2339](https://github.com/morluto/rea/issues/2339)) ([91b8f58](https://github.com/morluto/rea/commit/91b8f587cd495c509946e69076525eb19dc1ca65))
|
||||
* **signals:** discover installed decoders and option help ([#2487](https://github.com/morluto/rea/issues/2487)) ([4276aaf](https://github.com/morluto/rea/commit/4276aaf18bb607cdd0182280f76cf50fcd110c3c))
|
||||
* **sqlite:** inspect offline snapshots with selective evidence ([#2271](https://github.com/morluto/rea/issues/2271)) ([aeee507](https://github.com/morluto/rea/commit/aeee5074d124414239b6dd440dc79c26bcbd58a4))
|
||||
* **target:** explain static WASM inspection after native rejection ([1f17ac8](https://github.com/morluto/rea/commit/1f17ac82282dfda1ae2dc87d99e66a8a039c0259)), closes [#1852](https://github.com/morluto/rea/issues/1852)
|
||||
* **update:** update Bun global installations ([#1735](https://github.com/morluto/rea/issues/1735)) ([e4027e4](https://github.com/morluto/rea/commit/e4027e4afccc6316cd9cfc27c7b0427e66ceb91f))
|
||||
* **wasm:** add opt-in first-call summary delivery ([#2193](https://github.com/morluto/rea/issues/2193)) ([0b71453](https://github.com/morluto/rea/commit/0b7145361ec02d27260eb1f5bc0be955c9374a96))
|
||||
* **wasm:** inspect local artifacts with WABT ([#1533](https://github.com/morluto/rea/issues/1533)) ([ffaf2c1](https://github.com/morluto/rea/commit/ffaf2c12f1793c7c451e66dc09c3074544a4c086))
|
||||
* **website:** add a persistent light and dark theme switch ([4773c15](https://github.com/morluto/rea/commit/4773c152bc5f299fa682575bb8d1efea7587c7bc))
|
||||
* **website:** add multilingual editions and translation workflow ([a2d3338](https://github.com/morluto/rea/commit/a2d3338e90a3a11385e2f63bedaa9750612f8719))
|
||||
* **website:** follow the system dark mode and fix small-screen text ([#1894](https://github.com/morluto/rea/issues/1894)) ([12ee785](https://github.com/morluto/rea/commit/12ee785e5e61132898adecf9424ce6706a2e754a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **adb:** admit canonical device states and preserve malformed output ([e1cd292](https://github.com/morluto/rea/commit/e1cd292aa6e8f5f13441d408c66126782c8dbb0d))
|
||||
* **adb:** count requested permissions that the device marks restricted ([#2359](https://github.com/morluto/rea/issues/2359)) ([15463bd](https://github.com/morluto/rea/commit/15463bdbe605b9dd11da5dbba031f99cd1ec9190))
|
||||
* **adb:** count UTF-8 bytes in logcat total_bytes ([#2212](https://github.com/morluto/rea/issues/2212)) ([ec2d251](https://github.com/morluto/rea/commit/ec2d251c0ad8382e0665546be5ec8f5f1c175962))
|
||||
* **adb:** evaluate the full activity start report ([c89972c](https://github.com/morluto/rea/commit/c89972c8777d0114566fe76a060849a18bdd2659)), closes [#2225](https://github.com/morluto/rea/issues/2225)
|
||||
* **adb:** preserve device paths and prevent destination collisions ([55b6148](https://github.com/morluto/rea/commit/55b61485e9f7568b69597eef8696ff6abfe53cfd))
|
||||
* **adb:** preserve directory identities and share byte capture ([3bdc873](https://github.com/morluto/rea/commit/3bdc8730bd98e073522bcb8ba86527be402c0cbe))
|
||||
* **adb:** preserve existing bugreport archives ([#2221](https://github.com/morluto/rea/issues/2221)) ([d80b55e](https://github.com/morluto/rea/commit/d80b55e11bc47c6f999dac7dcb8382219d65739a))
|
||||
* **adb:** reject unsafe host file names when pulling one device file ([#1962](https://github.com/morluto/rea/issues/1962)) ([1d4c4eb](https://github.com/morluto/rea/commit/1d4c4eb0308b4cb5761a91533edeb33fcfb442f2))
|
||||
* **adb:** share actionable provider configuration refusals ([c42232f](https://github.com/morluto/rea/commit/c42232f566d55fe7191763649755148c768eef9f))
|
||||
* **adb:** share host configuration refusals with apktool ([#1954](https://github.com/morluto/rea/issues/1954)) ([39418ea](https://github.com/morluto/rea/commit/39418ea173763ca55d4f116992ee903bcaeb1898))
|
||||
* align MCP archive contracts and documentation ([#2478](https://github.com/morluto/rea/issues/2478)) ([a86deec](https://github.com/morluto/rea/commit/a86deecac5fa8434a4f50482f86b0c6ab486a936)), closes [#2454](https://github.com/morluto/rea/issues/2454)
|
||||
* **analysis:** expose provider image base ([#2083](https://github.com/morluto/rea/issues/2083)) ([81cca2e](https://github.com/morluto/rea/commit/81cca2e0e3ebd98c21f41d7ad63d6a00e4cbc3f9))
|
||||
* **analysis:** preserve producer identity and partial evidence ([6fb3a34](https://github.com/morluto/rea/commit/6fb3a345fe952b8e8f59221aec993acc37ccbf31))
|
||||
* **analysis:** preserve relationship coverage across evidence and replay ([9c85114](https://github.com/morluto/rea/commit/9c8511409c0706f9979c2b68dd56aaaf9f21e191))
|
||||
* **analysis:** scope call-graph caveats to their observed relationships ([5dfb9d0](https://github.com/morluto/rea/commit/5dfb9d050c2cc44a61b183dae765bef4d22a53b7))
|
||||
* **android:** bound inline search Evidence with retained inventory provenance ([#1890](https://github.com/morluto/rea/issues/1890)) ([7a0140b](https://github.com/morluto/rea/commit/7a0140b4aa43276368058ab98826b77e040ea14f))
|
||||
* **android:** preserve decoded values and duplicate class inventories ([0677468](https://github.com/morluto/rea/commit/0677468877465c8dfbe25c72f142a7b5bc8a3212)), closes [#1978](https://github.com/morluto/rea/issues/1978)
|
||||
* **android:** preserve executable static initializer evidence ([#1876](https://github.com/morluto/rea/issues/1876)) ([efbc25f](https://github.com/morluto/rea/commit/efbc25fbea1b6ade2a284a4916644c791f83bf49)), closes [#1846](https://github.com/morluto/rea/issues/1846)
|
||||
* **android:** reject malformed UTF-8 at the JADX protocol boundary ([fe8d0bd](https://github.com/morluto/rea/commit/fe8d0bdd36fe12bc0836468d1a702a34fbdc219b))
|
||||
* **apktool:** accept --no-strings CLI option ([#2707](https://github.com/morluto/rea/issues/2707)) ([11f8b76](https://github.com/morluto/rea/commit/11f8b7625788d747eaadd59af6efdf88587ed058))
|
||||
* **apktool:** name host requirements in configuration refusals ([#1946](https://github.com/morluto/rea/issues/1946)) ([7cdb798](https://github.com/morluto/rea/commit/7cdb7981c3612bc42bd0153fddd9c33b639918ed))
|
||||
* **apktool:** run Windows batch launchers through cmd.exe ([#1777](https://github.com/morluto/rea/issues/1777)) ([ae95e68](https://github.com/morluto/rea/commit/ae95e68ba5fa95cf2ec0453b50ffd217cabf5359)), closes [#1774](https://github.com/morluto/rea/issues/1774)
|
||||
* **apple:** decode UIKit keyed-archive outlet connections ([8d4d631](https://github.com/morluto/rea/commit/8d4d631606c701aac150cf009d3b80ae4a7ae7d5))
|
||||
* **apple:** derive dyld environment findings from each traced slice ([#2733](https://github.com/morluto/rea/issues/2733)) ([52d4ce6](https://github.com/morluto/rea/commit/52d4ce6ced1c3b6ae7570a5df7f4499cd65a1182)), closes [#2661](https://github.com/morluto/rea/issues/2661)
|
||||
* **apple:** join recognized keys from NIBArchive field maps ([#2248](https://github.com/morluto/rea/issues/2248)) ([fd985db](https://github.com/morluto/rea/commit/fd985dbc5b9435918732c0cc1825060af2a3a47e))
|
||||
* **apple:** limit already-loaded reuse to rpath dependencies ([#2292](https://github.com/morluto/rea/issues/2292)) ([60f771a](https://github.com/morluto/rea/commit/60f771a11e9889ce69cce6ad631b6b186ac742dd))
|
||||
* **apple:** preflight XML plist nesting before keyed archive decoding ([#2161](https://github.com/morluto/rea/issues/2161)) ([7711b00](https://github.com/morluto/rea/commit/7711b0000592aa80c7b0412004150313f78feabc))
|
||||
* **apple:** preserve archive signatures and metadata byte identity ([dc038d1](https://github.com/morluto/rea/commit/dc038d16bba9046afd28970de9c47f6259a43524))
|
||||
* **apple:** preserve ARM64E X1 architecture identity ([f598bab](https://github.com/morluto/rea/commit/f598bab488e38c378d69346fa3c106f9e18529ed))
|
||||
* **apple:** preserve slice identity and macOS boundary diagnostics ([#1772](https://github.com/morluto/rea/issues/1772)) ([5c29565](https://github.com/morluto/rea/commit/5c2956587a1b0d431f72e10df07a1a6363fa066c))
|
||||
* **apple:** validate build-version tool records within cmdsize ([#1784](https://github.com/morluto/rea/issues/1784)) ([817aef4](https://github.com/morluto/rea/commit/817aef406861c2ce3f87ad3c912ea6c235bcdf12))
|
||||
* **artifacts:** authenticate file subjects across provider and view boundaries ([3c57e1a](https://github.com/morluto/rea/commit/3c57e1a8b21b379fca55032d177078e1d8426e64))
|
||||
* **artifacts:** bind directory reads to captured file state ([918713a](https://github.com/morluto/rea/commit/918713af376e307ecf24d75e6a0dd9d1b544545b))
|
||||
* **artifacts:** bound archive decompression by source size ([#2144](https://github.com/morluto/rea/issues/2144)) ([c59fbf5](https://github.com/morluto/rea/commit/c59fbf5e9c3b856d042f02cb18ad4de077eaeab5))
|
||||
* **artifacts:** close mounted directory resources before DMG detach ([2260887](https://github.com/morluto/rea/commit/2260887f2092be6a3d9c79509e4ae4a3e52fce7f))
|
||||
* **artifacts:** compare leaf inventories using explicit coverage ([ed485a3](https://github.com/morluto/rea/commit/ed485a3dc8cc3a4baf8cabe01acb7aa0490b73b3))
|
||||
* **artifacts:** compare package signatures without ASCII folding ([#2069](https://github.com/morluto/rea/issues/2069)) ([75a4a14](https://github.com/morluto/rea/commit/75a4a14fa05d352762ffaefd3cd2e16515842b03))
|
||||
* **artifacts:** count a changed partial-inventory root once ([#1854](https://github.com/morluto/rea/issues/1854)) ([ee5275c](https://github.com/morluto/rea/commit/ee5275cd9fae8db64f945a0c8b59e1c19ac402c3)), closes [#1844](https://github.com/morluto/rea/issues/1844)
|
||||
* **artifacts:** distinguish Java classes from universal Mach-O ([921bb95](https://github.com/morluto/rea/commit/921bb9575505065f6cfd57b14d97d80d0f238546)), closes [#2317](https://github.com/morluto/rea/issues/2317)
|
||||
* **artifacts:** honor cancellation at the output publication boundary ([e7882b3](https://github.com/morluto/rea/commit/e7882b34f4ac4a26f903c5f49264acc2c19bb562))
|
||||
* **artifacts:** mark Windows host executables by extension ([#1823](https://github.com/morluto/rea/issues/1823)) ([91f843b](https://github.com/morluto/rea/commit/91f843bf38b0e4ec8576f41f3140488b4ee8ff32)), closes [#1819](https://github.com/morluto/rea/issues/1819)
|
||||
* **artifacts:** mark Windows launchable files as host executables ([#1824](https://github.com/morluto/rea/issues/1824)) ([e8491e0](https://github.com/morluto/rea/commit/e8491e023a255f790ae9850225084d7495e9ade0)), closes [#1819](https://github.com/morluto/rea/issues/1819)
|
||||
* **artifacts:** preserve decode coverage and cleanup ownership ([a73a55f](https://github.com/morluto/rea/commit/a73a55f53cc2b2e5b7618effe6ecb5cee6e0cee2))
|
||||
* **artifacts:** preserve emitted ZIP entry identity ([e36c82b](https://github.com/morluto/rea/commit/e36c82becdd613787bcc59903f8b5fe14eb2f105))
|
||||
* **artifacts:** preserve inventory uncertainty in application projections ([d8ceb3f](https://github.com/morluto/rea/commit/d8ceb3f276a65c181b146f68c0729ff4b6f9fa61))
|
||||
* **artifacts:** preserve NIB field and class identities ([#2106](https://github.com/morluto/rea/issues/2106)) ([f8e9579](https://github.com/morluto/rea/commit/f8e95799f941e9e55e0d47841896b401bab67d29))
|
||||
* **artifacts:** preserve physical ZIP member identities ([#1865](https://github.com/morluto/rea/issues/1865)) ([bcd350c](https://github.com/morluto/rea/commit/bcd350c365030a5d210208c0a8dc238302c43303)), closes [#1860](https://github.com/morluto/rea/issues/1860)
|
||||
* **artifacts:** preserve ZIP identities and partial managed metadata ([74d7da6](https://github.com/morluto/rea/commit/74d7da6ced0f869936ee0316b00616931b088066))
|
||||
* **artifacts:** read ASCII control bytes in legacy ZIP names as ASCII ([#2365](https://github.com/morluto/rea/issues/2365)) ([9928f35](https://github.com/morluto/rea/commit/9928f357fb9c420e6384e155d74586dfe37d4533))
|
||||
* **artifacts:** read every ZIP package family extraction is asked to open ([#2493](https://github.com/morluto/rea/issues/2493)) ([7017c4d](https://github.com/morluto/rea/commit/7017c4d68cab49aed998283e09069d9d791e1b0b))
|
||||
* **artifacts:** reject malformed serialized keys and integer values ([9435c7a](https://github.com/morluto/rea/commit/9435c7aae279ba184937c0dfeb713fd574ba3f29))
|
||||
* **artifacts:** retain ASAR resources through cleanup failures ([eb97da3](https://github.com/morluto/rea/commit/eb97da33881e3d1472d97a7d788ee35944fb6b07))
|
||||
* **artifacts:** retain read cleanup ownership through CLI and MCP shutdown ([b955b82](https://github.com/morluto/rea/commit/b955b825fc002a4bb408f2910b78768c566c2cd1))
|
||||
* **artifacts:** retry transient DMG verification refusals ([bb54082](https://github.com/morluto/rea/commit/bb540826d65316c164d2de8ba4ef10f81a8ad073))
|
||||
* **browser:** bound CDP discovery response size and lifetime ([951b095](https://github.com/morluto/rea/commit/951b095e11adf9faa9c4a27e212260060aa6b095))
|
||||
* **browser:** compare only observed HTTP response facts ([486b567](https://github.com/morluto/rea/commit/486b56724c4eaba50719177bcb9eae84d6907297))
|
||||
* **browser:** compare PNG IHDR bytes without ASCII folding ([#2099](https://github.com/morluto/rea/issues/2099)) ([fdd8f82](https://github.com/morluto/rea/commit/fdd8f826023c568937d237c2f97d855de283a9b8))
|
||||
* **browser:** exclude sensitive binary lengths from historical captures ([#2477](https://github.com/morluto/rea/issues/2477)) ([68ecf3d](https://github.com/morluto/rea/commit/68ecf3df8c666fdbe1ced8089c64340af614d1c3))
|
||||
* **browser:** isolate script URLs with empty query or fragment ([#2074](https://github.com/morluto/rea/issues/2074)) ([9b780c4](https://github.com/morluto/rea/commit/9b780c47bca039939dd5f73b78541f3ea566a7dd))
|
||||
* **browser:** keep elapsed capture timing independent of wall-clock changes ([6bd2b28](https://github.com/morluto/rea/commit/6bd2b28161411f70ed5dc72e42c2736a1aab758b))
|
||||
* **browser:** preserve attached service-worker policies and ownership ([221c856](https://github.com/morluto/rea/commit/221c85648108e9846d9920b0964dafa7f108d581))
|
||||
* **browser:** preserve body API provenance through script export ([966f7cc](https://github.com/morluto/rea/commit/966f7cc84b4a8bb0043641cb64d7a7aaf8715e05))
|
||||
* **browser:** preserve uncertainty in malformed network observations ([a8d4984](https://github.com/morluto/rea/commit/a8d4984404b611e60b88bc42289c0ba07eee4578))
|
||||
* **browser:** redact encoded declared secrets consistently ([f09bb8a](https://github.com/morluto/rea/commit/f09bb8a99773f79c7306b8799c86f856fe2c0d4b))
|
||||
* **browser:** report mismatched event family selection as unknown in capture comparison ([#2239](https://github.com/morluto/rea/issues/2239)) ([0043d9b](https://github.com/morluto/rea/commit/0043d9b134c0a353e8bf85c0bde4492275f1f720))
|
||||
* **browser:** retain effective source-map response urls ([062723e](https://github.com/morluto/rea/commit/062723e44e937c1e1df865e9ae16184b41e70ae8))
|
||||
* **browser:** retain module resolver cleanup ownership ([a82e37c](https://github.com/morluto/rea/commit/a82e37c8065cc200a0ca3e14e7c4f85e4e468580))
|
||||
* **build:** preserve LF text checkouts on Windows ([#1807](https://github.com/morluto/rea/issues/1807)) ([031d778](https://github.com/morluto/rea/commit/031d7781f0f6c99e5f14e67f56cb89a88d6af3eb))
|
||||
* **build:** preserve resource controls in strict Turbo tasks ([#1809](https://github.com/morluto/rea/issues/1809)) ([2e22330](https://github.com/morluto/rea/commit/2e223300375101558f90b6e78ef9612c55626673))
|
||||
* **build:** resolve rolldown from the npm registry in package-lock.json ([#1986](https://github.com/morluto/rea/issues/1986)) ([f889a69](https://github.com/morluto/rea/commit/f889a69fb9efe42dd0120c96ff44b4298c1c7c6c))
|
||||
* **bun:** report the real runtime and enforce worker memory budgets on Windows ([#1730](https://github.com/morluto/rea/issues/1730)) ([66c2157](https://github.com/morluto/rea/commit/66c21572defc97fe277cad49d3173bd4073d1f58))
|
||||
* **call-path:** preserve relationship coverage from analysis ([#2001](https://github.com/morluto/rea/issues/2001)) ([ed6af7e](https://github.com/morluto/rea/commit/ed6af7eb8cf052c52fec8bfdc5d509377cb30a1a))
|
||||
* **captures:** name the violated HAR schema constraint ([#2708](https://github.com/morluto/rea/issues/2708)) ([b69d20a](https://github.com/morluto/rea/commit/b69d20a300dba57783a810146f25dacb69f9b8a4))
|
||||
* **checks:** order artifact-backed lint after compilation ([0d5033a](https://github.com/morluto/rea/commit/0d5033ad12e12abe78fc3c0ba81622b410d22aa1))
|
||||
* **ci:** align FAT fixtures and consolidate duplicated workflow helpers ([a0317cf](https://github.com/morluto/rea/commit/a0317cf732b3addefad5ac3b234db1e044e3351d))
|
||||
* **ci:** allow cold Chrome startup in runtime fixtures ([f941968](https://github.com/morluto/rea/commit/f941968eb1860dd41efc2a5201b35cecca42301d))
|
||||
* **ci:** ignore generated website translation staging ([#2511](https://github.com/morluto/rea/issues/2511)) ([1883149](https://github.com/morluto/rea/commit/18831491cfb5cb98abbffce0cb04dae12a90fe28)), closes [#2508](https://github.com/morluto/rea/issues/2508)
|
||||
* **ci:** keep cancellation ownership in the calling workflow ([db3c9f0](https://github.com/morluto/rea/commit/db3c9f084bacad34f620ba678924564e20f441f9))
|
||||
* **ci:** reject incomplete verification plans ([bd2c202](https://github.com/morluto/rea/commit/bd2c202dd930548194cdbc55c27a797f22279db9))
|
||||
* **ci:** stop aggregate jobs when verification is cancelled ([2e45b4d](https://github.com/morluto/rea/commit/2e45b4d7a5b3228fe8b283def6f047797561bee1))
|
||||
* **cli:** await browser scenario cleanup on interrupts ([aaeadf2](https://github.com/morluto/rea/commit/aaeadf2d4c4e3f0d0581af2dca33c5bfdc2bcb0e))
|
||||
* **cli:** normalize empty Windows profile before imports ([#2482](https://github.com/morluto/rea/issues/2482)) ([fa4b828](https://github.com/morluto/rea/commit/fa4b8288655188426222773ead2ccbdaf76334fd))
|
||||
* **cli:** preserve failure semantics across full-output formats ([570c40c](https://github.com/morluto/rea/commit/570c40cde55d31e455ef9bc484b7b908658ded58))
|
||||
* **cli:** report an invalid REA_LOG_LEVEL as configuration ([#2140](https://github.com/morluto/rea/issues/2140)) ([3159732](https://github.com/morluto/rea/commit/315973259732544a597b8b17d25c69735910e72e)), closes [#2135](https://github.com/morluto/rea/issues/2135)
|
||||
* **cli:** retain browser and Inspector input correction details ([#2230](https://github.com/morluto/rea/issues/2230)) ([916ac97](https://github.com/morluto/rea/commit/916ac97bf9f7b976982e438ceb856a9692576cb1))
|
||||
* **cli:** retain correction details across adjacent browser commands ([#2294](https://github.com/morluto/rea/issues/2294)) ([bb26622](https://github.com/morluto/rea/commit/bb266226707135ad9f181e6c3afd6af0ef62fb22))
|
||||
* **cli:** retry retained browser scenario cleanup ([4daea94](https://github.com/morluto/rea/commit/4daea941bfeeb426886f2d048aed7f3cb2dbf97b))
|
||||
* **cli:** reuse firmware and web module input constraints ([48c352b](https://github.com/morluto/rea/commit/48c352b6bfa34c820a530272cd36600158c37b1e))
|
||||
* **config:** reject NUL bytes in Hopper loader arguments ([#2474](https://github.com/morluto/rea/issues/2474)) ([a07ceae](https://github.com/morluto/rea/commit/a07ceaec36346a7c73e66efe35c8af4654029c84))
|
||||
* **contracts:** mark foreign execution and device mutation destructive ([#1904](https://github.com/morluto/rea/issues/1904)) ([0b7fc40](https://github.com/morluto/rea/commit/0b7fc40eeef03b06de08d886572c2862331d8947))
|
||||
* **contracts:** reject OS-invalid local paths in binary and artifact tools ([#1925](https://github.com/morluto/rea/issues/1925)) ([976c2c0](https://github.com/morluto/rea/commit/976c2c083eee1fdd9e89a1a720f768a6ee6df277))
|
||||
* **dev:** preserve command arguments in exclusive runners ([fd95880](https://github.com/morluto/rea/commit/fd9588089d089e7cb02d12178e34407d6f889f22)), closes [#2319](https://github.com/morluto/rea/issues/2319)
|
||||
* **docs:** synchronize disclaimer links across translations ([d864ad5](https://github.com/morluto/rea/commit/d864ad54717d92fe4b512ce1170717d9904c1f00))
|
||||
* **docs:** validate translated README website destinations ([c9136ee](https://github.com/morluto/rea/commit/c9136eef716aa8d32e0301189fbf5a511ff62d5d))
|
||||
* **domain:** preserve derived byte counts and uncertain identity ([7a2cd1e](https://github.com/morluto/rea/commit/7a2cd1ea0a63e8c44166860b7ee9c7a1aa43492b))
|
||||
* **domain:** preserve retryable capability failures through redaction ([#2494](https://github.com/morluto/rea/issues/2494)) ([d73ed6f](https://github.com/morluto/rea/commit/d73ed6fce24f6d1372c80319cd41d29d66666159))
|
||||
* **electron:** attribute unresolved paths to selected inputs ([b7169c8](https://github.com/morluto/rea/commit/b7169c8b747cbc032913b31f008a3b81c77b6219)), closes [#2272](https://github.com/morluto/rea/issues/2272)
|
||||
* **elf:** centralize fixed header validation across consumers ([34a4bb6](https://github.com/morluto/rea/commit/34a4bb6508006fa113463ef20ed799a2994e9ee8))
|
||||
* **errors:** stop recommending retries for non-retryable failures ([#1869](https://github.com/morluto/rea/issues/1869)) ([fb94d9d](https://github.com/morluto/rea/commit/fb94d9dde0ccda83ae520ac6fb22b1d283562cf3))
|
||||
* **evaluation:** authenticate delivered Evidence citations ([844fa52](https://github.com/morluto/rea/commit/844fa52772c1533f7358a71a764da780503775a3))
|
||||
* **evaluation:** share completed transcript facts across graders ([1447fd2](https://github.com/morluto/rea/commit/1447fd2f990c5d9820337dfd4b8a170f8cad17cd))
|
||||
* **evidence:** admit bundle imports across session transitions ([#2303](https://github.com/morluto/rea/issues/2303)) ([1230381](https://github.com/morluto/rea/commit/12303812a8d70f198c99c7aeb0df3aaa8166543c))
|
||||
* **evidence:** authenticate identities and retain extraction provenance ([6bae774](https://github.com/morluto/rea/commit/6bae774328724403255590aa2ddad4d7a2aa727f))
|
||||
* **evidence:** enforce managed identity and cancel bundle reads ([3bcc68c](https://github.com/morluto/rea/commit/3bcc68c037e9415c78093cb286069c1fcca6a2f8))
|
||||
* **evidence:** normalize unknown revisions and preserve mutation intent ([#1733](https://github.com/morluto/rea/issues/1733)) ([2165045](https://github.com/morluto/rea/commit/21650455a9c90a26e725c3505a3c17054cc4516c)), closes [#1723](https://github.com/morluto/rea/issues/1723) [#1724](https://github.com/morluto/rea/issues/1724)
|
||||
* **evidence:** preserve call coverage in function comparisons ([049132a](https://github.com/morluto/rea/commit/049132ab43fa962d31774f2b258c15a71217148b)), closes [#2222](https://github.com/morluto/rea/issues/2222)
|
||||
* **evidence:** preserve literal keys and response body ownership ([d1dcebc](https://github.com/morluto/rea/commit/d1dcebce2c0820469ed63e33497a14258971b710))
|
||||
* **evidence:** preserve trace uncertainty and partial provider observations ([e997085](https://github.com/morluto/rea/commit/e9970855ba16ba711816ac30212c5ca17ae085cd))
|
||||
* **evidence:** reject NUL bytes in subject and snapshot paths ([#1873](https://github.com/morluto/rea/issues/1873)) ([fa9a7e5](https://github.com/morluto/rea/commit/fa9a7e5ec026c5a4e8426f0f36c2c4555be9c9f0))
|
||||
* **evidence:** reject unsupported metadata before authentication ([acf75e6](https://github.com/morluto/rea/commit/acf75e6700ace28bb1db25e54e56673ac15e0ed3))
|
||||
* **evidence:** retain unreadable PE and Darwin process facts ([4a4d36f](https://github.com/morluto/rea/commit/4a4d36f881861bbab3c734b0dd459f805766caec))
|
||||
* **evidence:** validate closure projections and browser body coverage ([aada0ca](https://github.com/morluto/rea/commit/aada0ca8126bf14c60750a4ccd4a79667770a1e4))
|
||||
* **filesystem:** enforce symlink and decoded-text admission ([ec0b081](https://github.com/morluto/rea/commit/ec0b081371f118e6e5e042fbef3479359c672ddb))
|
||||
* **firmware:** name missing extractor dependencies in partial extraction ([#1879](https://github.com/morluto/rea/issues/1879)) ([1769417](https://github.com/morluto/rea/commit/176941700118cc33c86e07373cd6950f30548386))
|
||||
* **formats:** preserve high bits in Windows signature checks ([ac57c88](https://github.com/morluto/rea/commit/ac57c88efe64c3125850b54d3d833227e6b8ec51))
|
||||
* **frida:** name the argument each Frida input error rejects ([#2217](https://github.com/morluto/rea/issues/2217)) ([86de439](https://github.com/morluto/rea/commit/86de439ba4c9297f721abc60c715fe30fb6ff3df)), closes [#2213](https://github.com/morluto/rea/issues/2213)
|
||||
* **frida:** reject an unreadable script file before starting a session ([#2211](https://github.com/morluto/rea/issues/2211)) ([c9aecde](https://github.com/morluto/rea/commit/c9aecde580d26d4e7745c2623a4efa327a27367e))
|
||||
* **ghidra:** derive installation admission from Java property semantics ([2187e16](https://github.com/morluto/rea/commit/2187e16f149a7a7b512a4cb45d5f468cc04091fe))
|
||||
* **ghidra:** encode Java probe output as UTF-8 ([#2206](https://github.com/morluto/rea/issues/2206)) ([59be866](https://github.com/morluto/rea/commit/59be866719e8fe8ec060515044d427a3b0a57649))
|
||||
* **ghidra:** keep JVM warnings out of LaunchSupport output ([#1698](https://github.com/morluto/rea/issues/1698)) ([e20994a](https://github.com/morluto/rea/commit/e20994a8ea10609d420e958bd8a1268b0bcd8899))
|
||||
* **ghidra:** recover stale clients on same-target reopen ([#2014](https://github.com/morluto/rea/issues/2014)) ([4573e8c](https://github.com/morluto/rea/commit/4573e8cf35de822bfc5feabcc89ec3717c247c8c))
|
||||
* **ghidra:** report progress while startup is pending ([#1877](https://github.com/morluto/rea/issues/1877)) ([29d2624](https://github.com/morluto/rea/commit/29d2624b3475586356c27f518bf6b823030268fb))
|
||||
* **ghidra:** resolve file offsets in decompile ([#2101](https://github.com/morluto/rea/issues/2101)) ([2995ec6](https://github.com/morluto/rea/commit/2995ec6910ead327829378f5ab1b9172ecce33fc))
|
||||
* **ghidra:** retain launcher command outcome in launch failure diagnostics ([#2011](https://github.com/morluto/rea/issues/2011)) ([d4e8ef7](https://github.com/morluto/rea/commit/d4e8ef74999666cdb735b49eb8fea367f8f86489))
|
||||
* **ghidra:** share Java properties semantics with PSP extension admission ([b953f96](https://github.com/morluto/rea/commit/b953f96c0aae27e1876c317a8f7db1d6f292b8ff))
|
||||
* **go:** accept file-backed unmapped Mach-O debug segments ([7fb097d](https://github.com/morluto/rea/commit/7fb097d72c9931dd5dfae481ad2b9d3bb6029f15))
|
||||
* honor empty URL query and fragment markers ([#2091](https://github.com/morluto/rea/issues/2091)) ([af29247](https://github.com/morluto/rea/commit/af29247a7873ba8badec204a4a59f9a2b3a880b6))
|
||||
* **hopper:** let failed one-shot commands exit while retaining a lease ([#1763](https://github.com/morluto/rea/issues/1763)) ([e8528ce](https://github.com/morluto/rea/commit/e8528cedcdfb1ca1d769304afdc353936e8e0d70))
|
||||
* **hopper:** preserve observed process failures across requests ([#1927](https://github.com/morluto/rea/issues/1927)) ([601814e](https://github.com/morluto/rea/commit/601814ed0e4a0c57d6e045da0e6cf8ee2f29bb7f))
|
||||
* **hopper:** preserve startup failures and recover denied launches ([069308b](https://github.com/morluto/rea/commit/069308bbe6d73b1d87038b43c9121658325042a9)), closes [#1808](https://github.com/morluto/rea/issues/1808)
|
||||
* **hopper:** stop Linux demo children when the REA owner exits ([#2499](https://github.com/morluto/rea/issues/2499)) ([c105391](https://github.com/morluto/rea/commit/c105391db53e2e2ab8b8624d5f3870c14aae3637))
|
||||
* **hopper:** wait for late bridges during cleanup ([#1866](https://github.com/morluto/rea/issues/1866)) ([cf7477e](https://github.com/morluto/rea/commit/cf7477eb4642bc07228fa33ccc4c87656e6e3003))
|
||||
* **ida:** declare GUI navigation for attached legacy analysis ([#1764](https://github.com/morluto/rea/issues/1764)) ([cf69207](https://github.com/morluto/rea/commit/cf692072b56903b60426e4b301b3c48fd31ee0fd)), closes [#1712](https://github.com/morluto/rea/issues/1712)
|
||||
* **ida:** preserve native coverage and verify returned values ([#2324](https://github.com/morluto/rea/issues/2324)) ([37b7550](https://github.com/morluto/rea/commit/37b7550ecc916acc8619fa72e5c05af45702733f))
|
||||
* **ida:** preserve non-x86 call edges and workflow uncertainty ([#1906](https://github.com/morluto/rea/issues/1906)) ([0ae38e1](https://github.com/morluto/rea/commit/0ae38e1e31414c67a3be09231d208f0726c035f8))
|
||||
* **ida:** recover cleanup after worker sessions are reaped ([12fa8fd](https://github.com/morluto/rea/commit/12fa8fd9f986dbd4ff58165c3ec71a46dc8a8a3b))
|
||||
* **ida:** report a procedure IDA cannot resolve as invalid input ([#1785](https://github.com/morluto/rea/issues/1785)) ([d3e9ee5](https://github.com/morluto/rea/commit/d3e9ee5505251f760666bb8e1e6c295b6d6aedb2))
|
||||
* **ida:** stop the private input of a universal Mach-O at the end of the selected slice ([#2291](https://github.com/morluto/rea/issues/2291)) ([c6acd19](https://github.com/morluto/rea/commit/c6acd19b6d842699ab807d9526dc762c743686f4))
|
||||
* **ida:** thin the selected FAT slice into the private headless input ([#2201](https://github.com/morluto/rea/issues/2201)) ([10400fd](https://github.com/morluto/rea/commit/10400fdb57e4226c5ce15af17968a664f35498ce)), closes [#2139](https://github.com/morluto/rea/issues/2139)
|
||||
* **ida:** wait out a transient busy worker health in headless sessions ([#1778](https://github.com/morluto/rea/issues/1778)) ([0bdc7a4](https://github.com/morluto/rea/commit/0bdc7a40ac171a05971b287f2aab0376e2f14358))
|
||||
* **install:** describe automatic setup and guard truncated downloads ([#1902](https://github.com/morluto/rea/issues/1902)) ([d802304](https://github.com/morluto/rea/commit/d802304c542b20bc86e22cc492718e89a8a5579b))
|
||||
* **install:** honor redirected setup diagnostics ([8b18171](https://github.com/morluto/rea/commit/8b181716aca2cb467be499cba226abfb5ae19141))
|
||||
* **io:** consolidate managed snapshots and retain cleanup ownership ([98d8470](https://github.com/morluto/rea/commit/98d8470d36cfbf7cd25820a4ed430aebae20c0c5))
|
||||
* **io:** retain cleanup ownership and honor cancellation ([d8c76b6](https://github.com/morluto/rea/commit/d8c76b629639bda4405afa9ae2f7676b694f2f10))
|
||||
* **javascript:** apply loader-owned module lookup rules ([c0904b4](https://github.com/morluto/rea/commit/c0904b4c1aabfccf2f0ab12f44960a0a1fbbbfae))
|
||||
* **javascript:** bound self-alias mutation paths that never prune ([e365f37](https://github.com/morluto/rea/commit/e365f3715f0e9856218f0b2878bfe2144c821d9a))
|
||||
* **javascript:** bound value evaluation through nested closure aliases ([#1941](https://github.com/morluto/rea/issues/1941)) ([d59ab6d](https://github.com/morluto/rea/commit/d59ab6d977d5b9376af2fdd141b4f65460bb9a9f))
|
||||
* **javascript:** centralize module syntax and separate erased type dependencies ([1b95302](https://github.com/morluto/rea/commit/1b953021eadf4cac09e20be388d79536903633b3))
|
||||
* **javascript:** distinguish file URL paths from query identity ([f43fc02](https://github.com/morluto/rea/commit/f43fc0247527aebb8efc7d9db04276fe2d489323))
|
||||
* **javascript:** follow actual receivers in nested intrinsic calls ([#1912](https://github.com/morluto/rea/issues/1912)) ([9d8e42b](https://github.com/morluto/rea/commit/9d8e42bdd6f1bf8a984da1e06e1d76a1dbb9a7ed))
|
||||
* **javascript:** follow bundler namespace interop for Electron APIs ([#1827](https://github.com/morluto/rea/issues/1827)) ([6ac5c36](https://github.com/morluto/rea/commit/6ac5c36503492d3b6da4fcf2a5dee87aff60fc8c))
|
||||
* **javascript:** honor package exports before sibling files ([3ad5782](https://github.com/morluto/rea/commit/3ad5782dfdc8864879a887dc63fef682ffeb5839))
|
||||
* **javascript:** index repeated graph observations before final sorting ([6d5d71f](https://github.com/morluto/rea/commit/6d5d71fab5e135c687f7aa2f797da19b4c2a1c1e)), closes [#1748](https://github.com/morluto/rea/issues/1748)
|
||||
* **javascript:** keep TypeScript substitution out of runtime paths ([f8513a4](https://github.com/morluto/rea/commit/f8513a42dc44451a621127f06edde5d38bc93f69))
|
||||
* **javascript:** normalize package URLs before array selection ([#1916](https://github.com/morluto/rea/issues/1916)) ([369cd3d](https://github.com/morluto/rea/commit/369cd3dfdc47709cc34d01c50f645a885c70ded7))
|
||||
* **javascript:** parse explicit CommonJS wrapper syntax ([#2475](https://github.com/morluto/rea/issues/2475)) ([6a04f92](https://github.com/morluto/rea/commit/6a04f922e16ad4d171ba0c07e32238efdaf43e2d))
|
||||
* **javascript:** preserve and resolve complete source-map URLs ([#1788](https://github.com/morluto/rea/issues/1788)) ([ce845ba](https://github.com/morluto/rea/commit/ce845ba428e9b23d6b74eae1e25e17c37532adac))
|
||||
* **javascript:** preserve cancellation through recovery publication cleanup ([eaa9616](https://github.com/morluto/rea/commit/eaa961658578522cecfbba7c7be1acf1c64da65a))
|
||||
* **javascript:** preserve execution and invocation provenance ([b8fab74](https://github.com/morluto/rea/commit/b8fab7429771bb17a7ca0297661de3c9935212af))
|
||||
* **javascript:** preserve graphs within parent capacity ([#1921](https://github.com/morluto/rea/issues/1921)) ([0485cd3](https://github.com/morluto/rea/commit/0485cd3bce29c977bae05a4fdce5436f3e3d7913)), closes [#1911](https://github.com/morluto/rea/issues/1911)
|
||||
* **javascript:** preserve import-equals declaration and export evidence ([#2062](https://github.com/morluto/rea/issues/2062)) ([86fab11](https://github.com/morluto/rea/commit/86fab11fbe000a04c0810699419e99e50476e424))
|
||||
* **javascript:** preserve loader-specific package ancestry ([9f67e44](https://github.com/morluto/rea/commit/9f67e44e5d451d705bae9a579ad9802d5c9f097c))
|
||||
* **javascript:** preserve namespace re-export relationships ([#1833](https://github.com/morluto/rea/issues/1833)) ([37a9eb9](https://github.com/morluto/rea/commit/37a9eb9d65a088209f962e5094f6bc67eacbbaf9))
|
||||
* **javascript:** preserve package and source-map boundary identity ([#1939](https://github.com/morluto/rea/issues/1939)) ([14935e4](https://github.com/morluto/rea/commit/14935e4d470be318fdee442d279d6acb7d441119))
|
||||
* **javascript:** preserve POSIX runtime path case on Windows ([#2422](https://github.com/morluto/rea/issues/2422)) ([1a1b67c](https://github.com/morluto/rea/commit/1a1b67c8885e9ff33f7a06ed2e027ebfdb83e084))
|
||||
* **javascript:** preserve TypeScript loader and dependency semantics ([e4532b5](https://github.com/morluto/rea/commit/e4532b537663026c3e5b784380eecd009c959a36))
|
||||
* **javascript:** preserve unknown module-alias provenance ([c7a890d](https://github.com/morluto/rea/commit/c7a890d12987d9b2d496119c1fbf1a88c0fc298e))
|
||||
* **javascript:** prove namespace interop before attributing Electron APIs ([#1857](https://github.com/morluto/rea/issues/1857)) ([9b3e598](https://github.com/morluto/rea/commit/9b3e59804db71072ed023f109342f96063c57b45))
|
||||
* **javascript:** report available parent result budget in coverage limits ([#1915](https://github.com/morluto/rea/issues/1915)) ([72489a9](https://github.com/morluto/rea/commit/72489a9445299f695d793f004fed88e1bac0a557))
|
||||
* **javascript:** report JSON status for imported package manifests ([914ba83](https://github.com/morluto/rea/commit/914ba833ded5f8604345de4e4f6d306de0a38c4e))
|
||||
* **javascript:** resolve bundled exports by binding identity ([5ab0cbb](https://github.com/morluto/rea/commit/5ab0cbb48ea5a4f11542eac44479b9af48f1dabd)), closes [#2171](https://github.com/morluto/rea/issues/2171)
|
||||
* **javascript:** resolve module-relative Worker URL references ([#2104](https://github.com/morluto/rea/issues/2104)) ([653067d](https://github.com/morluto/rea/commit/653067d799591ce2b8fdc84e99c160ac17e2860f))
|
||||
* **javascript:** resolve relative ESM URLs as exact files ([783fcb0](https://github.com/morluto/rea/commit/783fcb06a4bd0444be214822fed60a3786932782))
|
||||
* **javascript:** retain compiler-dependent module resolution facts ([c923fb0](https://github.com/morluto/rea/commit/c923fb0dc9462e302b84a15770131ff899f89455))
|
||||
* **javascript:** retain completed facts through cancellation ([#1934](https://github.com/morluto/rea/issues/1934)) ([b6651cb](https://github.com/morluto/rea/commit/b6651cb99fba8fec3fd3efcb6794ae32b8a1525d))
|
||||
* **javascript:** retain distinct chunk registration callback metadata ([#2737](https://github.com/morluto/rea/issues/2737)) ([0cf224e](https://github.com/morluto/rea/commit/0cf224e76214abbcf47274aa936d1b5cad28db2f))
|
||||
* **javascript:** retain guards without duplicating observed IPC calls ([42b0ed1](https://github.com/morluto/rea/commit/42b0ed126df7d5b908ac369715378d5e519051b7))
|
||||
* **javascript:** retain unbound CommonJS semantic imports ([3a17882](https://github.com/morluto/rea/commit/3a17882cc469515e562df9ecc7d154f30a5a9205))
|
||||
* **javascript:** retain wrapped isolated-world bridge exposures ([1c37914](https://github.com/morluto/rea/commit/1c37914306b3ff257d7a0360f11c9bf2a3f2cac7))
|
||||
* **javascript:** share source-map resolution and preserve path uncertainty ([3ae826c](https://github.com/morluto/rea/commit/3ae826ceba6f25b08bce50bcb51001209bc16b57))
|
||||
* **javascript:** share TypeScript emitted import resolution ([1dfb933](https://github.com/morluto/rea/commit/1dfb933e47db0156cf1cb61f28715b81e740c57b))
|
||||
* **javascript:** trace wrapped Electron IPC paths in large bundles ([#1874](https://github.com/morluto/rea/issues/1874)) ([87b9793](https://github.com/morluto/rea/commit/87b9793c53caf70a9f3a516214856899ca2d7cf8))
|
||||
* **jeb:** confine an invalid REA_JEB_MCP_URL to the JEB tools ([#2145](https://github.com/morluto/rea/issues/2145)) ([df2c08b](https://github.com/morluto/rea/commit/df2c08b546d4f40103e11b8b31dbfc3d41ee687b)), closes [#2143](https://github.com/morluto/rea/issues/2143)
|
||||
* **managed:** admit ReadyToRun images built for non-Windows hosts ([#1985](https://github.com/morluto/rea/issues/1985)) ([0d11f71](https://github.com/morluto/rea/commit/0d11f71e0dc5cf95d3bea093355f7edc09089a68))
|
||||
* **managed:** bind row budget charges to metadata identities ([#1897](https://github.com/morluto/rea/issues/1897)) ([23d50ea](https://github.com/morluto/rea/commit/23d50ea3bec186b4459d36c04854a92276accdf1)), closes [#1878](https://github.com/morluto/rea/issues/1878)
|
||||
* **managed:** bound transient CIL by decoded instruction count ([#1822](https://github.com/morluto/rea/issues/1822)) ([554cb45](https://github.com/morluto/rea/commit/554cb45f7ff967884c9d1fdf706c20803d76a6df))
|
||||
* **managed:** charge retained member facts instead of decoding bounds ([#1768](https://github.com/morluto/rea/issues/1768)) ([3078729](https://github.com/morluto/rea/commit/30787297d49bf93e67f62a9ec42f896188d57a20))
|
||||
* **managed:** charge transient CIL by decoded instructions ([554cb45](https://github.com/morluto/rea/commit/554cb45f7ff967884c9d1fdf706c20803d76a6df))
|
||||
* **managed:** decode generic, array, fnptr, and modifier signatures ([#1835](https://github.com/morluto/rea/issues/1835)) ([1f531fc](https://github.com/morluto/rea/commit/1f531fc257f71731624cbd6b595208adacf59108))
|
||||
* **managed:** distinguish .NET Standard targets from runtimes ([81dc64e](https://github.com/morluto/rea/commit/81dc64e3cd13dc8bfcac8898386717ece9721da1))
|
||||
* **managed:** honor metadata ExtraData before table decoding ([#1893](https://github.com/morluto/rea/issues/1893)) ([fb43dd9](https://github.com/morluto/rea/commit/fb43dd9716fe113effa14a03b603b72ca7d9097c))
|
||||
* **managed:** index attribute constructor owners once per layout ([#1943](https://github.com/morluto/rea/issues/1943)) ([c2cf7dc](https://github.com/morluto/rea/commit/c2cf7dcb86e11350214f82e5c51de7ca6ee3d27a))
|
||||
* **managed:** preserve negative zero in CIL operands ([#2437](https://github.com/morluto/rea/issues/2437)) ([6c816af](https://github.com/morluto/rea/commit/6c816af11975e8e801adb2924c4e3c116c72ebba))
|
||||
* **managed:** report ExportedType rows in managed member inspection ([#1828](https://github.com/morluto/rea/issues/1828)) ([ed1519d](https://github.com/morluto/rea/commit/ed1519ddee02739f57136e356786f64c7f74fed0))
|
||||
* **managed:** report malformed PE layouts as invalid input ([#2194](https://github.com/morluto/rea/issues/2194)) ([90e37f4](https://github.com/morluto/rea/commit/90e37f4152990be721784006a820a3794920213c))
|
||||
* **managed:** resolve member ownership through pointer tables ([#1886](https://github.com/morluto/rea/issues/1886)) ([a95795c](https://github.com/morluto/rea/commit/a95795c6ed3cb149e03460f4c818ad12e0c670d9))
|
||||
* **managed:** share first-match member list ownership ([#1955](https://github.com/morluto/rea/issues/1955)) ([7d92d93](https://github.com/morluto/rea/commit/7d92d93a550f3be221d7cf1da07c37ea1770e922))
|
||||
* **managed:** share first-match member ownership indexes ([52e76c5](https://github.com/morluto/rea/commit/52e76c5d39b58d1685bb5e22bd40d19524dfa2cb))
|
||||
* **managed:** validate CLI type selections with the member contract ([#2125](https://github.com/morluto/rea/issues/2125)) ([60259a9](https://github.com/morluto/rea/commit/60259a91d9a58cbd313ad7f3d321acc2deb65d2e)), closes [#2121](https://github.com/morluto/rea/issues/2121)
|
||||
* **mcp:** cancel evidence imports before session mutation ([e5699d2](https://github.com/morluto/rea/commit/e5699d23b173bbd0b68e3ab177d0b7583d6df345)), closes [#1746](https://github.com/morluto/rea/issues/1746)
|
||||
* **mcp:** express inspect_analysis_view selector constraints in the contract schema ([#1705](https://github.com/morluto/rea/issues/1705)) ([a8a9c4d](https://github.com/morluto/rea/commit/a8a9c4d8173b63fb42d343a8ca043927970ea4ce)), closes [#1695](https://github.com/morluto/rea/issues/1695)
|
||||
* **mcp:** join owned doctor shutdown without signaling saved PIDs ([7fc0b5e](https://github.com/morluto/rea/commit/7fc0b5ec9c2532bbf789d7783b7a0cab6fc054d8))
|
||||
* **mcp:** keep prompt completion within ready provider sessions ([07b36c1](https://github.com/morluto/rea/commit/07b36c148946f46b99a4ce04e55726e97d0ee03a))
|
||||
* **mcp:** keep shared optional providers open across discarded probes ([#2205](https://github.com/morluto/rea/issues/2205)) ([7c9c1eb](https://github.com/morluto/rea/commit/7c9c1ebdb77662f6276ef2f7250e7df414bd03ca)), closes [#2195](https://github.com/morluto/rea/issues/2195)
|
||||
* **mcp:** preserve target provenance on retained partial errors ([8fc2043](https://github.com/morluto/rea/commit/8fc2043eb8bcedbf34dce064f977e455438635d6))
|
||||
* **mcp:** provide native evidence paging continuation ([1449e8e](https://github.com/morluto/rea/commit/1449e8e05138bf0c86d5fe6f4c93122b4b93b2b1)), closes [#1849](https://github.com/morluto/rea/issues/1849)
|
||||
* **native:** accept universal binaries with LIB64 x86_64 slices ([#1982](https://github.com/morluto/rea/issues/1982)) ([4b653fe](https://github.com/morluto/rea/commit/4b653fe6ad1d25a2d9a69d5e93610380c0c09a34))
|
||||
* **native:** clarify Mach-O offset origins and reject partial ELF tables ([8bd5ba2](https://github.com/morluto/rea/commit/8bd5ba2d234cbd9a5bb9dd5daeaa191c3a1cad24)), closes [#1973](https://github.com/morluto/rea/issues/1973)
|
||||
* **native:** honor Mach-O chained pointer locations and library ordinals ([#1709](https://github.com/morluto/rea/issues/1709)) ([3b0ea59](https://github.com/morluto/rea/commit/3b0ea590fcc876fa017946f4cec009d653f02b38))
|
||||
* **native:** name the ownership helper prerequisite in command failures ([#2506](https://github.com/morluto/rea/issues/2506)) ([20f51d7](https://github.com/morluto/rea/commit/20f51d7286b1759e42f99c0512c73230052dda86))
|
||||
* **native:** name the retained pwntools process group in cleanup resources ([#2281](https://github.com/morluto/rea/issues/2281)) ([4303dcd](https://github.com/morluto/rea/commit/4303dcdcf68af4d71d777ffff75cbd4f17a8877d)), closes [#2274](https://github.com/morluto/rea/issues/2274)
|
||||
* **native:** preserve literal line breaks in signing identifiers ([#2107](https://github.com/morluto/rea/issues/2107)) ([ec3db37](https://github.com/morluto/rea/commit/ec3db374574060fcc4fa6b215fceaea1bd4ca3f3))
|
||||
* **native:** preserve universal Mach-O container identity ([b38429c](https://github.com/morluto/rea/commit/b38429cb6a0de8f4bb5ae01633e24abe2d7cde46))
|
||||
* **native:** raise default PE resource entry budget to 16384 ([#1703](https://github.com/morluto/rea/issues/1703)) ([951c2aa](https://github.com/morluto/rea/commit/951c2aae3be1c357df9f23558d8c09680e64c2cb))
|
||||
* **native:** rank Mach-O slices by parsed CPU subtype ([c02fb0e](https://github.com/morluto/rea/commit/c02fb0ea8055f98def2b12ad557aed0faefd3639))
|
||||
* **native:** reject ambiguous otool load-command framing ([108b5f9](https://github.com/morluto/rea/commit/108b5f94254281639badd25b1f448cca35dc89a2))
|
||||
* **native:** reject required otool fields truncated before their value ([#1995](https://github.com/morluto/rea/issues/1995)) ([adde8a8](https://github.com/morluto/rea/commit/adde8a80921b199ddd7105998f3760d345b468fc))
|
||||
* **native:** retain failed command cleanup owners ([d9b4ae1](https://github.com/morluto/rea/commit/d9b4ae182d60dfd7fd5ba5bb539716413cbfdca6))
|
||||
* **native:** retain failed pwntools process and runtime cleanup ([#1708](https://github.com/morluto/rea/issues/1708)) ([11fe5a1](https://github.com/morluto/rea/commit/11fe5a13f8aee87d0c0e036a9f497407e085fe48))
|
||||
* **native:** retain signature snapshot cleanup ownership ([ebc22d9](https://github.com/morluto/rea/commit/ebc22d99d62225505c0a84f6e39823ba4ef2f829))
|
||||
* **native:** select one slice when universal Mach-O slices share a CPU type ([#2040](https://github.com/morluto/rea/issues/2040)) ([96fc84f](https://github.com/morluto/rea/commit/96fc84f1fb7c8bc69f66cd899896f4de9061fead))
|
||||
* order remaining outputs independently of host locale ([#1867](https://github.com/morluto/rea/issues/1867)) ([e4ac7bb](https://github.com/morluto/rea/commit/e4ac7bbfc2f8c631ba1c60f507d85253e122d4c8))
|
||||
* **pcap:** name the host tool to configure in capability refusals ([#2507](https://github.com/morluto/rea/issues/2507)) ([bb96afd](https://github.com/morluto/rea/commit/bb96afdbe32f5318d57fcb00099191c5a7712f53))
|
||||
* **pcap:** order evidence ties by code point instead of host locale ([#2509](https://github.com/morluto/rea/issues/2509)) ([f6556ad](https://github.com/morluto/rea/commit/f6556ad2bc102d80223f5b434136f546dbd2bbab))
|
||||
* **plist:** decode formatted XML dictionary keys ([f1924cf](https://github.com/morluto/rea/commit/f1924cf9c1910d5d29d11e7bf634b51af8d738cf))
|
||||
* **process:** accept a trace identifier the comparison result can represent ([#2488](https://github.com/morluto/rea/issues/2488)) ([2c6a4fa](https://github.com/morluto/rea/commit/2c6a4fac4ff3477ac14f3d780894c5b390880607))
|
||||
* **process:** add an opt-in Swift module cache ([#2252](https://github.com/morluto/rea/issues/2252)) ([47a51b0](https://github.com/morluto/rea/commit/47a51b08f4d79f808c1b74de79c3852aa853df53))
|
||||
* **process:** aggregate incomplete trace-spec diagnostics ([857a1b7](https://github.com/morluto/rea/commit/857a1b7b04da4488db2b224c270ad78998c6b6c7))
|
||||
* **process:** align capture Evidence provider admission ([713db4d](https://github.com/morluto/rea/commit/713db4dc2665c964707cb0e937e8794cbe73534e))
|
||||
* **process:** bound retained trace ordering state ([63e796d](https://github.com/morluto/rea/commit/63e796d50ea323ae8dcfef80f86c8154bb106790))
|
||||
* **process:** cancel run-token reads during sampling ([09b6828](https://github.com/morluto/rea/commit/09b68285166bef2b7633402c8adf7eb25823b47e))
|
||||
* **process:** observe already-due trailing callbacks before settling the capture journal ([#1700](https://github.com/morluto/rea/issues/1700)) ([ed084b3](https://github.com/morluto/rea/commit/ed084b3dbb13ec9dee1adc8f5b76e784b52c00bb)), closes [#1697](https://github.com/morluto/rea/issues/1697)
|
||||
* **process:** preserve invalid runtime path selections as input errors ([cecc6ec](https://github.com/morluto/rea/commit/cecc6ecbffe84689a173d1634f342e046b0c8ceb))
|
||||
* **process:** preserve monotonic capture timing and observed wall dates ([9049dba](https://github.com/morluto/rea/commit/9049dbaf0c3df32357419f9899965df2b611f493))
|
||||
* **process:** preserve nested path identities during normalization ([#2468](https://github.com/morluto/rea/issues/2468)) ([b9ee921](https://github.com/morluto/rea/commit/b9ee921560dcf4590c2b13a1cc92c49c93e10675))
|
||||
* **process:** reject deep trace cycles without recursive expansion ([10935b7](https://github.com/morluto/rea/commit/10935b7b1136accab2a43b308ab380749da0f7ae))
|
||||
* **process:** report single empty settlement observation as unverifiable ([#2232](https://github.com/morluto/rea/issues/2232)) ([7724edb](https://github.com/morluto/rea/commit/7724edbdd3fa0acd94c8210a30dceba501fcdbe8))
|
||||
* **process:** report the Swift compiler outcome with bounded diagnostics ([#1999](https://github.com/morluto/rea/issues/1999)) ([091cb81](https://github.com/morluto/rea/commit/091cb81fc3ca2b8ec96df7a534934388a6b882cd))
|
||||
* **process:** retain owned command cleanup diagnostics ([#1907](https://github.com/morluto/rea/issues/1907)) ([38539a2](https://github.com/morluto/rea/commit/38539a2f4a522243d6fc369ae34ef30e2a2f80cc)), closes [#1856](https://github.com/morluto/rea/issues/1856)
|
||||
* **process:** return private runtime ownership at allocation ([c22111e](https://github.com/morluto/rea/commit/c22111eca2c4bc84a084f12711f405b3e7422a40))
|
||||
* **process:** use monotonic time for provider execution budgets ([d9e9fb2](https://github.com/morluto/rea/commit/d9e9fb2273543caab4eb21d896c33d462ada6971))
|
||||
* **progress:** measure delivery intervals with a monotonic clock ([ed881c6](https://github.com/morluto/rea/commit/ed881c6d2ef6a8adba0c2a241ebc9ab4603b7868))
|
||||
* **providers:** retain native parsing and compiler failure diagnostics ([b5ededd](https://github.com/morluto/rea/commit/b5ededd0f9c28d0f696a205103066928117368ee))
|
||||
* **readiness:** validate stage evidence references once ([02cedc3](https://github.com/morluto/rea/commit/02cedc37961a5d230b134f330c148322cd04bf37))
|
||||
* **reconstruction:** resolve latest verifier observations without input-order bias ([1f7f808](https://github.com/morluto/rea/commit/1f7f808c4f3bb5eca6b13bcc71d5cceb2ace6614))
|
||||
* **reference:** report sloppy scripts, PHP, C# and enclosing Git facts ([#1770](https://github.com/morluto/rea/issues/1770)) ([7b15762](https://github.com/morluto/rea/commit/7b15762d09acf3d4ffb937f55ec1b54e7db5fbfa))
|
||||
* **reference:** verify symlink identity around target reads ([3d83135](https://github.com/morluto/rea/commit/3d8313598785359c424d7305f503cb69fc8639a6))
|
||||
* **runtime:** preserve unknown debugger attachment state ([e55ef72](https://github.com/morluto/rea/commit/e55ef7213515ba204c3d88a4487b4ee6a2773b06))
|
||||
* **screenshot:** reject invalid PNG header dimensions ([#2472](https://github.com/morluto/rea/issues/2472)) ([e74e4e9](https://github.com/morluto/rea/commit/e74e4e951bf59c42b25779b45d1c7b8dde6b618f))
|
||||
* **scripts, install:** honor test:fast "without build" promise and orient Node.js engine mismatches ([#2026](https://github.com/morluto/rea/issues/2026)) ([69e097a](https://github.com/morluto/rea/commit/69e097a8ca256ab97ee4b2883739447784a5f90e))
|
||||
* **scripts:** pass MCP callTool timeouts as request options ([#2387](https://github.com/morluto/rea/issues/2387)) ([c462a4e](https://github.com/morluto/rea/commit/c462a4ebd3a10e59253bcf38353a0914b176738b))
|
||||
* **session:** attribute selected-provider capabilities to the resolved identity ([#2013](https://github.com/morluto/rea/issues/2013)) ([2ff850d](https://github.com/morluto/rea/commit/2ff850de68dc9b05c6faf2bbfd26d56bc2d5d27c)), closes [#2009](https://github.com/morluto/rea/issues/2009)
|
||||
* **session:** invalidate snapshots before metadata write dispatch ([1c64fe3](https://github.com/morluto/rea/commit/1c64fe3eff48679c6880f42d2f0a4af46686ade7))
|
||||
* **session:** verify retained client liveness on open fast path ([e59db2f](https://github.com/morluto/rea/commit/e59db2f5deefb1c5e74f01e19631f6d75cfaa25a))
|
||||
* **setup:** configure an empty Hermes config.yaml ([#1983](https://github.com/morluto/rea/issues/1983)) ([43f9c39](https://github.com/morluto/rea/commit/43f9c39dfbdfaa6e5bc260b037a506cd1f56fe6d))
|
||||
* **setup:** honor config roots and display terminal dry-run plans ([2397137](https://github.com/morluto/rea/commit/23971376158e7815edcbe6c04a8df7c9504650dc))
|
||||
* **setup:** ignore empty home directory selections ([c339f9e](https://github.com/morluto/rea/commit/c339f9ebe7bd1f30608d6ccb7175da523dd3c485)), closes [#2325](https://github.com/morluto/rea/issues/2325)
|
||||
* **setup:** name each skill directory in the uninstall summary ([#2118](https://github.com/morluto/rea/issues/2118)) ([5190843](https://github.com/morluto/rea/commit/5190843f8f09ac17cbf79561bd1f708c509cda78)), closes [#2111](https://github.com/morluto/rea/issues/2111)
|
||||
* **setup:** name invalid client config shape errors clearly ([#2157](https://github.com/morluto/rea/issues/2157)) ([b1bf4a7](https://github.com/morluto/rea/commit/b1bf4a70d44191348ca20a91a52fb663da4daed0))
|
||||
* **setup:** name structural problems in client configurations ([#2154](https://github.com/morluto/rea/issues/2154)) ([51367ca](https://github.com/morluto/rea/commit/51367cae6f4e6ef9b9dcb28dc21fe6c92d1dd6f8)), closes [#2152](https://github.com/morluto/rea/issues/2152)
|
||||
* **setup:** pin Hopper's macOS publisher identity before install ([#2481](https://github.com/morluto/rea/issues/2481)) ([650237e](https://github.com/morluto/rea/commit/650237efb01b3c4a154799dbbedb2af1084f2312))
|
||||
* **setup:** preserve Cordis ownership across uncertain patch structure ([21eff3b](https://github.com/morluto/rea/commit/21eff3bae10f58711a35ad44f86e9f96f9bb4790))
|
||||
* **setup:** preserve current recovery snapshots and decoding failures ([f6775a3](https://github.com/morluto/rea/commit/f6775a36694a6a9c620f4248638e8056a6cbcb66))
|
||||
* **setup:** preserve skill files and report rollback failures ([c240768](https://github.com/morluto/rea/commit/c2407689cb04df082e8db0b6afa8f8d022f6bcd1))
|
||||
* **setup:** preserve unrelated Hermes YAML line formatting ([58e91e1](https://github.com/morluto/rea/commit/58e91e14bd5659dd57acbb91e56b5fc6a6963c80))
|
||||
* **setup:** register a stable launcher when run through bunx ([#1731](https://github.com/morluto/rea/issues/1731)) ([d412def](https://github.com/morluto/rea/commit/d412def7225a556e6d2140176dc9ab4bd34af0f3))
|
||||
* **setup:** remove emptied skill directories on uninstall ([#2182](https://github.com/morluto/rea/issues/2182)) ([d350357](https://github.com/morluto/rea/commit/d35035780a643e38301b8eaea03f01bb506e9df9))
|
||||
* **setup:** retain regular-file cleanup ownership through workflows ([dcfc5d4](https://github.com/morluto/rea/commit/dcfc5d403ac997c2b08bee4753e57a1a4a8a8538))
|
||||
* **setup:** retain symlinked data roots during uninstall ([#1821](https://github.com/morluto/rea/issues/1821)) ([7e2f79a](https://github.com/morluto/rea/commit/7e2f79ab45a23ab844f89fb93c9cdf202230c73f))
|
||||
* **setup:** verify independently selected skill destinations ([bb50813](https://github.com/morluto/rea/commit/bb508134efbba392a404b651d6ec05be8b7eac1e))
|
||||
* **snapshot:** bind replayed subjects to the selected target path ([c002b89](https://github.com/morluto/rea/commit/c002b89e2d8130a3c07e25e5fd604876be18c703))
|
||||
* **snapshot:** classify non-object roots as invalid content ([#2685](https://github.com/morluto/rea/issues/2685)) ([9cf49c7](https://github.com/morluto/rea/commit/9cf49c7a784ace9b79e4b4c1022c9fbf2b3f77e1))
|
||||
* **sourcemap:** retain named originals beside unknown source names ([83a4445](https://github.com/morluto/rea/commit/83a4445f440fab978ad9376b4260acae3f5fb189))
|
||||
* **sourcemaps:** preserve nullable source declarations ([ee50ed1](https://github.com/morluto/rea/commit/ee50ed121e38cc6e1a2ff0dc8b789f15f71c5402))
|
||||
* **target:** recognize binary plists by magic under any extension ([#2133](https://github.com/morluto/rea/issues/2133)) ([672bdea](https://github.com/morluto/rea/commit/672bdea21fd943688e081d209a993e234c45507f)), closes [#2129](https://github.com/morluto/rea/issues/2129)
|
||||
* **targets:** project invalid raw-binary loads as caller input ([#2004](https://github.com/morluto/rea/issues/2004)) ([edf4624](https://github.com/morluto/rea/commit/edf46248f2a44b4e5776518489fee09d0fc4516b))
|
||||
* **targets:** recognize Windows headers after short DOS stubs ([c9fed50](https://github.com/morluto/rea/commit/c9fed50691ae014890e91a72534773d2fb58ce44))
|
||||
* **targets:** remove path advice for format refusals ([f28f338](https://github.com/morluto/rea/commit/f28f3382836f8dd8629c25b1bf5455d29b28d41d)), closes [#1979](https://github.com/morluto/rea/issues/1979) [#1852](https://github.com/morluto/rea/issues/1852)
|
||||
* **test:** audit static Wasm and Harmony tool wording ([#1917](https://github.com/morluto/rea/issues/1917)) ([dfe3117](https://github.com/morluto/rea/commit/dfe31172ca0a090be3f40bc134f54d69c08e31d8))
|
||||
* **test:** wait for Hopper reconnect listener readiness ([#1885](https://github.com/morluto/rea/issues/1885)) ([d3e83b0](https://github.com/morluto/rea/commit/d3e83b089943e2941dace3546a068a59bd0a335e))
|
||||
* **uninstall:** label YAML configuration syntax failures correctly ([94153f1](https://github.com/morluto/rea/commit/94153f1c757e07a6d092e00903aa8b125d4a0afa))
|
||||
* **update:** resolve Bun global directories before changing cwd ([e90acae](https://github.com/morluto/rea/commit/e90acaea674f5f7f9f17ea472104afdc1422ada6))
|
||||
* **update:** share parsed client activation with maintenance ([b045df1](https://github.com/morluto/rea/commit/b045df100e1489e08a36d6ff04cea7feecf1332c))
|
||||
* **wasm:** accept unpadded WABT section rows ([#1963](https://github.com/morluto/rea/issues/1963)) ([9eee709](https://github.com/morluto/rea/commit/9eee709f0cc4064808579c225814898a475ca388))
|
||||
* **wasm:** inspect modules whose custom section names contain NUL ([#1981](https://github.com/morluto/rea/issues/1981)) ([1febc22](https://github.com/morluto/rea/commit/1febc22907f1cc8035dd1450616233121bd7f19d))
|
||||
* **wasm:** preserve debug module names in WABT output ([#1967](https://github.com/morluto/rea/issues/1967)) ([b777e5d](https://github.com/morluto/rea/commit/b777e5d4d8a7b60edad7d5c0eaa56408a4b3f610))
|
||||
* **wasm:** preserve leading BOM in custom section names ([#2102](https://github.com/morluto/rea/issues/2102)) ([abc8c6d](https://github.com/morluto/rea/commit/abc8c6dadf4f11b67de1cc2ee96bb55047416d71))
|
||||
* **wasm:** stop appending WABT remediation to unrelated runtime-root failures ([#2084](https://github.com/morluto/rea/issues/2084)) ([ea2f94d](https://github.com/morluto/rea/commit/ea2f94d4de2241306158fecdd75f96cd34566694))
|
||||
* **web:** reject NUL bytes in web tool local path inputs ([499ac18](https://github.com/morluto/rea/commit/499ac180115de7c9de3f1adc55159ffa6e9bb40e))
|
||||
* **website:** clarify translated evidence and runtime observations ([f1f5582](https://github.com/morluto/rea/commit/f1f5582ce84f54e529826fef7af3e201f52248d9))
|
||||
* **website:** clarify translated terminology and RTL flows ([a956275](https://github.com/morluto/rea/commit/a956275d8d0249c16dad282a3fb5d7da48fb2d6f))
|
||||
* **website:** incorporate multilingual contributor refinements ([d981a58](https://github.com/morluto/rea/commit/d981a58d7f5344c3ab2117b6384aece4d1c35480))
|
||||
* **website:** keep omitted language selectors free of whitespace ([8583a19](https://github.com/morluto/rea/commit/8583a190c0178374132c888622d693fed1f88a18))
|
||||
* **website:** prioritize getting started across language editions ([8cbd57c](https://github.com/morluto/rea/commit/8cbd57cafaf1cfacbe832bae593589cf211442ef))
|
||||
* **windows:** bring the real Windows Ghidra verifiers up to date ([#1863](https://github.com/morluto/rea/issues/1863)) ([ea40f71](https://github.com/morluto/rea/commit/ea40f717b26392000f989a8b500f6774b5e85091))
|
||||
* **windows:** preserve caller affinity only within one processor group ([#2202](https://github.com/morluto/rea/issues/2202)) ([ab62ee9](https://github.com/morluto/rea/commit/ab62ee9dcf98e4717d0b3445330f7cfdfe50c9f4))
|
||||
* **windows:** preserve transient runtime admission failures ([#2467](https://github.com/morluto/rea/issues/2467)) ([1724077](https://github.com/morluto/rea/commit/172407787f1842dedbcd27b5385177595f786ce4))
|
||||
* **windows:** retain failed native inspection ownership ([#1826](https://github.com/morluto/rea/issues/1826)) ([0b4ce83](https://github.com/morluto/rea/commit/0b4ce83618bb800627264ae6221e2e35140417b5))
|
||||
* **workflows:** retain staged output ownership and cancellation semantics ([7285e15](https://github.com/morluto/rea/commit/7285e15839a4fecf9940683699b781a60ccc4775)), closes [#1968](https://github.com/morluto/rea/issues/1968)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **browser:** parse captured module URLs once per lookup ([#1829](https://github.com/morluto/rea/issues/1829)) ([ca11162](https://github.com/morluto/rea/commit/ca11162d027a0d3e3b5a65a54825b00d5689fb80))
|
||||
* **ci:** share bytecode across fresh Apple artifact subprocesses ([ba7df3f](https://github.com/morluto/rea/commit/ba7df3f0f7ee63e6350d5fbdddc8ed945a6b141d))
|
||||
* **cli:** bundle runtime entries to reduce Windows startup I/O ([#1996](https://github.com/morluto/rea/issues/1996)) ([d93fbda](https://github.com/morluto/rea/commit/d93fbda390c6b8b2741c552bd194b50c3e08e0c1))
|
||||
* **cli:** defer command-specific analysis implementations ([a4dd4e8](https://github.com/morluto/rea/commit/a4dd4e8e8700ce350e1a0c612ebd61b91ca5e585))
|
||||
* **cli:** defer command-specific modules until dispatch ([a82d14e](https://github.com/morluto/rea/commit/a82d14e4db4dca989ffa2e7dbe78fd43551da256))
|
||||
* **domain:** avoid duplicate readiness bundle validation ([29ff887](https://github.com/morluto/rea/commit/29ff887f6558cf00c0d6392889b890ed8a702a6f))
|
||||
* **evidence:** authenticate and seal imported bundles in one pass ([b123b2e](https://github.com/morluto/rea/commit/b123b2e51cc7d6d2157525664be2df88fb1ba3bd))
|
||||
* **evidence:** fuse streamed JSON token handling ([#1694](https://github.com/morluto/rea/issues/1694)) ([13e03e9](https://github.com/morluto/rea/commit/13e03e94363a007353fcb5dfedec764de12fc927))
|
||||
* **evidence:** reuse authenticated comparisons through aggregation ([26bffb9](https://github.com/morluto/rea/commit/26bffb94bf80355c35e22a98a8fc629c6210307e))
|
||||
* **evidence:** reuse authenticated file snapshots during session import ([#1721](https://github.com/morluto/rea/issues/1721)) ([966dc3a](https://github.com/morluto/rea/commit/966dc3acde2880a0f94b2658e2a02180d505cafa))
|
||||
* **evidence:** reuse authenticated unknown mutation snapshots ([042dcaa](https://github.com/morluto/rea/commit/042dcaa1ee66f1b8668f08b193f38ab01bceaacb))
|
||||
* **flutter:** add opt-in retained AOT summary and string pages ([23de4f7](https://github.com/morluto/rea/commit/23de4f77c74dd299d45cab880c36321d802b21fe))
|
||||
* **frida:** evict captured messages without copying the whole queue ([#2628](https://github.com/morluto/rea/issues/2628)) ([6a05248](https://github.com/morluto/rea/commit/6a05248d464387c0ce4ec1577b4c1b24d3d355c7))
|
||||
* **ghidra:** compile bundle activator with bridge scripts ([#1931](https://github.com/morluto/rea/issues/1931)) ([0f4992f](https://github.com/morluto/rea/commit/0f4992fb9e5bfe1fafe75c35b7f20ad411595573))
|
||||
* **javascript:** index source-map ignore lists per leaf ([#1905](https://github.com/morluto/rea/issues/1905)) ([898d509](https://github.com/morluto/rea/commit/898d5096f94fe1ed3ea7d091455e638cc7d23bfd))
|
||||
* **javascript:** reuse collected callable identities for returns ([d3088c3](https://github.com/morluto/rea/commit/d3088c3795f6c37cb3b6aec0bb0c371dd0640ee8))
|
||||
* **javascript:** skip nested callables during fingerprint traversal ([#1871](https://github.com/morluto/rea/issues/1871)) ([097f0d5](https://github.com/morluto/rea/commit/097f0d54afceb3ce61dbaa24dd6736658d1fce06))
|
||||
* **javascript:** skip the repeated-prefix scan on first capture visits ([#1789](https://github.com/morluto/rea/issues/1789)) ([41544fb](https://github.com/morluto/rea/commit/41544fb952aef84fcf6ff5d9a5284cf2b2985053))
|
||||
* **mcp:** bundle internal modules to reduce Windows startup I/O ([76e4153](https://github.com/morluto/rea/commit/76e4153d346b14ef194222bbeef240cf8a16dc99))
|
||||
* **mcp:** compact repeated session discovery facts ([#1988](https://github.com/morluto/rea/issues/1988)) ([6395e8d](https://github.com/morluto/rea/commit/6395e8d3007d5ee453477460e7ee1c51cff76be0))
|
||||
* **mcp:** keep Playwright and type-only imports off MCP startup ([#1691](https://github.com/morluto/rea/issues/1691)) ([0ecf3f2](https://github.com/morluto/rea/commit/0ecf3f2438ec96a6b543942869808018e8d84452))
|
||||
* **mcp:** reduce Windows startup module resolution ([#1814](https://github.com/morluto/rea/issues/1814)) ([76e4153](https://github.com/morluto/rea/commit/76e4153d346b14ef194222bbeef240cf8a16dc99))
|
||||
* **native:** add opt-in Mach-O summary delivery and retained pages ([#2634](https://github.com/morluto/rea/issues/2634)) ([bf34d0d](https://github.com/morluto/rea/commit/bf34d0d2fc3cc22076f49d682e74a0b71aa1db18))
|
||||
* **native:** run independent Mach-O slice commands concurrently ([#2466](https://github.com/morluto/rea/issues/2466)) ([2c7240c](https://github.com/morluto/rea/commit/2c7240cf9d8231e16ad01de7678af9dbd01bab46))
|
||||
* **snapshot:** restore large legacy caches within live memory bounds ([092de8f](https://github.com/morluto/rea/commit/092de8f4b336b537579bd48d7d30135b7114371d))
|
||||
* **test:** batch compatible client profile lifecycles ([0153b4d](https://github.com/morluto/rea/commit/0153b4d95bdf38eddee91d5ce08181030fb955b4))
|
||||
* **test:** reuse CLI boundary worker module graphs ([f178bf1](https://github.com/morluto/rea/commit/f178bf1c00139e1b7fe2d14fcfe1f558fe08fbe2))
|
||||
* **test:** reuse the CI scope Git baseline ([ef5f9aa](https://github.com/morluto/rea/commit/ef5f9aa2d7769b32d9f88211812ae11456a88267))
|
||||
* **tests:** consolidate TypeScript dialect producer checks ([01762b1](https://github.com/morluto/rea/commit/01762b10c6a40370996e6dfa3a96ffcb27148f97))
|
||||
* **test:** share stdio analysis for comparison fixtures ([43dafb5](https://github.com/morluto/rea/commit/43dafb5abe60ee21be4f6a6b6de482cd7a5bab0a))
|
||||
* **tests:** remove timed output and synchronize browser fixtures ([693ed33](https://github.com/morluto/rea/commit/693ed332b58d05192f55b8192d2c0c4bc4f4824a))
|
||||
* **tests:** reuse stateless workflows and immutable fixtures ([5f4c7ff](https://github.com/morluto/rea/commit/5f4c7ff6df0735d5a4461b5d0e6f2e466b888be0))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **evm:** bind offline analysis through a static descriptor ([ad08510](https://github.com/morluto/rea/commit/ad08510a113d7a9b8d92fb1e385757880e469869))
|
||||
* **javascript:** reuse validated source-map decoding ([f64790b](https://github.com/morluto/rea/commit/f64790bfc32db330b2be5bdff61af555a9d26067))
|
||||
* **mcp:** trust validated inputs and tighten contract fixtures ([2646150](https://github.com/morluto/rea/commit/264615059ce6c89badcc7288e8c67bb8dd1ce0dd))
|
||||
* prune obsolete provider and verification code ([e198793](https://github.com/morluto/rea/commit/e19879328a11fe38b49b6b133a8ebde908ea980e))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add responsible-use disclaimer ([4a5aec8](https://github.com/morluto/rea/commit/4a5aec8ad6d54444b2a2c2313fbc506b17ff91aa))
|
||||
* **agents:** require brittleness review before resolving threads ([0365454](https://github.com/morluto/rea/commit/0365454a573eb52c7089320fd74bfd4a673b78ae))
|
||||
* **agents:** require related brittleness cleanup in bug fixes ([0d8b728](https://github.com/morluto/rea/commit/0d8b728d2470a9b51239d61986944bacaf9a8708))
|
||||
* celebrate 110,000 GitHub stars <‰ ([#2042](https://github.com/morluto/rea/issues/2042)) ([0d78c99](https://github.com/morluto/rea/commit/0d78c99c11efee66570772efe6eb88dfd0e1c694))
|
||||
* celebrate 170,000 GitHub stars <‰ ([#2462](https://github.com/morluto/rea/issues/2462)) ([9188698](https://github.com/morluto/rea/commit/91886982fccc50a9f7f31d4f320b25ffbcff35af))
|
||||
* celebrate 80,000 GitHub stars <‰ ([#1752](https://github.com/morluto/rea/issues/1752)) ([6ff4d4a](https://github.com/morluto/rea/commit/6ff4d4a0500e5f9fb7601e1c5ae5dbbd85796d91))
|
||||
* celebrate 90,000 GitHub stars <‰ ([#1858](https://github.com/morluto/rea/issues/1858)) ([5876779](https://github.com/morluto/rea/commit/5876779013726964deef748120c48f5fa0155546))
|
||||
* clarify ownership and interpretation boundaries ([64b71b1](https://github.com/morluto/rea/commit/64b71b180a88a2df1b0b61e803c1c322ee59c5e1))
|
||||
* clarify responsible use and preserve third-party notices ([#2082](https://github.com/morluto/rea/issues/2082)) ([974f11d](https://github.com/morluto/rea/commit/974f11d010570c0a18423cd1cc93e9a5203df1b6))
|
||||
* **cli:** add a managed assembly inspection workflow ([#2037](https://github.com/morluto/rea/issues/2037)) ([538f8cc](https://github.com/morluto/rea/commit/538f8cc7224dba44b728cf6c000920f911957fc4))
|
||||
* correct drift and reorganize English guides ([#2207](https://github.com/morluto/rea/issues/2207)) ([7ae3c50](https://github.com/morluto/rea/commit/7ae3c50a9d49c480035855de9fcc4484befacd92))
|
||||
* define contribution scope and simplify proposal forms ([ac6ef95](https://github.com/morluto/rea/commit/ac6ef95e12335bdc054ccaaa96c32474cf8789ea))
|
||||
* define performance priorities for analysis workflows ([#2245](https://github.com/morluto/rea/issues/2245)) ([8e00452](https://github.com/morluto/rea/commit/8e004522f232ec698c77536235853088a851e6b5))
|
||||
* **ghidra:** verified macOS arm64 native decompiler build recipe ([#2112](https://github.com/morluto/rea/issues/2112)) ([5f79c85](https://github.com/morluto/rea/commit/5f79c852af8e53a56f58266a9dcf84314c197164))
|
||||
* **ghidra:** verify and document the macOS arm64 native decompiler build ([5f79c85](https://github.com/morluto/rea/commit/5f79c852af8e53a56f58266a9dcf84314c197164))
|
||||
* **i18n:** celebrate 80,000 GitHub stars in translated READMEs ([#1758](https://github.com/morluto/rea/issues/1758)) ([953a0ce](https://github.com/morluto/rea/commit/953a0ce5bd3a8b44051eb9c16d8c9a20cad29e7c))
|
||||
* **install:** explain Claude Code upfront tool loading on third-party endpoints ([#2029](https://github.com/morluto/rea/issues/2029)) ([d024928](https://github.com/morluto/rea/commit/d0249282c64882329a6acab8f3b477552da53499)), closes [#2019](https://github.com/morluto/rea/issues/2019)
|
||||
* preserve the native guide title anchor ([#2224](https://github.com/morluto/rea/issues/2224)) ([ae41265](https://github.com/morluto/rea/commit/ae412654d5947f3841c191e92f3ad6b73d6f894b))
|
||||
* **readiness:** align required stage identifier and checks with evaluator ([#2200](https://github.com/morluto/rea/issues/2200)) ([85c5477](https://github.com/morluto/rea/commit/85c5477930d504cfd8b72334b48255d8e746a85e))
|
||||
* **readme:** remove CI badges from translations ([3298219](https://github.com/morluto/rea/commit/3298219d8cd905897ec9a96bf639602a273d4a9b))
|
||||
* **readme:** remove CI status badge ([b07e54a](https://github.com/morluto/rea/commit/b07e54a10e66319d90815313fae93350257e1655))
|
||||
* **setup:** confirm published 6.4.0 client support ([57c5aca](https://github.com/morluto/rea/commit/57c5acac2e13fd7f0e3832bc147c7ceda8c314f2))
|
||||
* shorten responsible-use notices across languages ([fa87dd2](https://github.com/morluto/rea/commit/fa87dd2aac3eaeb49875c54e93ec18c98cc92e4d))
|
||||
* sync French, Indonesian and Italian READMEs with English ([#1861](https://github.com/morluto/rea/issues/1861)) ([96547d3](https://github.com/morluto/rea/commit/96547d373fcdc672058b29774d6c634f4e9cc1bd))
|
||||
* **website:** add essay on reverse engineering models ([fe496bd](https://github.com/morluto/rea/commit/fe496bd1cf589938c372646ddef8ca9701417bf0))
|
||||
* **website:** highlight the setup guide on the homepage ([#1714](https://github.com/morluto/rea/issues/1714)) ([8be7ad4](https://github.com/morluto/rea/commit/8be7ad496c9a52a9986c4d3f6596de279b52df7e))
|
||||
* **website:** improve model essay transitions and narrative flow ([ca07373](https://github.com/morluto/rea/commit/ca07373e44e19e0c8357ac49e460c90d87fcc22c))
|
||||
* **website:** publish model essay in all supported languages ([3d78a57](https://github.com/morluto/rea/commit/3d78a57043eb6b92812c71bcb6d83753c3d48036))
|
||||
* **website:** simplify reconstruction evidence in model essay ([b06751b](https://github.com/morluto/rea/commit/b06751b703181527a320226c4ab7e4cecd5dcea0))
|
||||
* **website:** use straight quotes in model essay ([806ff57](https://github.com/morluto/rea/commit/806ff575676e447c7ba349b6fd185e6e8e02ee5d))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* **analysis:** verify real preset snapshot and cleanup boundaries ([#2349](https://github.com/morluto/rea/issues/2349)) ([1dd20ce](https://github.com/morluto/rea/commit/1dd20ce1ff4ab66c74350929577e753aa17a9006)), closes [#2016](https://github.com/morluto/rea/issues/2016)
|
||||
* **android:** verify duplicate class inventory behavior on empty discovery queries ([#1984](https://github.com/morluto/rea/issues/1984)) ([6611bcf](https://github.com/morluto/rea/commit/6611bcf6e86630c775c24e6afa00d7239c64e70e))
|
||||
* **apple:** consolidate keyed archive integer verification ([#1933](https://github.com/morluto/rea/issues/1933)) ([3b490a7](https://github.com/morluto/rea/commit/3b490a743fd56a8a27b1356f7b2d1284db8848ef))
|
||||
* **apple:** keep the same-named slice dyld fixture openable on x64 hosts ([#2755](https://github.com/morluto/rea/issues/2755)) ([fb28ec1](https://github.com/morluto/rea/commit/fb28ec1537e2d02aaca96325483c849c5c8ade6f))
|
||||
* **apple:** skip ibtool-compiled NIB tests when Xcode ibtool is unavailable ([#2070](https://github.com/morluto/rea/issues/2070)) ([7bc57e1](https://github.com/morluto/rea/commit/7bc57e1e64915a26c32f89fb53d0321193a37160))
|
||||
* **architecture:** consolidate import boundary verification ([2976fac](https://github.com/morluto/rea/commit/2976fac30d4088c1f1ba383261eb3eff5e95bce6))
|
||||
* bound CLI validation subprocess concurrency ([ff57b75](https://github.com/morluto/rea/commit/ff57b75e226028767b86aa3d4271c2c2f56342db))
|
||||
* **ci:** stabilize JADX recovery and ignore generated reports ([#1668](https://github.com/morluto/rea/issues/1668)) ([0a6b46d](https://github.com/morluto/rea/commit/0a6b46d5b0299f76c0d268f7b70395f9b6fbd68e))
|
||||
* classify filesystem and process workflows by boundary ([c2b56e3](https://github.com/morluto/rea/commit/c2b56e3e1c9619beab15de8e1f200422473caf30))
|
||||
* classify filesystem application workflows as boundaries ([804c287](https://github.com/morluto/rea/commit/804c287e441dcaa3876429f026ac89f6482f9699))
|
||||
* **cli:** align reference import failure envelopes ([8224aeb](https://github.com/morluto/rea/commit/8224aeba9dd41f29ea6f8ef7ad7efa3f45fd4a6e))
|
||||
* **cli:** cache subprocess compilation within each worker ([6bcfeb0](https://github.com/morluto/rea/commit/6bcfeb0e55a6fcc3ed586e5744f2aa1c270cac31))
|
||||
* **cli:** consolidate development runner workflow coverage ([16f5ded](https://github.com/morluto/rea/commit/16f5ded4468d7100d3640f874f33ddbaa953a787))
|
||||
* **cli:** consolidate schema coverage and assert input refusals ([beb80a0](https://github.com/morluto/rea/commit/beb80a07bafc0847600eb635e0323068a386c811))
|
||||
* **clients:** verify native Claude Code integration on Windows ([#1926](https://github.com/morluto/rea/issues/1926)) ([c32d846](https://github.com/morluto/rea/commit/c32d84622fb3952823c6cfc25c557aec08c1788c)), closes [#1923](https://github.com/morluto/rea/issues/1923)
|
||||
* **clients:** verify native Gemini CLI integration on Windows ([#1936](https://github.com/morluto/rea/issues/1936)) ([04089d5](https://github.com/morluto/rea/commit/04089d5bec11d6e0411e7c35d0168bbae63d7e7d))
|
||||
* **cli:** preserve Windows runtime paths and failure diagnostics ([79156e5](https://github.com/morluto/rea/commit/79156e5bed1d72b83ade0483246f457b337b7e59))
|
||||
* **docs:** type README drift scenarios as fixed pairs ([6e38529](https://github.com/morluto/rea/commit/6e38529333a7a0704690a087c7cb054fb7385d25))
|
||||
* **evidence:** cover workflow question whitespace normalization ([#1739](https://github.com/morluto/rea/issues/1739)) ([b2da21a](https://github.com/morluto/rea/commit/b2da21abe11ac43a4cc24b233bd52ce10995e4a1))
|
||||
* **evm:** verify real worker cancellation and cleanup recovery ([#2404](https://github.com/morluto/rea/issues/2404)) ([ae80513](https://github.com/morluto/rea/commit/ae8051302b5834322fa00cfa31c968ae74ab6ed9))
|
||||
* **evm:** verify static descriptor bindings before use ([#2427](https://github.com/morluto/rea/issues/2427)) ([4f4f6cd](https://github.com/morluto/rea/commit/4f4f6cd7b5d7a400b90772a138d7b9f942c2a556))
|
||||
* **ghidra:** require pending multi-HI16 coverage in the real MIPS relocatable lane ([#1974](https://github.com/morluto/rea/issues/1974)) ([eaf88ac](https://github.com/morluto/rea/commit/eaf88aca2af2b8ad7a277c09d0b45f6e2de8ac4a))
|
||||
* **ghidra:** restore complete installation fixtures across CI boundaries ([18d56f5](https://github.com/morluto/rea/commit/18d56f5321c3c5d5be40c19bcb0a0f2b25c2d459))
|
||||
* **hopper:** align startup fixtures with cleanup and lease contracts ([b3dec00](https://github.com/morluto/rea/commit/b3dec005bcb89b5afe6e0aa41e5251f8a3a31de7))
|
||||
* **hopper:** repair Linux demo setup and native verification ([#2638](https://github.com/morluto/rea/issues/2638)) ([141d209](https://github.com/morluto/rea/commit/141d2095f5c6faaf2d3c9b81b0f0f992cacec79c))
|
||||
* **ida:** expect direction in combined call coverage ([4dd44dd](https://github.com/morluto/rea/commit/4dd44ddf16c810c4bcaa16e0bdb3b09bfcb4e33a))
|
||||
* **javascript:** classify artifact workflows as boundary tests ([dc303a8](https://github.com/morluto/rea/commit/dc303a8ee8a2ceaae37e4da4ac4ed64035793d59))
|
||||
* **javascript:** consolidate module lookup regressions ([#2031](https://github.com/morluto/rea/issues/2031)) ([783fcb0](https://github.com/morluto/rea/commit/783fcb06a4bd0444be214822fed60a3786932782))
|
||||
* **javascript:** expect unknown parameter provenance ([e05d339](https://github.com/morluto/rea/commit/e05d3399f19f25fdf04285a02b293418256b26dc))
|
||||
* **javascript:** verify CommonJS file URL boundaries via CLI and MCP ([#2061](https://github.com/morluto/rea/issues/2061)) ([6ce5903](https://github.com/morluto/rea/commit/6ce5903e7a37252156ec847c4c8874410a4d8f3f))
|
||||
* **managed:** use the current attribute fixture contract ([6407718](https://github.com/morluto/rea/commit/6407718f3020a34cc087163492e3f9a465e2e841))
|
||||
* **mcp:** align completion checks with provider readiness ([fe4b94a](https://github.com/morluto/rea/commit/fe4b94a661054cef7603a3f07aae71f1df0a701a))
|
||||
* **mcp:** fix three MCP boundary suites on native Windows ([#2312](https://github.com/morluto/rea/issues/2312)) ([a97b713](https://github.com/morluto/rea/commit/a97b713f740932339a5f1036ad2213168c421270))
|
||||
* **mcp:** preserve coverage in unchanged function fixtures ([3ee6ff4](https://github.com/morluto/rea/commit/3ee6ff43c1696a5a5269bd4aa5de72f7e5f40627))
|
||||
* **mcp:** ready providers before live prompt completion checks ([#2137](https://github.com/morluto/rea/issues/2137)) ([621cee1](https://github.com/morluto/rea/commit/621cee192d582c87ca3d254d4cb22c9d209d3aea)), closes [#2123](https://github.com/morluto/rea/issues/2123)
|
||||
* **mcp:** reuse advertised schema in inventory check ([#2469](https://github.com/morluto/rea/issues/2469)) ([a6efa4f](https://github.com/morluto/rea/commit/a6efa4fcefeffcf5ff5310b8154988b4be30c349))
|
||||
* **native:** replay complete dylib-use provider output ([89f5e48](https://github.com/morluto/rea/commit/89f5e489adac69af2937fabc1a5ba283191d630a)), closes [#1970](https://github.com/morluto/rea/issues/1970)
|
||||
* **native:** skip the DMG EACCES diagnostic as root ([#1584](https://github.com/morluto/rea/issues/1584)) ([262efe6](https://github.com/morluto/rea/commit/262efe6c54d8f179e9f9515d96025871ae45806a))
|
||||
* **native:** skip the EACCES directory diagnostic as root ([#1394](https://github.com/morluto/rea/issues/1394)) ([40d9c7b](https://github.com/morluto/rea/commit/40d9c7b836a3aca5d589c0a65c7d02b3ea5cd36b))
|
||||
* **native:** skip the inaccessible-directory diagnostic as root ([#1900](https://github.com/morluto/rea/issues/1900)) ([d0edeec](https://github.com/morluto/rea/commit/d0edeec1009f1149956ee2a95dc5941aff8f78b5))
|
||||
* **native:** verify real PE and DOS entry views ([1770f38](https://github.com/morluto/rea/commit/1770f38cfc6cbe87491d0de8b8730c305c5cf8ea))
|
||||
* **pcap:** run the missing-path refusal only where captures are admitted ([#2510](https://github.com/morluto/rea/issues/2510)) ([1991542](https://github.com/morluto/rea/commit/1991542e59f64d46269b3ff942be77a5ff06e88f))
|
||||
* **perf:** distinguish Codex tool context and token accounting ([#2416](https://github.com/morluto/rea/issues/2416)) ([ee87111](https://github.com/morluto/rea/commit/ee87111009ace2ca4ef82c9aef09c82a37ca55f7))
|
||||
* **perf:** observe owned counters at payload completion ([ca2d15b](https://github.com/morluto/rea/commit/ca2d15b3ee965bd7ab27204cdf4b96b117d148c1))
|
||||
* **perf:** verify existing SQLite column selection on QGIS data ([#2489](https://github.com/morluto/rea/issues/2489)) ([45984a4](https://github.com/morluto/rea/commit/45984a4319c8b6dcf2b03e969fbd4330c8432394))
|
||||
* **reference:** scope cleanup faults to the owned source file ([#2430](https://github.com/morluto/rea/issues/2430)) ([eeba618](https://github.com/morluto/rea/commit/eeba61826c1cd1c7be70f011fda0537baadd4179))
|
||||
* **release:** validate only the selected preflight job ([747e729](https://github.com/morluto/rea/commit/747e7299141cec0b03774c427f579889d5088d20))
|
||||
* reuse application analysis across comparison directions ([044c0a9](https://github.com/morluto/rea/commit/044c0a92d5c86faa9965669680935d4f552977b1))
|
||||
* reuse identical JavaScript analysis fixtures ([42951bb](https://github.com/morluto/rea/commit/42951bbbbe8d98ebde1783450d130fdddb5ba889))
|
||||
* reuse validators for identical MCP schemas ([85ccef3](https://github.com/morluto/rea/commit/85ccef34de1cddcf71037ccea93acdf709f94965))
|
||||
* share native keyed archive integer fixture generation ([847a1a3](https://github.com/morluto/rea/commit/847a1a3f25f69e1b3808fb0a53e5f1415a7ed058))
|
||||
* **sourcemap:** consolidate source map regressions ([#2034](https://github.com/morluto/rea/issues/2034)) ([83a4445](https://github.com/morluto/rea/commit/83a4445f440fab978ad9376b4260acae3f5fb189))
|
||||
* **sourcemaps:** assert semantic evidence and actual output admission ([bd74c7a](https://github.com/morluto/rea/commit/bd74c7af2bec952c2eea7a17feedd05e6675b719))
|
||||
* strengthen archive and resource cleanup assertions ([5ba1642](https://github.com/morluto/rea/commit/5ba1642a7d891a60493b2195e55149b0f3197fb4))
|
||||
* strengthen public input and module lookup controls ([b7f563e](https://github.com/morluto/rea/commit/b7f563e15f854061dc1507896181b7b24ac3b5f0))
|
||||
* **web:** remove duplicate path schema cases ([b5b7301](https://github.com/morluto/rea/commit/b5b73019f946e5edc0df506c9f8f4bb28afa6590))
|
||||
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
* balance test shards and reuse package download caches ([40b37ae](https://github.com/morluto/rea/commit/40b37aeee10e89d85ee8b7747cbdcd3ad1a34db4))
|
||||
* bound core job execution time ([9f3f392](https://github.com/morluto/rea/commit/9f3f392eb8fe81060b44941ed754521984def3bd))
|
||||
* finish main validation and balance complete test costs ([f0f0995](https://github.com/morluto/rea/commit/f0f09950f34d96e3b1c62fd04821af5e301f80c7))
|
||||
* fix PR metadata triggers and defer expensive runner work ([3af8f84](https://github.com/morluto/rea/commit/3af8f847f0e35843f51622a136165dd6ee12cdd0))
|
||||
* keep validation fresh and preserve failed-shard reports ([#2210](https://github.com/morluto/rea/issues/2210)) ([428eee0](https://github.com/morluto/rea/commit/428eee00fdd0406cdf2b734c9ea14a2bb02da279))
|
||||
* narrow native-host lane selection and reuse build caches ([#2131](https://github.com/morluto/rea/issues/2131)) ([4f46eac](https://github.com/morluto/rea/commit/4f46eac453965efde3bc782aebfeec198551afc4))
|
||||
* observe affected-owner test plans against full results ([fa0a9b2](https://github.com/morluto/rea/commit/fa0a9b2d328a07d8ec2924dc224d51c620981dcc))
|
||||
* parallelize native verification and share test artifacts ([91f6840](https://github.com/morluto/rea/commit/91f6840c35e99e5f23612920d790a30d324ac115))
|
||||
* raise duplication threshold to 3.5 percent ([11f1d95](https://github.com/morluto/rea/commit/11f1d952e79241b507e47c690eed00e0d5ffda30))
|
||||
* reduce macOS runner contention ([3f4ea32](https://github.com/morluto/rea/commit/3f4ea3218929baba0c30c2cb7eb7e7a829a1fc21))
|
||||
* reduce redundant compatibility and qualification work ([d13c9f8](https://github.com/morluto/rea/commit/d13c9f840d36a321a56917e75c48afcb8d72920e))
|
||||
* reduce reporter fan-out and preserve shard history ([ab02f3c](https://github.com/morluto/rea/commit/ab02f3c8d665ead35391c94db74cd0fb0cfed0ed))
|
||||
* reuse current-run builds across provider lanes ([490a285](https://github.com/morluto/rea/commit/490a285070268aee0ecd50001b9a9a83c1e313f0))
|
||||
* reuse portable build cache in native package jobs ([8fa1175](https://github.com/morluto/rea/commit/8fa11751eaab94eaa5b8de8c1ecd143ef0066cd6))
|
||||
* reuse portable Turbo outputs across verification runs ([a00c34d](https://github.com/morluto/rea/commit/a00c34dcb155e5993bda8ad0f51bf3b13e289760))
|
||||
* scope timing helpers to portable verification ([c8d861f](https://github.com/morluto/rea/commit/c8d861f9764f8c07f0925dae0c6451de85fffd6a))
|
||||
* separate fast feedback from full qualification ([0c7f4df](https://github.com/morluto/rea/commit/0c7f4dfbd9c1975eeedfecd96debb2e4263012d0))
|
||||
* summarize static checks and retain complete reports ([#2242](https://github.com/morluto/rea/issues/2242)) ([d96550f](https://github.com/morluto/rea/commit/d96550f2f61647ffa150597de73363fa90c4e169))
|
||||
|
||||
---
|
||||
This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+148
-103
@@ -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));
|
||||
}
|
||||
+40
-6
@@ -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);
|
||||
|
||||
@@ -85,6 +85,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",
|
||||
@@ -300,6 +349,7 @@ export const SCOPES = {
|
||||
nativeaot: [
|
||||
"bridge/nativeaot/**",
|
||||
"scripts/generate-nativeaot-fixtures.mjs",
|
||||
"scripts/lib/nativeaot-build.*",
|
||||
"tests/fixtures/nativeaot/**",
|
||||
".github/workflows/nativeaot-fixtures.yml",
|
||||
],
|
||||
@@ -312,6 +362,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);
|
||||
}
|
||||
+82
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,20 @@ class Generator:
|
||||
assert "ows-secret" not in json.dumps(projected), projected
|
||||
if isinstance(value, bytes):
|
||||
assert projected["binary_fields"][-1]["state"] == "redacted", projected
|
||||
body = b"A" * 12345
|
||||
state = {"response": {"content": body}}
|
||||
control = adapter.project_record(state, [])
|
||||
field = control["binary_fields"][0]
|
||||
assert field["state"] == "retained" and field["bytes"] == len(body), control
|
||||
for literal in ["12345", "2345"]:
|
||||
# Only the generated byte count matches this exclusion.
|
||||
assert literal not in control["reported"]["response"]["content"]
|
||||
assert literal not in field["content_base64"] and literal not in field["sha256"]
|
||||
projected = adapter.project_record(state, [literal])
|
||||
assert projected["reported"]["response"]["content"] is None, projected
|
||||
assert projected["binary_fields"] == [{"pointer": "/response/content", "representation": "producer-bytes", "state": "redacted", "content_base64": None, "bytes": None, "sha256": None}], projected
|
||||
assert {"pointer": "/response/content", "reason": "explicit-sensitive-value"} in projected["redactions"], projected
|
||||
assert literal not in json.dumps(projected), projected
|
||||
(root / "native-error-classifications.json").write_text(json.dumps({"passed": len(classifications) + 2}))
|
||||
first = http.HTTPFlow(connection.Client(peername=("127.0.0.1", 1), sockname=("127.0.0.1", 2)), connection.Server(address=("example.test", 80)))
|
||||
first.id = "original-producer-id"
|
||||
|
||||
@@ -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,3 @@
|
||||
export function assertCleanLinuxHopperRuntime(
|
||||
launcherPaths?: readonly string[],
|
||||
): Promise<void>;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Observe leftover demo processes on a dedicated clean Linux runner, including
|
||||
* groups reparented after their launcher exits. This is not ownership proof and
|
||||
* never authorizes signaling; unrelated runtime processes also block this lane.
|
||||
*/
|
||||
export const assertCleanLinuxHopperRuntime = async (
|
||||
launcherPaths = [
|
||||
"/opt/hopper/bin/Hopper",
|
||||
...(process.env.HOPPER_LAUNCHER_PATH === undefined
|
||||
? []
|
||||
: [process.env.HOPPER_LAUNCHER_PATH]),
|
||||
],
|
||||
) => {
|
||||
const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]);
|
||||
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout))
|
||||
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||
const launchers = [];
|
||||
for (const path of launcherPaths) {
|
||||
try {
|
||||
launchers.push(await stat(path, { bigint: true }));
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
if (launchers.length === 0) return;
|
||||
// /proc exposes the executed file even when argv[0] names a removed symlink.
|
||||
// Compare file identity, not a display path or a process name.
|
||||
let inspectionError;
|
||||
for (const entry of await readdir("/proc", { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
|
||||
let executable;
|
||||
try {
|
||||
executable = await stat(`/proc/${entry.name}/exe`, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
|
||||
// Other users' executables may be unreadable. The setup lane launches
|
||||
// providers as the caller; unreadable caller-owned processes fail closed.
|
||||
if (error?.code === "EACCES") {
|
||||
const owner = await stat(`/proc/${entry.name}`).catch((cause) => {
|
||||
if (cause?.code === "ENOENT") return undefined;
|
||||
throw cause;
|
||||
});
|
||||
if (owner === undefined || owner.uid !== process.getuid()) continue;
|
||||
}
|
||||
// Finish the inventory so an unrelated restricted process cannot mask a
|
||||
// positively identified leak. Unknown caller processes still fail closed.
|
||||
inspectionError ??= error;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
launchers.some(
|
||||
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
|
||||
)
|
||||
)
|
||||
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||
}
|
||||
if (inspectionError !== undefined) throw inspectionError;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
type VerifierCall = (
|
||||
name: string,
|
||||
arguments_?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
interface StringExpectation {
|
||||
readonly value: string;
|
||||
readonly encoding: string;
|
||||
readonly symbol?: string;
|
||||
readonly recognitionOptional?: boolean;
|
||||
}
|
||||
|
||||
/** Verify source bytes, observed decoding, address filters and literal searches. */
|
||||
export function verifyHopperStringObjects(
|
||||
call: VerifierCall,
|
||||
expectations: readonly StringExpectation[],
|
||||
inventory?: readonly z.output<typeof analysisStringSchema>[] | null,
|
||||
): Promise<{
|
||||
readonly sourceBytesVerified: true;
|
||||
readonly recognition: readonly {
|
||||
readonly value: string;
|
||||
readonly address: string;
|
||||
readonly state: "observed" | "not_observed";
|
||||
readonly decodedObject: "verified" | "unverified";
|
||||
}[];
|
||||
readonly displayEscapesExcludedFromMatching: true;
|
||||
}>;
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
@@ -1,13 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
|
||||
const fixtureSymbolPrefix = process.platform === "darwin" ? "_" : "";
|
||||
|
||||
/** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */
|
||||
export async function verifyHopperSearch(call, invalid, procedure) {
|
||||
const address = procedure.address;
|
||||
const original = await call("address_name", { address });
|
||||
let stringCoverage;
|
||||
try {
|
||||
await verifySymbolNames(call, invalid, address);
|
||||
await verifySearchModes(call, address);
|
||||
stringCoverage = await verifySearchModes(call, address);
|
||||
} finally {
|
||||
await call("set_address_name", { address, name: original });
|
||||
}
|
||||
@@ -26,7 +29,7 @@ export async function verifyHopperSearch(call, invalid, procedure) {
|
||||
symbolUtf16LimitValidatedBeforeMutation: true,
|
||||
regexAndCaseModes: true,
|
||||
annotationCacheInvalidation: true,
|
||||
escapedUtf8AndByteStrings: true,
|
||||
stringCoverage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,11 +106,26 @@ async function verifySearchModes(call, address) {
|
||||
[{ address, value: "REA_Search_Upper" }],
|
||||
);
|
||||
const strings = await call("list_strings");
|
||||
const long = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
|
||||
const longItem = strings.find((item) =>
|
||||
item.value.startsWith("REA_LONG_LITERAL_"),
|
||||
const linux = process.platform === "linux";
|
||||
const fullLiteral = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
|
||||
// Recognition is a provider/loader capability, not an adapter guarantee.
|
||||
// Always retain the original size in raw-byte coverage; Linux additionally
|
||||
// requires an extent observed to produce native fragmented string objects.
|
||||
const fullBytes = Buffer.from(fullLiteral + "\0");
|
||||
const raw = await call("read_bytes", {
|
||||
address: `${fixtureSymbolPrefix}rea_long_literal`,
|
||||
length: fullBytes.length,
|
||||
});
|
||||
assert.equal(raw.complete, true);
|
||||
assert.equal(raw.bytes_hex, fullBytes.toString("hex"));
|
||||
const long = linux
|
||||
? "REA_FRAGMENTED_LITERAL_" + "x".repeat(512) + "fragment_tail"
|
||||
: fullLiteral;
|
||||
const longItem = strings.find(
|
||||
(item) => long.startsWith(item.value) && item.value.startsWith("REA_"),
|
||||
);
|
||||
assert.ok(longItem, "fixture omitted its real long literal");
|
||||
if (!linux) assert.equal(longItem.address, raw.address);
|
||||
assert.ok(long.startsWith(longItem.value));
|
||||
assert.ok(longItem.provider_value.endsWith("…"));
|
||||
assert.equal(longItem.string.termination, "missing");
|
||||
@@ -158,7 +176,7 @@ async function verifySearchModes(call, address) {
|
||||
}
|
||||
assert.deepEqual(
|
||||
await call("search_strings", {
|
||||
pattern: "needle",
|
||||
pattern: linux ? "fragment_tail" : "needle",
|
||||
mode: "literal",
|
||||
case_sensitive: true,
|
||||
}),
|
||||
@@ -176,36 +194,122 @@ async function verifySearchModes(call, address) {
|
||||
}),
|
||||
expected,
|
||||
);
|
||||
await verifyHopperStringObjects(call, [
|
||||
{ value: "REA_UTF8_é_😀", encoding: "utf-8" },
|
||||
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
|
||||
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
|
||||
{ value: "REA_LITERAL_…", encoding: "utf-8" },
|
||||
{ value: "REA_LATIN1_ÿ", encoding: "latin-1" },
|
||||
]);
|
||||
const objects = await verifyHopperStringObjects(
|
||||
call,
|
||||
[
|
||||
{
|
||||
value: "REA_UTF8_é_😀",
|
||||
encoding: "utf-8",
|
||||
symbol: `${fixtureSymbolPrefix}rea_utf8_literal`,
|
||||
recognitionOptional: linux,
|
||||
},
|
||||
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
|
||||
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
|
||||
{
|
||||
value: "REA_LITERAL_…",
|
||||
encoding: "utf-8",
|
||||
symbol: `${fixtureSymbolPrefix}rea_ellipsis_literal`,
|
||||
recognitionOptional: linux,
|
||||
},
|
||||
{
|
||||
value: "REA_LATIN1_ÿ",
|
||||
encoding: "latin-1",
|
||||
symbol: `${fixtureSymbolPrefix}rea_latin1_literal`,
|
||||
recognitionOptional: linux,
|
||||
},
|
||||
],
|
||||
strings,
|
||||
);
|
||||
return {
|
||||
fullLiteralSourceBytes: fullBytes.length,
|
||||
fragmentedLiteralSourceBytes: Buffer.byteLength(long),
|
||||
fullLiteralRecognition: {
|
||||
address: raw.address,
|
||||
state: strings.some((item) => item.address === raw.address)
|
||||
? "observed"
|
||||
: "not_observed",
|
||||
// Linux validates the original through raw bytes; its required decoded
|
||||
// fragment/dossier checks exercise the separate supported-size literal.
|
||||
decodedObject: linux ? "unverified" : "verified",
|
||||
},
|
||||
...objects,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compare native string identities and values with the source fixture's bytes. */
|
||||
export async function verifyHopperStringObjects(call, expectations) {
|
||||
const strings = await call("list_strings");
|
||||
for (const { value, encoding } of expectations) {
|
||||
const item = strings.find((candidate) => candidate.value === value);
|
||||
assert.ok(
|
||||
item,
|
||||
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
|
||||
);
|
||||
assert.equal(item.string.encoding, encoding);
|
||||
assert.equal(item.string.encoding_status, "inferred");
|
||||
assert.equal(item.string.termination, "present_or_not_required");
|
||||
export async function verifyHopperStringObjects(
|
||||
call,
|
||||
expectations,
|
||||
inventory = null,
|
||||
) {
|
||||
// Reuse only the caller's inventory for this unchanged document, never live
|
||||
// state from an earlier session. Address-filter and search checks remain real.
|
||||
const strings = inventory ?? (await call("list_strings"));
|
||||
const recognition = [];
|
||||
for (const {
|
||||
value,
|
||||
encoding,
|
||||
symbol,
|
||||
recognitionOptional = false,
|
||||
} of expectations) {
|
||||
const expectedBytes = Buffer.from(
|
||||
value + "\0",
|
||||
encoding.replaceAll("-", ""),
|
||||
);
|
||||
assert.equal(item.string.byte_length, expectedBytes.length);
|
||||
const bytes = await call("read_bytes", {
|
||||
let sourceBytes;
|
||||
if (symbol !== undefined) {
|
||||
const raw = await call("read_bytes", {
|
||||
address: symbol,
|
||||
length: expectedBytes.length,
|
||||
});
|
||||
sourceBytes = raw;
|
||||
assert.equal(raw.complete, true);
|
||||
assert.equal(raw.bytes_hex, expectedBytes.toString("hex"));
|
||||
}
|
||||
const item = strings.find((candidate) =>
|
||||
sourceBytes === undefined
|
||||
? candidate.value === value
|
||||
: candidate.address === sourceBytes.address,
|
||||
);
|
||||
if (item === undefined && recognitionOptional) {
|
||||
assert.ok(
|
||||
symbol,
|
||||
"optional recognition requires independent source-byte coverage",
|
||||
);
|
||||
recognition.push({
|
||||
value,
|
||||
address: sourceBytes.address,
|
||||
state: "not_observed",
|
||||
decodedObject: "unverified",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
assert.ok(
|
||||
item,
|
||||
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
|
||||
);
|
||||
assert.equal(
|
||||
item.value,
|
||||
value,
|
||||
"native string decoding changed the source value",
|
||||
);
|
||||
recognition.push({
|
||||
value,
|
||||
address: item.address,
|
||||
length: expectedBytes.length,
|
||||
state: "observed",
|
||||
decodedObject: "verified",
|
||||
});
|
||||
assert.equal(item.string.encoding, encoding);
|
||||
assert.equal(item.string.encoding_status, "inferred");
|
||||
assert.equal(item.string.termination, "present_or_not_required");
|
||||
assert.equal(item.string.byte_length, expectedBytes.length);
|
||||
const bytes =
|
||||
sourceBytes ??
|
||||
(await call("read_bytes", {
|
||||
address: item.address,
|
||||
length: expectedBytes.length,
|
||||
}));
|
||||
assert.equal(bytes.complete, true);
|
||||
assert.equal(bytes.bytes_hex, expectedBytes.toString("hex"));
|
||||
assert.deepEqual(await call("list_strings", { address: item.address }), [
|
||||
item,
|
||||
@@ -225,7 +329,7 @@ export async function verifyHopperStringObjects(call, expectations) {
|
||||
);
|
||||
return {
|
||||
sourceBytesVerified: true,
|
||||
inferredEncodingReported: true,
|
||||
recognition,
|
||||
displayEscapesExcludedFromMatching: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { assertCleanLinuxHopperRuntime } from "./lib/clean-linux-hopper-runtime.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const verifierRun = createVerifierRun();
|
||||
@@ -19,12 +20,12 @@ const command = process.env.REA_VERIFY_SERVER_COMMAND;
|
||||
if (process.platform !== "linux" || process.getuid?.() !== 0)
|
||||
throw new Error("Linux setup verification requires a root Linux runner");
|
||||
if (!command) throw new Error("REA_VERIFY_SERVER_COMMAND is required");
|
||||
await assertCleanLinuxHopperRuntime();
|
||||
try {
|
||||
await access("/opt/hopper/bin/Hopper");
|
||||
throw new Error("Linux setup verification requires a clean runner");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("clean runner"))
|
||||
throw error;
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
const runJson = async (args) => {
|
||||
@@ -35,14 +36,25 @@ const runJson = async (args) => {
|
||||
return JSON.parse(stdout);
|
||||
};
|
||||
|
||||
const plan = await runJson(["setup", "--json"]);
|
||||
if (plan.status !== "needs_confirmation")
|
||||
throw new Error("setup did not produce a read-only confirmation plan");
|
||||
const plan = await runJson([
|
||||
"setup",
|
||||
"--install-hopper",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]);
|
||||
if (
|
||||
plan.status !== "planned" ||
|
||||
!plan.plannedActions.some(({ kind }) => kind === "install_hopper") ||
|
||||
plan.appliedActions.length !== 0
|
||||
)
|
||||
throw new Error("setup did not produce a read-only Hopper installation plan");
|
||||
await access("/opt/hopper/bin/Hopper").then(
|
||||
() => {
|
||||
throw new Error("setup plan mutated the clean runner");
|
||||
},
|
||||
() => undefined,
|
||||
(error) => {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
},
|
||||
);
|
||||
|
||||
const installed = await runJson([
|
||||
@@ -58,7 +70,7 @@ if (
|
||||
throw new Error(
|
||||
`approved setup did not become ready: ${JSON.stringify(installed)}`,
|
||||
);
|
||||
const doctor = await runJson(["doctor", "--json"]);
|
||||
const doctor = await runJson(["doctor", "--provider", "hopper", "--json"]);
|
||||
if (doctor.healthy !== true)
|
||||
throw new Error(`doctor is unhealthy: ${JSON.stringify(doctor)}`);
|
||||
|
||||
@@ -67,8 +79,6 @@ await execFileAsync(command, ["analyze", "/usr/bin/true"], {
|
||||
timeout: 180_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
process.env.HOPPER_TARGET_PATH = "/usr/bin/true";
|
||||
process.env.HOPPER_SECOND_TARGET_PATH = "/usr/bin/false";
|
||||
process.env.REA_VERIFY_SERVER_ARGS_JSON = '["mcp"]';
|
||||
await import("./verify-real-hopper-linux.mjs");
|
||||
|
||||
@@ -95,28 +105,24 @@ try {
|
||||
await rm(modifiedDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const repeated = await runJson([
|
||||
"setup",
|
||||
"--yes",
|
||||
"--install-hopper",
|
||||
"--json",
|
||||
]);
|
||||
const repeated = await runJson(["setup", "--yes", "--json"]);
|
||||
if (
|
||||
repeated.status !== "ready" ||
|
||||
repeated.appliedActions.includes("installed_hopper")
|
||||
repeated.plannedActions.length !== 0 ||
|
||||
repeated.appliedActions.length !== 0
|
||||
)
|
||||
throw new Error("a second setup was not idempotent");
|
||||
const { stdout: processes } = await execFileAsync("ps", [
|
||||
"-ax",
|
||||
"-o",
|
||||
"command=",
|
||||
]);
|
||||
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(processes))
|
||||
throw new Error("Linux setup verification leaked a Hopper demo session");
|
||||
await assertCleanLinuxHopperRuntime();
|
||||
const completedVerifierRun = await completeVerifierRun(verifierRun);
|
||||
if (
|
||||
completedVerifierRun.process_lineage.status !== "verified" ||
|
||||
completedVerifierRun.process_lineage.descendants.length !== 0
|
||||
)
|
||||
throw new Error("Linux setup verification did not prove owned child cleanup");
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
verifier_run: await completeVerifierRun(verifierRun),
|
||||
verifier_run: completedVerifierRun,
|
||||
status: "verified",
|
||||
setup: true,
|
||||
cli: true,
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { estimateTokenCount } from "tokenx";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, readdir } from "node:fs/promises";
|
||||
import { readFile, writeFile, readdir, mkdir, rm } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
@@ -91,6 +92,20 @@ const source = pcap(1, [
|
||||
// LINKTYPE_BLUETOOTH_HCI_H4 = 187; H4 command packet, HCI Reset opcode 0x0c03, no parameters.
|
||||
const bluetoothSource = pcap(187, [Buffer.from("01030c00", "hex")]);
|
||||
let cases = 0;
|
||||
const discoveryMeasurements = [];
|
||||
const inheritedAddressSpace = (await readFile("/proc/self/limits", "utf8"))
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Max address space "))
|
||||
?.trim()
|
||||
.match(/^Max address space\s+(\S+)\s+(\S+)\s+bytes$/u);
|
||||
assert.ok(inheritedAddressSpace);
|
||||
const expectedAddressSpace = Math.min(
|
||||
1073741824,
|
||||
...inheritedAddressSpace
|
||||
.slice(1)
|
||||
.map((value) => (value === "unlimited" ? Infinity : Number(value))),
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(path, source);
|
||||
await writeFile(bluetooth, bluetoothSource);
|
||||
@@ -121,14 +136,16 @@ try {
|
||||
.digest("hex"),
|
||||
);
|
||||
assert.equal(report.hardware_acquisition, "not-performed");
|
||||
assert.equal(report.limits.address_space_bytes, 1073741824);
|
||||
assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
|
||||
assert.equal(report.tool.configured_path, process.env.REA_TSHARK_COMMAND);
|
||||
assert.equal(
|
||||
report.resource_limiter.configured_path,
|
||||
process.env.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
|
||||
);
|
||||
assert.ok(
|
||||
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
|
||||
report.resource_limiter.arguments.includes(
|
||||
`--as=${expectedAddressSpace}:${expectedAddressSpace}`,
|
||||
),
|
||||
);
|
||||
assert.equal(report.configuration, "private-empty-personal-profile");
|
||||
const flags = Object.entries(input)
|
||||
@@ -313,6 +330,133 @@ try {
|
||||
true,
|
||||
);
|
||||
cases++;
|
||||
const discovery = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_packet_fields",
|
||||
);
|
||||
assert.ok(discovery?.outputSchema);
|
||||
assert.ok(ajv.validateSchema(discovery.inputSchema));
|
||||
assert.ok(ajv.validateSchema(discovery.outputSchema));
|
||||
const nativeProfile = join(root.path, "native-profile");
|
||||
await mkdir(nativeProfile);
|
||||
const native = (
|
||||
await execute(process.env.REA_TSHARK_COMMAND, ["-G", "fields"], {
|
||||
env: { ...environment, WIRESHARK_CONFIG_DIR: nativeProfile },
|
||||
timeout: 30000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
).stdout;
|
||||
await rm(nativeProfile, { recursive: true });
|
||||
const nativeLines = native.split(/\r?\n/);
|
||||
if (nativeLines.at(-1) === "") nativeLines.pop();
|
||||
for (const input of [
|
||||
{ query: "usb" },
|
||||
{ limit: 1 },
|
||||
{ offset: 1, limit: 2 },
|
||||
{ kind: "fields", protocol: "usb", query: "idVendor" },
|
||||
{ kind: "fields", protocol: "udp", limit: null },
|
||||
{ kind: "fields", query: "rea_no_such_field" },
|
||||
]) {
|
||||
const started = performance.now();
|
||||
const response = await client.callTool({
|
||||
name: discovery.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(
|
||||
ajv.validate(discovery.outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = evidence.normalized_result;
|
||||
const kind = input.kind ?? "protocols";
|
||||
const query = (input.query ?? "").toLowerCase();
|
||||
const expectedMatches = nativeLines.flatMap((line, index) => {
|
||||
const columns = line.split("\t");
|
||||
if ((kind === "protocols") !== (columns[0] === "P")) return [];
|
||||
if (
|
||||
input.protocol !== undefined &&
|
||||
(columns[0] === "P" ? columns[2] : columns[4]) !== input.protocol
|
||||
)
|
||||
return [];
|
||||
if (
|
||||
![
|
||||
columns[1],
|
||||
columns[2],
|
||||
...(columns[0] === "F" ? [columns[7]] : []),
|
||||
].some((value) => value.toLowerCase().includes(query))
|
||||
)
|
||||
return [];
|
||||
return [{ sourceLine: index + 1, columns }];
|
||||
});
|
||||
const offset = input.offset ?? 0;
|
||||
const limit = input.limit === null ? undefined : (input.limit ?? 50);
|
||||
const expectedPage = expectedMatches.slice(
|
||||
offset,
|
||||
limit === undefined ? undefined : offset + limit,
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.records.map((record) => record.source_line),
|
||||
expectedPage.map((record) => record.sourceLine),
|
||||
);
|
||||
assert.deepEqual(report.output_window, {
|
||||
examined: nativeLines.filter((line) =>
|
||||
line.startsWith(kind === "protocols" ? "P\t" : "F\t"),
|
||||
).length,
|
||||
matching: expectedMatches.length,
|
||||
returned: expectedPage.length,
|
||||
has_more:
|
||||
Math.max(0, expectedMatches.length - offset) > expectedPage.length,
|
||||
});
|
||||
discoveryMeasurements.push({
|
||||
input,
|
||||
elapsed_ms: +(performance.now() - started).toFixed(2),
|
||||
response_bytes: Buffer.byteLength(JSON.stringify(response)),
|
||||
estimated_tokens_one_tool_request_response: estimateTokenCount(
|
||||
JSON.stringify(discovery) +
|
||||
JSON.stringify(input) +
|
||||
JSON.stringify(response),
|
||||
),
|
||||
});
|
||||
for (const record of report.records) {
|
||||
const columns = nativeLines[record.source_line - 1].split("\t");
|
||||
assert.equal(record.name, columns[1]);
|
||||
assert.equal(record.abbreviation, columns[2]);
|
||||
assert.equal(record.field_type, columns[0] === "F" ? columns[3] : null);
|
||||
assert.equal(
|
||||
record.parent_protocol,
|
||||
columns[0] === "F" ? columns[4] : null,
|
||||
);
|
||||
assert.equal(record.description, columns[0] === "F" ? columns[7] : null);
|
||||
assert.equal(record.display_base, columns[0] === "F" ? columns[5] : null);
|
||||
assert.equal(record.bitmask, columns[0] === "F" ? columns[6] : null);
|
||||
}
|
||||
if (input.query === "idVendor")
|
||||
assert.ok(
|
||||
report.records.some(
|
||||
(row) =>
|
||||
row.abbreviation === "usb.idVendor" &&
|
||||
row.field_type === "FT_UINT16",
|
||||
),
|
||||
);
|
||||
if (input.query === "usb")
|
||||
assert.ok(report.records.some((row) => row.abbreviation === "usb"));
|
||||
if (input.query === "rea_no_such_field")
|
||||
assert.equal(report.output_window.matching, 0);
|
||||
const flags = Object.entries(input).flatMap(([key, value]) =>
|
||||
value === null ? ["--all"] : [`--${key}`, String(value)],
|
||||
);
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-packet-fields", ...flags, "--format", "json"],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.deepEqual(cli.normalized_result, report);
|
||||
cases++;
|
||||
}
|
||||
await client.close();
|
||||
assert.deepEqual((await readdir(root.path)).sort(), [
|
||||
"bluetooth.pcap",
|
||||
@@ -329,6 +473,9 @@ console.log(
|
||||
{
|
||||
status: "passed",
|
||||
cases,
|
||||
discovery_measurements: discoveryMeasurements,
|
||||
measurement_scope:
|
||||
"Warm MCP server, fresh TShark processes; tokenx estimates include one advertised tool/request/response, not billed usage. Model-driven selection, CPU and peak process-family memory are unmeasured; no cache/speedup claim.",
|
||||
host: `${process.platform}/${process.arch}`,
|
||||
scope:
|
||||
"real TShark CLI/MCP; synthetic Ethernet/IPv4/UDP/DNS and Bluetooth HCI PCAP; no hardware acquisition",
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -11,7 +11,8 @@ createVerifierRun();
|
||||
|
||||
if (process.platform !== "linux")
|
||||
throw new Error("Linux real-Hopper verification must run on Linux");
|
||||
const launcher = process.env.HOPPER_LAUNCHER_PATH ?? "/opt/hopper/bin/Hopper";
|
||||
const configuredLauncher = process.env.HOPPER_LAUNCHER_PATH;
|
||||
const launcher = configuredLauncher ?? "/opt/hopper/bin/Hopper";
|
||||
await access(launcher);
|
||||
await access("/usr/bin/Xvfb");
|
||||
await access("/usr/bin/xauth");
|
||||
@@ -28,5 +29,7 @@ try {
|
||||
process.env.HOPPER_LAUNCHER_PATH = alternateLauncher;
|
||||
await import("./verify-real-hopper.mjs");
|
||||
} finally {
|
||||
if (configuredLauncher === undefined) delete process.env.HOPPER_LAUNCHER_PATH;
|
||||
else process.env.HOPPER_LAUNCHER_PATH = configuredLauncher;
|
||||
await rm(alternateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { estimateTokenCount } from "tokenx";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, readdir } from "node:fs/promises";
|
||||
@@ -68,6 +69,20 @@ for (const byte of Buffer.from("REA")) {
|
||||
lines.push(`#${tick + 1000}`, "1!");
|
||||
const source = lines.join("\n") + "\n";
|
||||
let cases = 0;
|
||||
const discoveryMeasurements = [];
|
||||
const inheritedAddressSpace = (await readFile("/proc/self/limits", "utf8"))
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Max address space "))
|
||||
?.trim()
|
||||
.match(/^Max address space\s+(\S+)\s+(\S+)\s+bytes$/u);
|
||||
assert.ok(inheritedAddressSpace);
|
||||
const expectedAddressSpace = Math.min(
|
||||
1073741824,
|
||||
...inheritedAddressSpace
|
||||
.slice(1)
|
||||
.map((value) => (value === "unlimited" ? Infinity : Number(value))),
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(path, source);
|
||||
await client.connect(transport);
|
||||
@@ -104,14 +119,16 @@ try {
|
||||
createHash("sha256").update(source).digest("hex"),
|
||||
);
|
||||
assert.equal(report.hardware_acquisition, "not-performed");
|
||||
assert.equal(report.limits.address_space_bytes, 1073741824);
|
||||
assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
|
||||
assert.equal(report.tool.configured_path, process.env.REA_SIGROK_COMMAND);
|
||||
assert.equal(
|
||||
report.resource_limiter.configured_path,
|
||||
process.env.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
|
||||
);
|
||||
assert.ok(
|
||||
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
|
||||
report.resource_limiter.arguments.includes(
|
||||
`--as=${expectedAddressSpace}:${expectedAddressSpace}`,
|
||||
),
|
||||
);
|
||||
assert.equal(report.diagnostics, "");
|
||||
const flags = Object.entries(input)
|
||||
@@ -245,6 +262,88 @@ try {
|
||||
});
|
||||
assert.equal(invalid.isError, true);
|
||||
cases++;
|
||||
const discoveryTool = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_signal_decoders",
|
||||
);
|
||||
assert.ok(discoveryTool?.outputSchema);
|
||||
assert.ok(ajv.validateSchema(discoveryTool.inputSchema));
|
||||
assert.ok(ajv.validateSchema(discoveryTool.outputSchema));
|
||||
const nativeHelp = (
|
||||
await execute(
|
||||
process.env.REA_SIGROK_COMMAND,
|
||||
["--dont-scan", "--protocol-decoders=uart", "--show"],
|
||||
{ env: environment, timeout: 30000, maxBuffer: 4 * 1024 * 1024 },
|
||||
)
|
||||
).stdout.split(/\r?\n/);
|
||||
if (nativeHelp.at(-1) === "") nativeHelp.pop();
|
||||
for (const input of [
|
||||
{ query: "uart" },
|
||||
{ limit: 1 },
|
||||
{ offset: 1, limit: 2 },
|
||||
{ decoder: "uart", query: "BaUd" },
|
||||
{ decoder: "uart", offset: 50, limit: null },
|
||||
{ query: "rea_no_such_decoder" },
|
||||
]) {
|
||||
const started = performance.now();
|
||||
const response = await client.callTool({
|
||||
name: discoveryTool.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(
|
||||
ajv.validate(discoveryTool.outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = evidence.normalized_result;
|
||||
discoveryMeasurements.push({
|
||||
input,
|
||||
elapsed_ms: +(performance.now() - started).toFixed(2),
|
||||
response_bytes: Buffer.byteLength(JSON.stringify(response)),
|
||||
estimated_tokens_one_tool_request_response: estimateTokenCount(
|
||||
JSON.stringify(discoveryTool) +
|
||||
JSON.stringify(input) +
|
||||
JSON.stringify(response),
|
||||
),
|
||||
});
|
||||
if (input.decoder) {
|
||||
const matched = nativeHelp
|
||||
.map((text, i) => ({ line_number: i + 1, text }))
|
||||
.filter(({ text }) =>
|
||||
text.toLowerCase().includes((input.query ?? "").toLowerCase()),
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.help_lines,
|
||||
matched.slice(
|
||||
input.offset ?? 0,
|
||||
input.limit === null
|
||||
? undefined
|
||||
: (input.offset ?? 0) + (input.limit ?? 50),
|
||||
),
|
||||
);
|
||||
} else if (input.query === "uart")
|
||||
assert.ok(report.decoders.some(({ id }) => id === "uart"));
|
||||
const flags = Object.entries(input).flatMap(([key, value]) =>
|
||||
value === null ? ["--all"] : [`--${key}`, String(value)],
|
||||
);
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-signal-decoders", ...flags, "--format", "json"],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 8 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.deepEqual(cli.normalized_result, report);
|
||||
cases++;
|
||||
}
|
||||
const unavailable = await client.callTool({
|
||||
name: discoveryTool.name,
|
||||
arguments: { decoder: "rea_no_such_decoder" },
|
||||
});
|
||||
assert.equal(unavailable.isError, true);
|
||||
cases++;
|
||||
await client.close();
|
||||
assert.deepEqual((await readdir(root.path)).sort(), [
|
||||
"paged.vcd",
|
||||
@@ -260,6 +359,9 @@ console.log(
|
||||
{
|
||||
status: "passed",
|
||||
cases,
|
||||
discovery_measurements: discoveryMeasurements,
|
||||
measurement_scope:
|
||||
"Warm MCP server, fresh sigrok processes; tokenx estimates include one advertised tool/request/response, not billed model usage. No model-driven selection benchmark, CPU or peak process-family memory measurement; no cache or speedup claim.",
|
||||
host: `${process.platform}/${process.arch}`,
|
||||
scope:
|
||||
"real sigrok UART VCD through compiled CLI/MCP; no physical acquisition",
|
||||
|
||||
@@ -662,6 +662,42 @@ setInterval(()=>{},1000);\n`,
|
||||
unicodeNamesPreserved: true,
|
||||
});
|
||||
report.processes.windowsEnvironmentSemantics = true;
|
||||
// Observed through this process's own id: a separate probe process would
|
||||
// get its own processor group, so it cannot stand in for the process that
|
||||
// spawns the child.
|
||||
const callerAffinity = JSON.parse(
|
||||
execFileSync(boundaryFixture, ["affinity", String(process.pid)], {
|
||||
encoding: "utf8",
|
||||
}),
|
||||
);
|
||||
const affinityChild = native.call("process_spawn", [
|
||||
boundaryFixture,
|
||||
`${quote(boundaryFixture)} affinity`,
|
||||
workspace,
|
||||
environment,
|
||||
]);
|
||||
const observedAffinity = await wait(affinityChild);
|
||||
native.call("process_close", [affinityChild.handle]);
|
||||
assert.equal(observedAffinity.exitCode, 0);
|
||||
const childAffinity = JSON.parse(observedAffinity.output);
|
||||
// Masks cross as hexadecimal strings because a mask can exceed the
|
||||
// safe-integer range. Sharing one processor group makes the caller's mask
|
||||
// applicable, so it must be inherited exactly. Windows placing the child in
|
||||
// another group is only permitted when the caller asked for no bound of its
|
||||
// own, which its group's whole active set establishes; a narrower mask has
|
||||
// to fail the spawn rather than widen to that group's default.
|
||||
const sharedGroup =
|
||||
callerAffinity.singleProcessorGroup &&
|
||||
childAffinity.singleProcessorGroup &&
|
||||
callerAffinity.processorGroup === childAffinity.processorGroup;
|
||||
if (sharedGroup)
|
||||
assert.equal(childAffinity.affinityMask, callerAffinity.affinityMask);
|
||||
else assert.equal(callerAffinity.affinityMask, callerAffinity.systemMask);
|
||||
report.processes.callerAffinityBoundsOwnedProcessOrRefusesToWiden = true;
|
||||
report.callerAffinity = {
|
||||
...callerAffinity,
|
||||
sharedGroupWithChild: sharedGroup,
|
||||
};
|
||||
}
|
||||
const ownerFixture = join(workspace, "owner.mjs");
|
||||
const ownerPidFile = join(workspace, "crash-child.pid");
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -84,6 +84,24 @@ describe("parseAdbDevicesOutput", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts complete multiword states and rejects state prefixes", () => {
|
||||
const output = [
|
||||
"serial-no-permission no permissions usb:1-1",
|
||||
"serial-bad-device deviceXYZ ",
|
||||
"serial-bad-offline offline-ish",
|
||||
"serial-bad-auth unauthorizedXYZ",
|
||||
].join("\n");
|
||||
const { devices, unparsedLines } = parseAdbDevicesOutput(output);
|
||||
expect(devices.map(({ serial, state }) => [serial, state])).toEqual([
|
||||
["serial-no-permission", "no permissions"],
|
||||
]);
|
||||
expect(unparsedLines).toEqual([
|
||||
"serial-bad-device deviceXYZ ",
|
||||
"serial-bad-offline offline-ish",
|
||||
"serial-bad-auth unauthorizedXYZ",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns unrecognized lines instead of dropping them", () => {
|
||||
const output = [
|
||||
"List of devices attached",
|
||||
@@ -194,10 +212,10 @@ describe("parsePackageListOutput", () => {
|
||||
|
||||
it("keeps unrecognized lines for partial coverage reporting", () => {
|
||||
const { packages, unparsedLines } = parsePackageListOutput(
|
||||
"package:/data/app/x/base.apk=com.ok\ngarbage line\n",
|
||||
"package:/data/app/x/base.apk=com.ok\n garbage line \n",
|
||||
);
|
||||
expect(packages).toHaveLength(1);
|
||||
expect(unparsedLines).toEqual(["garbage line"]);
|
||||
expect(unparsedLines).toEqual([" garbage line "]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+14
-25
@@ -10,11 +10,15 @@ import {
|
||||
adbHostFileName,
|
||||
type AdbHostFileNameReason,
|
||||
} from "./AdbHostFileName.js";
|
||||
import {
|
||||
ADB_DEVICE_STATES,
|
||||
type AdbDeviceState,
|
||||
} from "../domain/adb/adbDeviceAnalysis.js";
|
||||
|
||||
/** One device exactly as `adb devices -l` reports it. */
|
||||
export interface AdbDeviceEntry {
|
||||
readonly serial: string;
|
||||
readonly state: string;
|
||||
readonly state: AdbDeviceState;
|
||||
readonly transport: "usb" | "tcp" | "unknown";
|
||||
readonly product: string | null;
|
||||
readonly model: string | null;
|
||||
@@ -24,25 +28,6 @@ export interface AdbDeviceEntry {
|
||||
readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Device states adb prints after the serial, longest first so two-word
|
||||
* states match before their single-word prefixes. A line whose remainder
|
||||
* starts with none of them is not a device line.
|
||||
*/
|
||||
const DEVICE_STATES = [
|
||||
"no permissions",
|
||||
"unauthorized",
|
||||
"authorizing",
|
||||
"offline",
|
||||
"recovery",
|
||||
"sideload",
|
||||
"connecting",
|
||||
"bootloader",
|
||||
"device",
|
||||
"unknown",
|
||||
"host",
|
||||
] as const;
|
||||
|
||||
const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
|
||||
|
||||
const metadataValue = (remainder: string, key: string): string | null => {
|
||||
@@ -73,11 +58,15 @@ export const parseAdbDevicesOutput = (
|
||||
continue;
|
||||
}
|
||||
const match = DEVICE_LINE.exec(line);
|
||||
const state = DEVICE_STATES.find((candidate) =>
|
||||
match?.[2]?.startsWith(candidate),
|
||||
);
|
||||
const state = ADB_DEVICE_STATES.find((candidate) => {
|
||||
const remainder = match?.[2];
|
||||
if (remainder === undefined || !remainder.startsWith(candidate))
|
||||
return false;
|
||||
const next = remainder[candidate.length];
|
||||
return next === undefined || /\s/u.test(next);
|
||||
});
|
||||
if (match === null || state === undefined) {
|
||||
if (line.trim() !== "") unparsedLines.push(line);
|
||||
if (rawLine.trim() !== "") unparsedLines.push(rawLine);
|
||||
continue;
|
||||
}
|
||||
const serial = match[1]!;
|
||||
@@ -192,7 +181,7 @@ export const parsePackageListOutput = (
|
||||
if (line === "") continue;
|
||||
const match = PACKAGE_LINE.exec(line);
|
||||
if (match === null) {
|
||||
unparsedLines.push(line);
|
||||
unparsedLines.push(rawLine);
|
||||
continue;
|
||||
}
|
||||
packages.push({
|
||||
|
||||
@@ -121,6 +121,52 @@ fi`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses device listings with an invalid state token", async () => {
|
||||
const malformedLine = "serial deviceXYZ ";
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "devices" ]; then
|
||||
printf 'List of devices attached\\n${malformedLine}\\n'
|
||||
exit 0
|
||||
fi`,
|
||||
);
|
||||
const result = await providerOver(binary).execute({
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("AnalysisProtocolError");
|
||||
expect(result.error.message).toContain(malformedLine);
|
||||
});
|
||||
|
||||
it("lists rescue and detached transports alongside online devices", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "devices" ]; then
|
||||
printf 'List of devices attached\\n'
|
||||
printf 'serial-online device transport_id:1\\n'
|
||||
printf 'serial-rescue rescue transport_id:2\\n'
|
||||
printf 'serial-detached detached transport_id:3\\n'
|
||||
exit 0
|
||||
fi`,
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
});
|
||||
expect(result.devices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ serial: "serial-online", state: "device" }),
|
||||
expect.objectContaining({ serial: "serial-rescue", state: "rescue" }),
|
||||
expect.objectContaining({
|
||||
serial: "serial-detached",
|
||||
state: "detached",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("inspects a device through the getprop whitelist", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
@@ -580,6 +626,34 @@ it("projects package details from dumpsys package output", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Android 10 and 11 append ": restricted=true" to hard- and soft-restricted
|
||||
// entries of the requested-permissions list (Settings.dumpPackageLPr).
|
||||
it("counts requested permissions that the device marks as restricted", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
"dumpsys:package": `Packages:
|
||||
Package [com.example] (abcdef):
|
||||
userId=10234
|
||||
versionName=1.2.3
|
||||
requested permissions:
|
||||
android.permission.INTERNET
|
||||
android.permission.WRITE_EXTERNAL_STORAGE: restricted=true
|
||||
android.permission.CAMERA
|
||||
install permissions:
|
||||
android.permission.INTERNET: granted=true
|
||||
`,
|
||||
}),
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "inspect_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.example" },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
requested_permissions_count: 3,
|
||||
coverage: "complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an uninstalled package from dumpsys refusal output", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({ "dumpsys:package": "Unable to find package: com.absent\n" }),
|
||||
|
||||
@@ -236,7 +236,11 @@ export const parseDumpsysPackageOutput = (
|
||||
inPermissions = true;
|
||||
continue;
|
||||
}
|
||||
if (inPermissions && /^\s{2,}[A-Za-z0-9._]+$/.test(line)) {
|
||||
// Android 10 and 11 mark restricted entries as "NAME: restricted=true".
|
||||
if (
|
||||
inPermissions &&
|
||||
/^\s{2,}[A-Za-z0-9._]+(?:: restricted=true)?$/u.test(line)
|
||||
) {
|
||||
permissions += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -199,9 +199,11 @@ export class JadxMcpTransport implements Transport {
|
||||
throw new Error(`JADX MCP frame exceeds ${MAX_FRAME_BYTES} bytes`);
|
||||
this.#appendFrame(fragment, frameBytes);
|
||||
if (newline === -1) return;
|
||||
const line = this.#frameBuffer
|
||||
.subarray(0, this.#frameLength)
|
||||
.toString("utf8");
|
||||
const line = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
// Preserve a leading BOM so the JSON-RPC parser can reject it.
|
||||
ignoreBOM: true,
|
||||
}).decode(this.#frameBuffer.subarray(0, this.#frameLength));
|
||||
this.#frameBuffer = Buffer.alloc(0);
|
||||
this.#frameLength = 0;
|
||||
this.onmessage?.(deserializeMessage(line));
|
||||
|
||||
@@ -397,7 +397,7 @@ const workflowAvailabilityFor = ({
|
||||
remediation:
|
||||
"On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
|
||||
};
|
||||
if (name === "inspect_packet_capture")
|
||||
if (name === "inspect_packet_capture" || name === "inspect_packet_fields")
|
||||
return policy.packetCaptureEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
@@ -405,7 +405,7 @@ const workflowAvailabilityFor = ({
|
||||
remediation:
|
||||
"On Linux, supply TShark 4.x via absolute REA_TSHARK_COMMAND and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.",
|
||||
};
|
||||
if (name === "decode_signal_recording")
|
||||
if (name === "decode_signal_recording" || name === "inspect_signal_decoders")
|
||||
return policy.signalRecordingEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -78,7 +78,9 @@ export const readJsonObjectFile = (
|
||||
value === null ||
|
||||
Array.isArray(value)
|
||||
)
|
||||
throw new TypeError("JSON document root must be an object");
|
||||
return err(
|
||||
invalidJson(operation, "JSON document root must be an object"),
|
||||
);
|
||||
for (const members of scans)
|
||||
for (const [key, member] of members) {
|
||||
if (!Object.hasOwn(value, key)) continue;
|
||||
@@ -177,7 +179,9 @@ export const readJsonObjectFile = (
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
if (!rootObject)
|
||||
throw new TypeError("JSON document root must be an object");
|
||||
return err(
|
||||
invalidJson(operation, "JSON document root must be an object"),
|
||||
);
|
||||
const digest = hash.digest("hex");
|
||||
if (previousDigest !== undefined && digest !== previousDigest)
|
||||
throw new RegularFileChangedError(path);
|
||||
|
||||
@@ -2,12 +2,16 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { installMacHopper, type MacHopperInstallHost } from "./MacHopper.js";
|
||||
import {
|
||||
installMacHopper,
|
||||
type MacHopperBundleVerification,
|
||||
type MacHopperInstallHost,
|
||||
} from "./MacHopper.js";
|
||||
|
||||
class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
readonly archive = new TextEncoder().encode("hopper-dmg");
|
||||
archiveOk = true;
|
||||
valid = true;
|
||||
verification: MacHopperBundleVerification = "verified";
|
||||
existing = false;
|
||||
mountSucceeds = true;
|
||||
copySucceeds = true;
|
||||
@@ -15,6 +19,7 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
badHash = false;
|
||||
unmounts = 0;
|
||||
cleanups = 0;
|
||||
installs = 0;
|
||||
opens = 0;
|
||||
replaces = false;
|
||||
|
||||
@@ -47,7 +52,7 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
return Promise.resolve();
|
||||
};
|
||||
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
|
||||
validBundle = () => Promise.resolve(this.valid);
|
||||
validBundle = () => Promise.resolve(this.verification);
|
||||
destinationExists = () => Promise.resolve(this.existing);
|
||||
installBundle = (
|
||||
_source: string,
|
||||
@@ -55,6 +60,7 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
_stage: string,
|
||||
replaceExisting: boolean,
|
||||
) => {
|
||||
this.installs += 1;
|
||||
this.replaces = replaceExisting;
|
||||
return Promise.resolve(this.copySucceeds);
|
||||
};
|
||||
@@ -115,7 +121,7 @@ describe("macOS Hopper installation", () => {
|
||||
|
||||
it("cleans and detaches after bundle validation fails", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
host.valid = false;
|
||||
host.verification = "invalid_signature";
|
||||
await expect(installMacHopper({}, host)).resolves.toEqual({
|
||||
status: "failed",
|
||||
reason: "bundle",
|
||||
@@ -123,4 +129,17 @@ describe("macOS Hopper installation", () => {
|
||||
expect(host.unmounts).toBe(1);
|
||||
expect(host.cleanups).toBe(1);
|
||||
});
|
||||
|
||||
it("reports a publisher mismatch without copying or launching the bundle", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
host.verification = "unverified_publisher";
|
||||
await expect(installMacHopper({}, host)).resolves.toEqual({
|
||||
status: "failed",
|
||||
reason: "publisher_verification_failed",
|
||||
});
|
||||
expect(host.installs).toBe(0);
|
||||
expect(host.opens).toBe(0);
|
||||
expect(host.unmounts).toBe(1);
|
||||
expect(host.cleanups).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,35 @@ const execFileAsync = promisify(execFile);
|
||||
const RELEASES_URL =
|
||||
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
|
||||
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
||||
const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
|
||||
|
||||
/**
|
||||
* Apple publisher identity pinned for the official Hopper macOS bundle. The
|
||||
* team identifier was captured from `Hopper-6.6.0-demo.dmg` at
|
||||
* `https://www.hopperapp.com:443/downloader/public/Hopper-6.6.0-demo.dmg`
|
||||
* (34846271 bytes, SHA-1 0be530f038249742590992199c7b042ea20ca955) with
|
||||
* `codesign -dvvv`, which reported
|
||||
* `Authority=Developer ID Application: Cryptic Apps (2AMA2753NF)`.
|
||||
*/
|
||||
export const HOPPER_PUBLISHER = Object.freeze({
|
||||
bundleIdentifier: "com.cryptic-apps.hopper-web-4",
|
||||
teamIdentifier: "2AMA2753NF",
|
||||
});
|
||||
|
||||
/**
|
||||
* Code requirement enforcing the bundle identifier and signing team together.
|
||||
* The identifier is read from the sealed CodeDirectory, so it cannot be changed
|
||||
* by editing `Info.plist` the way the previous string comparison could.
|
||||
*/
|
||||
const HOPPER_PUBLISHER_REQUIREMENT =
|
||||
`= identifier "${HOPPER_PUBLISHER.bundleIdentifier}"` +
|
||||
` and anchor apple generic` +
|
||||
` and certificate leaf[subject.OU] = "${HOPPER_PUBLISHER.teamIdentifier}"`;
|
||||
|
||||
/** Outcome of reading a mounted bundle's signature and publisher identity. */
|
||||
export type MacHopperBundleVerification =
|
||||
| "verified"
|
||||
| "invalid_signature"
|
||||
| "unverified_publisher";
|
||||
|
||||
/** Stable network and command effects disclosed before macOS Hopper setup. */
|
||||
export const macHopperInstallDisclosure = {
|
||||
@@ -32,6 +60,7 @@ export const macHopperInstallDisclosure = {
|
||||
commands: [
|
||||
"hdiutil attach -readonly -nobrowse -mountpoint <private-mount> <verified-hopper.dmg>",
|
||||
"codesign --verify --deep --strict <mounted-hopper.app>",
|
||||
`codesign --verify --strict '-R${HOPPER_PUBLISHER_REQUIREMENT}' <mounted-hopper.app>`,
|
||||
"ditto <mounted-hopper.app> <user-Applications-stage>",
|
||||
"open <installed-hopper.app>",
|
||||
] as const,
|
||||
@@ -52,6 +81,7 @@ export type MacHopperInstallResult =
|
||||
| "integrity"
|
||||
| "mount"
|
||||
| "bundle"
|
||||
| "publisher_verification_failed"
|
||||
| "destination_exists"
|
||||
| "copy"
|
||||
| "launcher_missing";
|
||||
@@ -69,7 +99,7 @@ export interface MacHopperInstallHost {
|
||||
mount(packagePath: string, mountPath: string): Promise<boolean>;
|
||||
unmount(mountPath: string): Promise<void>;
|
||||
appBundle(mountPath: string): Promise<string | undefined>;
|
||||
validBundle(appPath: string): Promise<boolean>;
|
||||
validBundle(appPath: string): Promise<MacHopperBundleVerification>;
|
||||
destinationExists(path: string): Promise<boolean>;
|
||||
installBundle(
|
||||
source: string,
|
||||
@@ -124,8 +154,12 @@ export const installMacHopper = async (
|
||||
return { status: "failed", reason: "mount" };
|
||||
mounted = true;
|
||||
const source = await host.appBundle(mountPath);
|
||||
if (source === undefined || !(await host.validBundle(source)))
|
||||
if (source === undefined) return { status: "failed", reason: "bundle" };
|
||||
const verification = await host.validBundle(source);
|
||||
if (verification === "invalid_signature")
|
||||
return { status: "failed", reason: "bundle" };
|
||||
if (verification === "unverified_publisher")
|
||||
return { status: "failed", reason: "publisher_verification_failed" };
|
||||
const stage = `${destination}.rea-stage`;
|
||||
if (
|
||||
!(await host.installBundle(
|
||||
@@ -222,25 +256,21 @@ export const systemMacHopperInstallHost = (
|
||||
environment,
|
||||
))
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const identifier = (
|
||||
await execFileAsync(
|
||||
"/usr/libexec/PlistBuddy",
|
||||
[
|
||||
"-c",
|
||||
"Print :CFBundleIdentifier",
|
||||
join(appPath, "Contents/Info.plist"),
|
||||
],
|
||||
{ env: environment },
|
||||
)
|
||||
).stdout.trim();
|
||||
return identifier === BUNDLE_IDENTIFIER;
|
||||
} catch (cause: unknown) {
|
||||
// best-effort cleanup: optional bundle probing; unreadable means invalid.
|
||||
void cause;
|
||||
return false;
|
||||
}
|
||||
return "invalid_signature";
|
||||
if (
|
||||
!(await commandSucceeds(
|
||||
"codesign",
|
||||
[
|
||||
"--verify",
|
||||
"--strict",
|
||||
`-R${HOPPER_PUBLISHER_REQUIREMENT}`,
|
||||
appPath,
|
||||
],
|
||||
environment,
|
||||
))
|
||||
)
|
||||
return "unverified_publisher";
|
||||
return "verified";
|
||||
},
|
||||
async destinationExists(path) {
|
||||
try {
|
||||
|
||||
@@ -23,7 +23,7 @@ export const createProgressReporter = (
|
||||
} = {},
|
||||
): ProgressReporter => {
|
||||
const minimumIntervalMs = options.minimumIntervalMs ?? 100;
|
||||
const now = options.now ?? Date.now;
|
||||
const now = options.now ?? (() => performance.now());
|
||||
let lastCompleted = -1;
|
||||
let lastSentAt = Number.NEGATIVE_INFINITY;
|
||||
let sequence = 0;
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
detectReferenceSourceLanguage,
|
||||
} from "../domain/referenceSourceClassification.js";
|
||||
import type { HistoricalSourceGraphInput } from "../domain/referenceSourceGraph.js";
|
||||
import { parseReferenceSourceImports } from "../domain/referenceSourceImportParsing.js";
|
||||
import {
|
||||
parseReferenceSourceImports,
|
||||
PARSEABLE_REFERENCE_SOURCE_LANGUAGES,
|
||||
} from "../domain/referenceSourceImportParsing.js";
|
||||
import type {
|
||||
ReferenceSourceEntry,
|
||||
ReferenceSourceRead,
|
||||
} from "../reference/ReferenceSourceReaderTypes.js";
|
||||
import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
|
||||
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
|
||||
|
||||
export interface ParsedReferenceSourceEntries {
|
||||
@@ -102,7 +104,11 @@ const resolveInternalSpecifier = (
|
||||
]);
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
candidates.push(...typescriptEmittedSourceCandidates(fromPath, normalized));
|
||||
candidates.push(
|
||||
...typescriptEmittedSourceCandidates(fromPath, normalized).map(
|
||||
({ path }) => path,
|
||||
),
|
||||
);
|
||||
const match = lookupPath.resolveCandidates(candidates);
|
||||
return match === undefined
|
||||
? { to: normalized, resolution: "unresolved" }
|
||||
|
||||
@@ -87,6 +87,3 @@ export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [
|
||||
".coverage",
|
||||
"*.log",
|
||||
] as const;
|
||||
|
||||
export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES: ReadonlySet<string> =
|
||||
new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
|
||||
|
||||
@@ -19,6 +19,17 @@ export interface RegularFileReadOptions {
|
||||
readonly resources?: ArtifactResourceScope;
|
||||
}
|
||||
|
||||
export class InvalidUtf8FileError extends TypeError {
|
||||
readonly _tag = "InvalidUtf8FileError";
|
||||
constructor(
|
||||
readonly path: string,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(`File is not valid UTF-8: ${path}`, { cause });
|
||||
this.name = this._tag;
|
||||
}
|
||||
}
|
||||
|
||||
export type RegularFileReadOutcome<Value> =
|
||||
| { readonly kind: "completed"; readonly value: Value }
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
@@ -142,7 +153,34 @@ const withRegularFileOwned = async <Value>(
|
||||
export const readRegularFile = (
|
||||
path: string,
|
||||
options: RegularFileReadOptions = {},
|
||||
): Promise<Buffer> =>
|
||||
): Promise<Buffer> => readRegularFileContent(path, (bytes) => bytes, options);
|
||||
|
||||
/** Read valid UTF-8 without replacing source bytes or stripping an initial BOM. */
|
||||
export const readRegularFileText = (
|
||||
path: string,
|
||||
options: RegularFileReadOptions = {},
|
||||
): Promise<string> =>
|
||||
readRegularFileContent(
|
||||
path,
|
||||
(bytes) => {
|
||||
try {
|
||||
return new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
ignoreBOM: true,
|
||||
}).decode(bytes);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof TypeError)) throw cause;
|
||||
throw new InvalidUtf8FileError(path, cause);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const readRegularFileContent = <Value>(
|
||||
path: string,
|
||||
decode: (bytes: Buffer) => Value,
|
||||
options: RegularFileReadOptions,
|
||||
): Promise<Value> =>
|
||||
withRegularFile(
|
||||
path,
|
||||
async (handle, stats) => {
|
||||
@@ -153,30 +191,7 @@ export const readRegularFile = (
|
||||
);
|
||||
if (bytes === undefined || bytes.length !== stats.size)
|
||||
throw new RegularFileChangedError(path);
|
||||
return bytes;
|
||||
return decode(bytes);
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
/** Read one admitted regular file as UTF-8 without waiting on a pipe. */
|
||||
export const readRegularFileText = async (
|
||||
path: string,
|
||||
options: RegularFileReadOptions = {},
|
||||
): Promise<string> => {
|
||||
try {
|
||||
return (await readRegularFile(path, options)).toString("utf8");
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure &&
|
||||
cause.outcome.kind === "completed" &&
|
||||
Buffer.isBuffer(cause.outcome.value)
|
||||
)
|
||||
throw new RegularFileCleanupFailure(
|
||||
cause.path,
|
||||
cause.owner,
|
||||
{ kind: "completed", value: cause.outcome.value.toString("utf8") },
|
||||
cause.cleanupCause,
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,6 +54,40 @@ describe("setup workflow", () => {
|
||||
expect(result.remediation).toContain("could not be updated");
|
||||
});
|
||||
|
||||
it("reports a failed config rollback with its destination and recovery backup", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [{ name: "codex", configPath: "/fixture/codex.json" }];
|
||||
host.clientResults.set("codex", {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
backupPath: "/fixture/codex.json.rea.backup",
|
||||
failure: {
|
||||
kind: "restore",
|
||||
path: "/fixture/codex.json",
|
||||
detail: "permission denied",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(
|
||||
{ ...options(true), clientIds: ["codex"] },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(result.clients.codex).toEqual({
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
backupPath: "/fixture/codex.json.rea.backup",
|
||||
failure: {
|
||||
kind: "restore",
|
||||
path: "/fixture/codex.json",
|
||||
detail: "permission denied",
|
||||
},
|
||||
});
|
||||
expect(result.remediation).toContain("permission denied");
|
||||
expect(result.remediation).toContain("/fixture/codex.json.rea.backup");
|
||||
});
|
||||
|
||||
it("explains skill installation recovery", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
@@ -29,6 +30,7 @@ import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import {
|
||||
readRegularFile,
|
||||
readRegularFileText,
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
@@ -40,6 +42,26 @@ import type {
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanupFailure<string> {
|
||||
constructor(
|
||||
failure: RegularFileCleanupFailure<string>,
|
||||
readonly configurationFailure: Extract<
|
||||
ClientConfigurationResult,
|
||||
{ readonly status: "failed"; readonly reason: "rollback" }
|
||||
>,
|
||||
) {
|
||||
super(
|
||||
failure.path,
|
||||
failure.owner,
|
||||
failure.outcome,
|
||||
new AggregateError(
|
||||
[failure.cleanupCause, new Error(configurationFailure.failure.detail)],
|
||||
`Configuration readback cleanup and rollback failed: ${configurationFailure.failure.path}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Configure one supported client's native stdio MCP registration shape. */
|
||||
export const configureClientConfiguration = (
|
||||
client: SetupClient,
|
||||
@@ -167,11 +189,23 @@ const persistClientConfiguration = async ({
|
||||
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
|
||||
const backupPath =
|
||||
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
||||
if (
|
||||
backupPath !== undefined &&
|
||||
!(await preserveConfigBackup(transactionPath, backupPath))
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
let recoveryBackupPath: string | undefined;
|
||||
if (backupPath !== undefined && original !== undefined) {
|
||||
const selection = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (!selection.ok) return { status: "failed", reason: "backup" };
|
||||
if (
|
||||
!(await preserveConfigBackup(transactionPath, selection.value.backupPath))
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
const verified = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (
|
||||
!verified.ok ||
|
||||
verified.value.backupPath !== selection.value.backupPath ||
|
||||
!verified.value.present
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
recoveryBackupPath = verified.value.backupPath;
|
||||
}
|
||||
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
|
||||
// otherwise conflict with it.
|
||||
const legacyPath = legacyClientServerPath(
|
||||
@@ -239,19 +273,39 @@ const persistClientConfiguration = async ({
|
||||
!clientConfigurationValuesEqual(readback.servers[name], entry),
|
||||
)
|
||||
) {
|
||||
await restoreConfig(transactionPath, original);
|
||||
return { status: "failed", reason: "readback" };
|
||||
return rollbackOrReadbackFailure(
|
||||
transactionPath,
|
||||
original,
|
||||
recoveryBackupPath,
|
||||
);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (cause instanceof RegularFileCleanupFailure) {
|
||||
const rollback = await restoreConfig(transactionPath, original);
|
||||
if (!rollback.ok)
|
||||
throw new ClientConfigurationCleanupRollbackFailure(cause, {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
...(recoveryBackupPath === undefined
|
||||
? {}
|
||||
: { backupPath: recoveryBackupPath }),
|
||||
failure: rollback.error,
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
// Readback failure restores the transaction before reporting.
|
||||
void cause;
|
||||
await restoreConfig(transactionPath, original);
|
||||
return { status: "failed", reason: "readback" };
|
||||
return rollbackOrReadbackFailure(
|
||||
transactionPath,
|
||||
original,
|
||||
recoveryBackupPath,
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: "configured",
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
...(recoveryBackupPath === undefined
|
||||
? {}
|
||||
: { backupPath: recoveryBackupPath }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -406,9 +460,18 @@ export const inspectClientConfiguration = async (
|
||||
"The existing configuration is malformed. Repair it before setup applies any other changes.",
|
||||
};
|
||||
}
|
||||
const backupSelection = await selectCurrentRecoveryBackup(
|
||||
`${client.configPath}.rea.backup`,
|
||||
original,
|
||||
);
|
||||
if (!backupSelection.ok)
|
||||
return {
|
||||
status: "invalid",
|
||||
remediation: backupSelection.error.detail,
|
||||
};
|
||||
return {
|
||||
status: "update",
|
||||
backupPath: `${client.configPath}.rea.backup`,
|
||||
backupPath: backupSelection.value.backupPath,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -473,19 +536,101 @@ const preserveConfigBackup = async (
|
||||
}
|
||||
};
|
||||
|
||||
interface RecoveryBackupSelection {
|
||||
readonly backupPath: string;
|
||||
readonly present: boolean;
|
||||
}
|
||||
|
||||
interface RecoveryBackupSelectionFailure {
|
||||
readonly kind: "unreadable" | "occupied";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
const selectCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
original: string,
|
||||
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
|
||||
const expected = Buffer.from(original, "utf8");
|
||||
let firstBackup: Buffer | undefined;
|
||||
try {
|
||||
firstBackup = await readRegularFile(firstBackupPath, {
|
||||
symlinks: "reject",
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
return err({
|
||||
kind: "unreadable",
|
||||
path: firstBackupPath,
|
||||
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
if (firstBackup === undefined)
|
||||
return ok({ backupPath: firstBackupPath, present: false });
|
||||
if (firstBackup.equals(expected))
|
||||
return ok({ backupPath: firstBackupPath, present: true });
|
||||
|
||||
const digest = createHash("sha256").update(original, "utf8").digest("hex");
|
||||
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
|
||||
let recovery: Buffer | undefined;
|
||||
try {
|
||||
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
return err({
|
||||
kind: "unreadable",
|
||||
path: recoveryPath,
|
||||
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
if (recovery !== undefined && !recovery.equals(expected))
|
||||
return err({
|
||||
kind: "occupied",
|
||||
path: recoveryPath,
|
||||
detail: `The recovery path already contains different data: ${recoveryPath}`,
|
||||
});
|
||||
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
|
||||
};
|
||||
|
||||
const restoreConfig = async (
|
||||
path: string,
|
||||
original: string | undefined,
|
||||
): Promise<void> => {
|
||||
): Promise<
|
||||
Result<
|
||||
void,
|
||||
{ readonly kind: "restore"; readonly path: string; readonly detail: string }
|
||||
>
|
||||
> => {
|
||||
try {
|
||||
if (original === undefined) await rm(path, { force: true });
|
||||
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
|
||||
return ok(undefined);
|
||||
} catch (cause: unknown) {
|
||||
// The backup remains available for the remediation reported by setup.
|
||||
void cause;
|
||||
return err({ kind: "restore", path, detail: errorDetail(cause) });
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackOrReadbackFailure = async (
|
||||
targetPath: string,
|
||||
original: string | undefined,
|
||||
backupPath: string | undefined,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
const rollback = await restoreConfig(targetPath, original);
|
||||
return rollback.ok
|
||||
? { status: "failed", reason: "readback" }
|
||||
: {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
failure: rollback.error,
|
||||
};
|
||||
};
|
||||
|
||||
const errorDetail = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
/** Whether REA's entry matches, no legacy entry remains, and no client disable list suppresses it. */
|
||||
const registrationCurrent = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
|
||||
@@ -6,15 +6,19 @@ import type {
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
const failedConfigurationMessage = (
|
||||
reason: "path" | "backup" | "write" | "readback",
|
||||
export const failedConfigurationMessage = (
|
||||
result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
|
||||
): string => {
|
||||
if (reason === "path")
|
||||
if (result.reason === "path")
|
||||
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
|
||||
if (reason === "backup")
|
||||
if (result.reason === "backup")
|
||||
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
|
||||
if (reason === "write")
|
||||
if (result.reason === "write")
|
||||
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
|
||||
if (result.reason === "rollback")
|
||||
return result.backupPath === undefined
|
||||
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
|
||||
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
|
||||
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
|
||||
};
|
||||
|
||||
@@ -40,12 +44,12 @@ export const configureDetectedClients = async (options: {
|
||||
);
|
||||
options.clients[client.name] = result;
|
||||
if (result.status === "failed") {
|
||||
failure ??= failedConfigurationMessage(result.reason);
|
||||
failure ??= failedConfigurationMessage(result);
|
||||
options.onProgress?.({
|
||||
actionId,
|
||||
label,
|
||||
state: "failed",
|
||||
detail: failedConfigurationMessage(result.reason),
|
||||
detail: failedConfigurationMessage(result),
|
||||
});
|
||||
} else if (result.status === "configured") {
|
||||
options.appliedActions.push(`configured_${client.name}`);
|
||||
|
||||
@@ -33,9 +33,11 @@ import {
|
||||
} from "./SetupSkill.js";
|
||||
import {
|
||||
clientConfigurationAligned,
|
||||
ClientConfigurationCleanupRollbackFailure,
|
||||
configureClientConfiguration,
|
||||
inspectClientConfiguration,
|
||||
} from "./SetupClientConfiguration.js";
|
||||
import { failedConfigurationMessage } from "./SetupClients.js";
|
||||
import { setupInstallFailure } from "./SetupInstallFailure.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
@@ -275,8 +277,12 @@ const runClientConfigurationOperation = async <Value>(
|
||||
cleanup.outcome.kind === "failed"
|
||||
? cleanup.outcome.cause
|
||||
: cause.cleanupCause;
|
||||
const rollbackMessage =
|
||||
cause instanceof ClientConfigurationCleanupRollbackFailure
|
||||
? ` ${failedConfigurationMessage(cause.configurationFailure)}`
|
||||
: "";
|
||||
throw configurationCleanupError(
|
||||
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}`,
|
||||
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}.${rollbackMessage}`,
|
||||
cause,
|
||||
cleanup.cleanup,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { HOPPER_PUBLISHER } from "./MacHopper.js";
|
||||
import { setupInstallFailure } from "./SetupInstallFailure.js";
|
||||
|
||||
describe("macOS Hopper setup failure projection", () => {
|
||||
it("projects a publisher mismatch onto its own code and recovery", () => {
|
||||
expect(setupInstallFailure("publisher_verification_failed")).toEqual({
|
||||
status: "failed",
|
||||
code: "publisher_verification_failed",
|
||||
remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a broken bundle distinct from a publisher mismatch", () => {
|
||||
expect(setupInstallFailure("bundle")).toEqual({
|
||||
status: "failed",
|
||||
code: "bundle_invalid",
|
||||
remediation:
|
||||
"The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { HOPPER_PUBLISHER } from "./MacHopper.js";
|
||||
|
||||
export type SetupFailureCode =
|
||||
| "unsupported_host"
|
||||
| "download_failed"
|
||||
@@ -10,6 +12,7 @@ export type SetupFailureCode =
|
||||
| "destination_exists"
|
||||
| "mount_failed"
|
||||
| "bundle_invalid"
|
||||
| "publisher_verification_failed"
|
||||
| "copy_failed"
|
||||
| "skill_install_failed"
|
||||
| "cleanup_failed";
|
||||
@@ -39,6 +42,7 @@ export type HopperInstallFailureReason =
|
||||
| "destination_exists"
|
||||
| "mount"
|
||||
| "bundle"
|
||||
| "publisher_verification_failed"
|
||||
| "copy";
|
||||
|
||||
/** Map provider installer failures to stable setup codes and safe recovery. */
|
||||
@@ -115,6 +119,12 @@ export const setupInstallFailure = (
|
||||
remediation:
|
||||
"The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.",
|
||||
};
|
||||
case "publisher_verification_failed":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "publisher_verification_failed",
|
||||
remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`,
|
||||
};
|
||||
case "copy":
|
||||
return {
|
||||
status: "failed",
|
||||
|
||||
@@ -320,5 +320,5 @@ const clientConfigurationDetail = (
|
||||
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
|
||||
: " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
|
||||
: "";
|
||||
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
|
||||
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
|
||||
};
|
||||
|
||||
@@ -26,6 +26,16 @@ export type ClientConfigurationResult =
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "path" | "backup" | "write" | "readback";
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "rollback";
|
||||
readonly backupPath?: string;
|
||||
readonly failure: {
|
||||
readonly kind: "restore";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Read-only state used to block unsafe setup plans before any mutation. */
|
||||
|
||||
@@ -8,6 +8,11 @@ const EMITTED_SUFFIXES: ReadonlyArray<
|
||||
[".jsx", [".tsx", ".d.ts"]],
|
||||
];
|
||||
|
||||
export interface TypeScriptEmittedSourceCandidate {
|
||||
readonly path: string;
|
||||
readonly kind: "implementation" | "declaration";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return TypeScript source counterparts for an explicit emitted filename.
|
||||
* Callers retain exact runtime-file precedence and must not use these for
|
||||
@@ -16,7 +21,7 @@ const EMITTED_SUFFIXES: ReadonlyArray<
|
||||
export const typescriptEmittedSourceCandidates = (
|
||||
sourcePath: string,
|
||||
candidate: string,
|
||||
): readonly string[] => {
|
||||
): readonly TypeScriptEmittedSourceCandidate[] => {
|
||||
if (!/\.(?:ts|tsx|mts|cts)$/u.test(sourcePath)) return [];
|
||||
const emitted = EMITTED_SUFFIXES.find(([suffix]) =>
|
||||
candidate.endsWith(suffix),
|
||||
@@ -25,5 +30,8 @@ export const typescriptEmittedSourceCandidates = (
|
||||
// Strip the matched suffix by length. posix.extname would miss dotfile
|
||||
// names such as ".js", whose whole basename is the emitted suffix itself.
|
||||
const stem = candidate.slice(0, candidate.length - emitted[0].length);
|
||||
return emitted[1].map((suffix) => `${stem}${suffix}`);
|
||||
return emitted[1].map((suffix) => ({
|
||||
path: `${stem}${suffix}`,
|
||||
kind: suffix.startsWith(".d.") ? "declaration" : "implementation",
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import { readRegularFileText } from "./RegularFileRead.js";
|
||||
import {
|
||||
InvalidUtf8FileError,
|
||||
RegularFileCleanupFailure,
|
||||
readRegularFileText,
|
||||
} from "./RegularFileRead.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
manualRegistrationRemediation,
|
||||
@@ -269,13 +273,17 @@ const readClientConfiguration = async (
|
||||
try {
|
||||
original = await fileSystem.readText(transactionPath);
|
||||
} catch (cause: unknown) {
|
||||
const readFailure =
|
||||
cause instanceof RegularFileCleanupFailure ? cause.cause : cause;
|
||||
return itemRead(
|
||||
isMissing(cause)
|
||||
? item(client.name, "skipped", "Configuration does not exist.")
|
||||
: item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be read. Check file permissions, then rerun uninstall.",
|
||||
readFailure instanceof InvalidUtf8FileError
|
||||
? "Configuration is not valid UTF-8 and was not changed. Repair it, then rerun uninstall."
|
||||
: "Configuration could not be read. Check file permissions, then rerun uninstall.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface WebModuleArtifacts extends SelectedWebScriptArtifacts {
|
||||
|
||||
/** Read selected local artifacts without refetching website assets. */
|
||||
export interface WebModuleArtifactPort {
|
||||
close?(): Promise<void>;
|
||||
load(
|
||||
input: WebModuleTraceInput,
|
||||
options?: ExecutionOptions,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { closeOwnedWebResources } from "./closeOwnedWebResources.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -70,7 +71,13 @@ export class WebModuleTraceService {
|
||||
) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.resolver.close?.() ?? Promise.resolve();
|
||||
return closeOwnedWebResources(
|
||||
[
|
||||
this.artifacts.close?.bind(this.artifacts),
|
||||
this.resolver.close?.bind(this.resolver),
|
||||
],
|
||||
"Web module trace cleanup failed",
|
||||
);
|
||||
}
|
||||
|
||||
async trace(
|
||||
|
||||
@@ -12,4 +12,6 @@ export interface WebNetworkCapturePort {
|
||||
input: InspectWebNetworkCaptureInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebNetworkCapture, AnalysisError>>;
|
||||
/** Retry retained reader/runtime cleanup when the owning service shuts down. */
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ const OPERATION = "inspect_web_network_capture";
|
||||
export class WebNetworkCaptureService {
|
||||
constructor(readonly port: WebNetworkCapturePort) {}
|
||||
|
||||
/** Release resources retained by the selected capture adapter. */
|
||||
close(): Promise<void> {
|
||||
return this.port.close?.() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
/** Return selected complete records inline, without inventing live browser attribution. */
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user