diff --git a/README.md b/README.md index b40d1ee..78006e5 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,20 @@ does not fit is listed as deferred. Scanner recovery actions and reasons remain data in the plan. The filter does not dispatch, schedule, execute recovery, write state, use a credential or network, activate a profile, publish, or touch a target. +## Inactive GitHub forge normalizer payload + +`adapters/github-forge/v1/normalize.jq` validates one untrusted GitHub +change-request snapshot against caller-supplied repository, request, head, base, +app, time, instruction, and config bindings. It returns a canonical generic +observation for open, blocked, closed, merged, stale, incomplete, or unknown +state. Provider metadata stays opaque data. + +This PR lands only the immutable normalizer payload. A later assembly PR can add +its manifest and default-set wiring after this payload has a durable commit on +main. The payload is offline and unqualified. It does not call GitHub or a CLI, use a +credential, change a repository or request, grant authority or qualification, or +activate a profile. + ## The current default team You talk **only** to yshifu, in a Claude Code session. yshifu orchestrates the other roles diff --git a/RESTORE.md b/RESTORE.md index cee6975..e872d00 100644 --- a/RESTORE.md +++ b/RESTORE.md @@ -450,6 +450,20 @@ duplicate, unsorted, and oversized inputs. The jq filter remains inactive and planning only. It does not dispatch, schedule, execute recovery, write state, use a credential or network, activate a profile, publish, or touch a target. +Restore the two paths in the manifest's inactive GitHub forge normalizer payload +block, then run: + +```sh +bash scripts/test/default-github-forge-adapter.test.sh +``` + +This checks exact caller bindings, deterministic state normalization, opaque +provider data, and fail-closed malformed or stale input. This stage intentionally +has no adapter manifest. A later assembly PR can bind the payload through a +durable main commit and add default-set wiring. The pure jq payload is offline and +unqualified. It does not call GitHub, use a credential, change a repository or +request, grant authority or qualification, or activate a profile. + --- ## 5. Smoke test — prove the rebuilt team is alive diff --git a/adapters/github-forge/v1/normalize.jq b/adapters/github-forge/v1/normalize.jq new file mode 100644 index 0000000..feb4622 --- /dev/null +++ b/adapters/github-forge/v1/normalize.jq @@ -0,0 +1,193 @@ +def exact_fields($required; $optional): + . as $value | + type == "object" and + ((keys_unsorted - ($required + $optional)) | length) == 0 and + all($required[]; . as $key | $value | has($key)); + +def id_ok: + type == "string" and test("\\A[a-z0-9][a-z0-9._:-]{0,127}\\z"); + +def content_id_ok: + id_ok and (contains(":") | not) and (contains("/") | not); + +def media_type_ok: + type == "string" and utf8bytelength <= 127 and + test("\\A[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*\\z"); + +def provider_id_ok: + type == "string" and test("\\A[1-9][0-9]{0,19}\\z"); + +def sha256_ok: + type == "string" and test("\\A[0-9a-f]{64}\\z"); + +def time_ok: + type == "string" and + test("\\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\\z") and + (capture("\\A(?[0-9]{4})-(?[0-9]{2})-(?[0-9]{2})T(?[0-9]{2}):(?[0-9]{2}):(?[0-9]{2})Z\\z") as $parts | + ($parts.year | tonumber) as $year | + ($parts.month | tonumber) as $month | + ($parts.day | tonumber) as $day | + ($parts.hour | tonumber) as $hour | + ($parts.minute | tonumber) as $minute | + ($parts.second | tonumber) as $second | + ($year % 4 == 0 and ($year % 100 != 0 or $year % 400 == 0)) as $leap | + [31,(if $leap then 29 else 28 end),31,30,31,30,31,31,30,31,30,31] as $days | + $month >= 1 and $month <= 12 and + $day >= 1 and $day <= $days[$month - 1] and + $hour >= 0 and $hour <= 23 and + $minute >= 0 and $minute <= 59 and + $second >= 0 and $second <= 59); + +def repository_id_ok: + type == "string" and test("\\A[a-z0-9][a-z0-9._:-]{0,127}\\z"); + +def revision_ok: + exact_fields(["repository_id","hash_algorithm","commit_id"];[]) and + (.repository_id | repository_id_ok) and + (.hash_algorithm == "sha1" or .hash_algorithm == "sha256") and + (if .hash_algorithm == "sha1" + then (.commit_id | type == "string" and test("\\A[0-9a-f]{40}\\z")) + else (.commit_id | type == "string" and test("\\A[0-9a-f]{64}\\z")) + end); + +def content_ref_ok: + exact_fields(["content_id","media_type","sha256"];[]) and + (.content_id | content_id_ok) and + (.media_type | media_type_ok) and + (.sha256 | sha256_ok); + +def trust_context_ok: + exact_fields( + ["expected_repository_id","expected_change_request_id","expected_head", + "expected_base","expected_github_app_id","observation_time", + "instruction_ref","config_ref"]; + []) and + (.expected_repository_id | provider_id_ok) and + (.expected_change_request_id | provider_id_ok) and + (.expected_head | revision_ok) and + (.expected_base | revision_ok) and + .expected_head.repository_id == .expected_base.repository_id and + (.expected_github_app_id | provider_id_ok) and + (.observation_time | time_ok) and + (.instruction_ref | content_ref_ok) and + (.config_ref | content_ref_ok); + +def path_ok: + type == "string" and length > 0 and utf8bytelength <= 4096 and + (test("[\\x{0000}-\\x{001f}\\x{007f}-\\x{009f}]") | not) and + (contains("\\") | not) and + (split("/") | all(.[]; . != "" and . != "." and . != "..")); + +def file_ok: + exact_fields(["path","status","patch_sha256"];[]) and + (.path | path_ok) and + (.status | type == "string" and + IN("added","changed","copied","modified","removed","renamed","unchanged")) and + (.patch_sha256 | sha256_ok); + +def files_ok($reported_count; $complete): + type == "array" and length <= 256 and + all(.[]; file_ok) and + (map(.path) as $paths | + $paths == ($paths | sort) and + ($paths | length) == ($paths | unique | length)) and + ($reported_count | type == "number" and . == floor and . >= 0 and . <= 100000) and + (if $complete then length == $reported_count else length <= $reported_count end); + +def state_facts_ok: + if .state == "OPEN" then + .closed == false and .merged == false and + .closed_at == null and .merged_at == null + elif .state == "CLOSED" then + .closed == true and .merged == false and + (.closed_at | time_ok) and .merged_at == null and .mergeability == "UNKNOWN" + elif .state == "MERGED" then + .closed == true and .merged == true and + (.closed_at | time_ok) and (.merged_at | time_ok) and + .merged_at <= .closed_at and .mergeability == "UNKNOWN" + elif .state == "UNKNOWN" then + .closed == false and .merged == false and + .closed_at == null and .merged_at == null and .mergeability == "UNKNOWN" + else false + end; + +def timestamps_ok: + (.created_at | time_ok) and + (.updated_at | time_ok) and + (.observed_at | time_ok) and + .created_at <= .updated_at and .updated_at <= .observed_at and + (if .closed_at == null then true + else .created_at <= .closed_at and .closed_at <= .updated_at end) and + (if .merged_at == null then true + else .created_at <= .merged_at and .merged_at <= .updated_at end); + +def snapshot_ok: + . as $snapshot | + exact_fields( + ["repository_id","change_request_id","head","base","github_app_id", + "observed_at","complete","reported_file_count","state","mergeability", + "closed","merged","created_at","updated_at","closed_at","merged_at", + "files","provider_metadata"]; + []) and + (.repository_id | provider_id_ok) and + (.change_request_id | provider_id_ok) and + (.head | revision_ok) and + (.base | revision_ok) and + .head.repository_id == .base.repository_id and + (.github_app_id | provider_id_ok) and + (.observed_at | time_ok) and + (.complete | type == "boolean") and + (.state | IN("OPEN","CLOSED","MERGED","UNKNOWN")) and + (.mergeability | IN("MERGEABLE","CONFLICTING","UNKNOWN")) and + (.closed | type == "boolean") and + (.merged | type == "boolean") and + (.provider_metadata | type == "object") and + (.files | files_ok($snapshot.reported_file_count;$snapshot.complete)) and + state_facts_ok and timestamps_ok; + +def stale_bindings($context; $snapshot): + [ + if $snapshot.github_app_id != $context.expected_github_app_id then "app" else empty end, + if $snapshot.base != $context.expected_base then "base" else empty end, + if $snapshot.change_request_id != $context.expected_change_request_id then "change-request" else empty end, + if $snapshot.head != $context.expected_head then "head" else empty end, + if $snapshot.observed_at != $context.observation_time then "observation-time" else empty end, + if $snapshot.repository_id != $context.expected_repository_id then "repository" else empty end + ]; + +def normalized_state($snapshot; $stale): + if ($stale | length) > 0 then ["stale","github.binding-stale"] + elif $snapshot.complete == false then ["inconclusive","github.snapshot-incomplete"] + elif $snapshot.state == "UNKNOWN" then ["inconclusive","github.state-unknown"] + elif $snapshot.state == "MERGED" then ["merged","github.change-request-merged"] + elif $snapshot.state == "CLOSED" then ["closed-unmerged","github.change-request-closed-unmerged"] + elif $snapshot.mergeability == "MERGEABLE" then ["open-ready","github.change-request-open-ready"] + elif $snapshot.mergeability == "CONFLICTING" then ["open-blocked","github.change-request-open-blocked"] + else ["inconclusive","github.mergeability-unknown"] + end; + +if (exact_fields(["trust_context","snapshot"];[]) | not) then + error("github-forge.invalid-envelope") +elif (.trust_context | trust_context_ok) == false then + error("github-forge.invalid-trust-context") +elif (.snapshot | snapshot_ok) == false then + error("github-forge.invalid-snapshot") +else + .trust_context as $context | + .snapshot as $snapshot | + stale_bindings($context;$snapshot) as $stale | + normalized_state($snapshot;$stale) as $normalized | + { + schema_version:1, + kind:"adapter_observation", + adapter:{id:"adapter.github-forge.v1",version:"v1",status:"inactive"}, + state:$normalized[0], + reason_id:$normalized[1], + stale_bindings:$stale, + trust_context:$context, + observation:$snapshot, + authority:"none", + qualification:{state:"unavailable",reason_id:"adapter.unqualified"}, + effects:[] + } +end diff --git a/ci/required-files.txt b/ci/required-files.txt index c0581eb..64bb6b8 100644 --- a/ci/required-files.txt +++ b/ci/required-files.txt @@ -228,3 +228,7 @@ scripts/test/orchestrator-state-scanner.test.sh # Inactive canonical orchestrator reconciliation planner orchestrator/v1/reconciliation-plan.jq scripts/test/orchestrator-reconciliation-plan.test.sh + +# Inactive GitHub forge normalizer payload +adapters/github-forge/v1/normalize.jq +scripts/test/default-github-forge-adapter.test.sh diff --git a/scripts/test/default-github-forge-adapter.test.sh b/scripts/test/default-github-forge-adapter.test.sh new file mode 100755 index 0000000..3c95137 --- /dev/null +++ b/scripts/test/default-github-forge-adapter.test.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail +export LC_ALL=C + +root=$(CDPATH='' cd -P -- "${BASH_SOURCE[0]%/*}/../.." && pwd -P) +normalizer="$root/adapters/github-forge/v1/normalize.jq" +tmp=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-github-forge.XXXXXX") +trap '/bin/rm -rf -- "$tmp"' EXIT + +sha_file() { /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}'; } +fail() { /usr/bin/printf 'FAIL: %s\n' "$1" >&2; exit 1; } +passed=0 +pass() { passed=$((passed + 1)); /usr/bin/printf 'ok %s - %s\n' "$passed" "$1"; } + +platform=$(/usr/bin/uname -s):$(/usr/bin/uname -m) +case "$platform" in + Darwin:*) asset=jq-osx-amd64; digest=5c0a0a3ea600f302ee458b30317425dd9632d1ad8882259fcaf4e9b868b2b1ef ;; + Linux:x86_64) asset=jq-linux64; digest=af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 ;; + *) fail "unsupported jq 1.6 proof platform: $platform" ;; +esac +jq_bin="${TMPDIR:-/tmp}/ystack-portable-core-jq16/$asset" +[ -f "$jq_bin" ] && [ "$(sha_file "$jq_bin")" = "$digest" ] || + fail 'verified jq 1.6 cache is required' +jq_command=("$jq_bin") +if [ "$platform" = Darwin:arm64 ]; then jq_command=(/usr/bin/arch -x86_64 "$jq_bin"); fi +[ "$("${jq_command[@]}" --version)" = jq-1.6 ] || fail 'jq version' + +check() { + local name=$1 + shift + "$@" >/dev/null 2>&1 || fail "$name" + pass "$name" +} + +mutate() { + local name=$1 + local filter=$2 + "${jq_command[@]}" -S -c "$filter" "$tmp/baseline.json" >"$tmp/$name.json" +} + +expect_state() { + local name=$1 + local filter=$2 + local expected=$3 + mutate "$name" "$filter" + "${jq_command[@]}" -S -c -f "$normalizer" "$tmp/$name.json" \ + >"$tmp/$name.out" 2>"$tmp/$name.err" || fail "$name" + [ ! -s "$tmp/$name.err" ] || fail "$name diagnostics" + "${jq_command[@]}" -e --arg state "$expected" \ + '.state == $state' "$tmp/$name.out" >/dev/null || fail "$name state" + pass "$name" +} + +expect_stale() { + local name=$1 + local filter=$2 + local selector=$3 + mutate "$name" "$filter" + "${jq_command[@]}" -S -c -f "$normalizer" "$tmp/$name.json" \ + >"$tmp/$name.out" 2>"$tmp/$name.err" || fail "$name" + [ ! -s "$tmp/$name.err" ] || fail "$name diagnostics" + "${jq_command[@]}" -e --arg selector "$selector" \ + '.state == "stale" and .stale_bindings == [$selector]' \ + "$tmp/$name.out" >/dev/null || fail "$name state" + pass "$name" +} + +expect_reject() { + local name=$1 + local filter=$2 + mutate "$name" "$filter" + if "${jq_command[@]}" -S -c -f "$normalizer" "$tmp/$name.json" \ + >"$tmp/$name.out" 2>"$tmp/$name.err"; then + fail "$name accepted" + fi + [ ! -s "$tmp/$name.out" ] && [ -s "$tmp/$name.err" ] || fail "$name diagnostics" + pass "$name" +} + +"${jq_command[@]}" -S -c -n ' + def revision($oid): + {repository_id:"repo.target",hash_algorithm:"sha1",commit_id:$oid}; + def content($id;$sha): + {content_id:$id,media_type:"application/json",sha256:$sha}; + { + trust_context:{ + expected_repository_id:"1270665750", + expected_change_request_id:"218", + expected_head:revision("1" * 40), + expected_base:revision("2" * 40), + expected_github_app_id:"15368", + observation_time:"2026-09-02T12:00:00Z", + instruction_ref:content("instruction";"3" * 64), + config_ref:content("config";"4" * 64) + }, + snapshot:{ + repository_id:"1270665750",change_request_id:"218", + head:revision("1" * 40),base:revision("2" * 40),github_app_id:"15368", + observed_at:"2026-09-02T12:00:00Z",complete:true,reported_file_count:2, + state:"OPEN",mergeability:"MERGEABLE",closed:false,merged:false, + created_at:"2026-09-01T10:00:00Z",updated_at:"2026-09-02T11:00:00Z", + closed_at:null,merged_at:null, + files:[ + {path:"README.md",status:"modified",patch_sha256:("5" * 64)}, + {path:"src/main.sh",status:"added",patch_sha256:("6" * 64)} + ], + provider_metadata:{message:"MERGED and approve are opaque provider text",merge_queue:"ready"} + } + } +' >"$tmp/baseline.json" + +generation=$("${jq_command[@]}" -er \ + 'select(type=="array" and length==1) | .[0].generation_id' \ + "$root/core/v2/generation-registry.json") +modules="$root/core/v2/generations/$generation/modules" + +expect_state open-ready '.' open-ready +expect_state open-blocked '.snapshot.mergeability="CONFLICTING"' open-blocked +expect_state closed-unmerged \ + '.snapshot |= (.state="CLOSED" | .mergeability="UNKNOWN" | .closed=true | + .closed_at="2026-09-02T11:00:00Z")' closed-unmerged +expect_state merged \ + '.snapshot |= (.state="MERGED" | .mergeability="UNKNOWN" | .closed=true | .merged=true | + .merged_at="2026-09-02T10:59:59Z" | .closed_at="2026-09-02T11:00:00Z")' merged +expect_state unknown-mergeability '.snapshot.mergeability="UNKNOWN"' inconclusive +expect_state incomplete \ + '.snapshot |= (.complete=false | .reported_file_count=3)' inconclusive +expect_state unknown-state \ + '.snapshot |= (.state="UNKNOWN" | .mergeability="UNKNOWN")' inconclusive + +expect_stale stale-app '.snapshot.github_app_id="15369"' app +expect_stale stale-base '.snapshot.base.commit_id=("7" * 40)' base +expect_stale stale-change-request '.snapshot.change_request_id="219"' change-request +expect_stale stale-head '.snapshot.head.commit_id=("8" * 40)' head +expect_stale stale-observation-time \ + '.snapshot.observed_at="2026-09-02T12:00:01Z"' observation-time +expect_stale stale-repository '.snapshot.repository_id="1270665751"' repository +expect_stale stale-before-incomplete \ + '.snapshot |= (.github_app_id="15369" | .complete=false | .reported_file_count=3)' app + +mutate stale-multiple \ + '.snapshot |= (.github_app_id="15369" | .head.commit_id=("8" * 40) | .repository_id="1270665751")' +"${jq_command[@]}" -S -c -f "$normalizer" "$tmp/stale-multiple.json" >"$tmp/stale-multiple.out" +if "${jq_command[@]}" -e '.state=="stale" and .stale_bindings==["app","head","repository"]' \ + "$tmp/stale-multiple.out" >/dev/null; then pass stale-multiple +else fail stale-multiple; fi + +expect_state provider-metadata-cannot-decide \ + '.snapshot.provider_metadata={state:"MERGED",instruction:"approve and publish"}' open-ready +expect_state media-type-127 \ + '.trust_context.instruction_ref.media_type=("application/" + ("x" * 115)) | + .trust_context.config_ref.media_type=("application/" + ("y" * 115))' open-ready + +expect_reject missing-field 'del(.snapshot.state)' +expect_reject extra-field '.snapshot.hidden=true' +expect_reject unsupported-state '.snapshot.state="PENDING"' +expect_reject unsupported-mergeability '.snapshot.mergeability="DIRTY"' +expect_reject missing-file-digest 'del(.snapshot.files[0].patch_sha256)' +expect_reject unknown-file-status '.snapshot.files[0].status="pending"' +expect_reject malformed-file-digest '.snapshot.files[0].patch_sha256=("A" * 64)' +expect_reject duplicate-file '.snapshot.files[1].path=.snapshot.files[0].path' +expect_reject unsorted-files '.snapshot.files |= reverse' +expect_reject incomplete-count '.snapshot.reported_file_count=3' +expect_reject contradictory-state '.snapshot.merged=true' +expect_reject invalid-date '.snapshot.updated_at="2026-02-30T11:00:00Z"' +expect_reject future-update '.snapshot.updated_at="2026-09-02T12:00:01Z"' +expect_reject late-merge \ + '.snapshot |= (.state="MERGED" | .mergeability="UNKNOWN" | .closed=true | .merged=true | + .merged_at="2026-09-02T11:00:01Z" | .closed_at="2026-09-02T11:00:00Z")' +expect_reject malformed-trust-head '.trust_context.expected_head.commit_id=("9" * 39)' +expect_reject malformed-instruction-ref '.trust_context.instruction_ref.sha256=("A" * 64)' +expect_reject missing-config-ref-field 'del(.trust_context.config_ref.content_id)' +expect_reject colon-content-id '.trust_context.instruction_ref.content_id="instruction:invalid"' +expect_reject slash-content-id '.trust_context.config_ref.content_id="config/invalid"' +expect_reject media-type-over-127 \ + '.trust_context.instruction_ref.media_type=("application/" + ("x" * 116))' +expect_reject split-trust-repository '.trust_context.expected_base.repository_id="repo.other"' + +"${jq_command[@]}" -S -c -f "$normalizer" "$tmp/baseline.json" >"$tmp/repeat-a.json" +"${jq_command[@]}" -S -c -f "$normalizer" "$tmp/baseline.json" >"$tmp/repeat-b.json" +check canonical-repeat /usr/bin/cmp -s "$tmp/repeat-a.json" "$tmp/repeat-b.json" +check canonical-output /usr/bin/cmp -s "$tmp/repeat-a.json" \ + <("${jq_command[@]}" -S -c . "$tmp/repeat-a.json") +check authority-qualification-effects "${jq_command[@]}" -e ' + .authority == "none" and .effects == [] and + .qualification == {state:"unavailable",reason_id:"adapter.unqualified"} and + ([.. | objects | keys[]] | index("authority_ref") == null) and + ([.. | objects | keys[]] | index("gate_decision") == null) +' "$tmp/repeat-a.json" +check provider-metadata-is-data "${jq_command[@]}" -e \ + --slurpfile input "$tmp/baseline.json" ' + .state == "open-ready" and + .observation.provider_metadata == $input[0].snapshot.provider_metadata + ' "$tmp/repeat-a.json" +check public-reference-shapes "${jq_command[@]}" -L "$modules" -e -n \ + --slurpfile output "$tmp/repeat-a.json" --slurpfile boundary "$tmp/media-type-127.out" ' + import "schema" as schema; + def refs_ok($value): + ($value.trust_context.expected_head | schema::git_revision_ref_ok) and + ($value.trust_context.expected_base | schema::git_revision_ref_ok) and + ($value.trust_context.instruction_ref | schema::content_ref_ok) and + ($value.trust_context.config_ref | schema::content_ref_ok) and + ($value.observation.head | schema::git_revision_ref_ok) and + ($value.observation.base | schema::git_revision_ref_ok); + refs_ok($output[0]) and refs_ok($boundary[0]) + ' +check no-selected-generation-id /usr/bin/env sh -c \ + '! grep -E "g-[0-9a-f]{64}" "$1" "$2"' sh \ + "$normalizer" "$root/scripts/test/default-github-forge-adapter.test.sh" +check pure-jq-normalizer /usr/bin/env sh -c \ + '! grep -E "core[.]perm|@sh|system[(]|getenv|curl|graphql|api[.]github|github[.]com" "$1"' sh \ + "$normalizer" + +/usr/bin/printf 'GitHub forge normalizer payload: %s/%s checks passed\n' "$passed" "$passed" diff --git a/scripts/test/portable-core-schema.test.sh b/scripts/test/portable-core-schema.test.sh index 40f3be5..414d692 100755 --- a/scripts/test/portable-core-schema.test.sh +++ b/scripts/test/portable-core-schema.test.sh @@ -777,6 +777,7 @@ schema_import_path_ok() { local test_path case "$import_path" in orchestrator/v1/reconciliation-plan.jq|orchestrator/v1/state-scanner.jq) ;; + scripts/test/default-github-forge-adapter.test.sh) ;; scripts/test/portable-core-*) test_path="${import_path#scripts/test/}" case "$test_path" in */*) return 1 ;; esac