diff --git a/.github/scripts/emit-job-diagnostics.sh b/.github/scripts/emit-job-diagnostics.sh new file mode 100644 index 0000000..a3fb290 --- /dev/null +++ b/.github/scripts/emit-job-diagnostics.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# TEMPORARY (#61): publish the tail of a failing step's output as check +# annotations, since raw job logs are not retrievable outside the UI. +# Remove together with the `diag` steps in julia-ci.yml. +set -uo pipefail +f="${1:-}" +if [ -z "$f" ] || [ ! -s "$f" ]; then + echo "::error title=diag::(no log captured: ${f:-unset})" + exit 0 +fi +# Last 40 KB of output, in 3 KB pieces. Escape % and newlines per the +# workflow-command rules so each piece is one annotation. +tail -c 40000 "$f" | split -b 3000 - /tmp/diag-piece. +n=0 +for piece in /tmp/diag-piece.*; do + esc=$(sed -e 's/%/%25/g' -e 's/\r/%0D/g' "$piece" | awk 'BEGIN{ORS=""} NR>1{print "%0A"} {print}') + echo "::error title=diag ${n}::${esc}" + n=$((n + 1)) +done +rm -f /tmp/diag-piece.* +exit 0 diff --git a/.github/scripts/tmp-commit-formatted.sh b/.github/scripts/tmp-commit-formatted.sh new file mode 100644 index 0000000..d173dd3 --- /dev/null +++ b/.github/scripts/tmp-commit-formatted.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# TEMPORARY (#61): commit whatever the formatter changed through the GraphQL +# createCommitOnBranch mutation, so GitHub signs the commit. +set -euo pipefail + +files=$(git diff --name-only) +if [ -z "$files" ]; then + echo "nothing to format" + exit 0 +fi + +adds=$(for f in $files; do + jq -n --arg p "$f" --rawfile c "$f" '{path: $p, contents: ($c | @base64)}' +done | jq -s '.') + +jq -n \ + --arg repo "$GITHUB_REPOSITORY" \ + --arg branch "$GITHUB_REF_NAME" \ + --arg head "$GITHUB_SHA" \ + --argjson adds "$adds" \ + '{ + query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", + variables: {input: { + branch: {repositoryNameWithOwner: $repo, branchName: $branch}, + message: { + headline: "style(format): apply JuliaFormatter to src and test (#61)", + body: "Automated JuliaFormatter pass (DefaultStyle) over src/ and test/, committed through the GitHub API so GitHub signs it." + }, + fileChanges: {additions: $adds, deletions: []}, + expectedHeadOid: $head + }} + }' > /tmp/format-req.json + +gh api graphql --input /tmp/format-req.json +echo "committed formatted files: $files" diff --git a/.github/scripts/tmp-format-apply.jl b/.github/scripts/tmp-format-apply.jl new file mode 100644 index 0000000..81f52f6 --- /dev/null +++ b/.github/scripts/tmp-format-apply.jl @@ -0,0 +1,18 @@ +# TEMPORARY (#61): formats src/ and test/ in place with JuliaFormatter. +using Pkg +Pkg.activate(mktempdir()) +Pkg.add("JuliaFormatter") +using JuliaFormatter + +files = String[] +for dir in ("src", "test") + for (root, _, names) in walkdir(dir) + for n in names + endswith(n, ".jl") && push!(files, joinpath(root, n)) + end + end +end +for f in sort(files) + changed = format_file(f) + println(changed ? "formatted " : "ok ", f) +end diff --git a/.github/workflows/julia-ci.yml b/.github/workflows/julia-ci.yml index 8cedb87..434b980 100644 --- a/.github/workflows/julia-ci.yml +++ b/.github/workflows/julia-ci.yml @@ -11,6 +11,8 @@ # Pin posture: symbolic refs are pinned by commit SHA (comment gives the tag). # No `continue-on-error` anywhere: a red job is the gate working. # +# TEMPORARY: the `diag` steps publish failing output as annotations (see +# .github/scripts/emit-job-diagnostics.sh). Remove with that script. name: Julia CI on: @@ -42,6 +44,7 @@ jobs: - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 - name: Check formatting (check mode, never writes) run: | + set +e julia -e ' using Pkg Pkg.activate(mktempdir()) @@ -66,7 +69,13 @@ jobs: println("Not formatted (run JuliaFormatter.format_file on these):") foreach(f -> println(" ", f), bad) exit(1) - end' + end' > fmt.log 2>&1 + rc=$? + cat fmt.log + exit $rc + - name: diag + if: failure() + run: bash .github/scripts/emit-job-diagnostics.sh fmt.log jet: name: Static analysis (JET) @@ -81,6 +90,7 @@ jobs: - name: JET analysis (supported API) # JET 0.12 removed the path/string entry points; pass the loaded module. run: | + set +e julia -e ' using Pkg Pkg.activate(mktempdir()) @@ -91,11 +101,13 @@ jobs: if !hasmethod(JET.test_package, (Module,)) error("JET ", pkgversion(JET), " has no test_package(::Module)") end - # Documented exemption (#61): JSON3 reads values through a generic - # getvalue(::Type{Any}), so JET infers a BoundsError inside JSON3 for - # every field access. Only reports whose stack passes through JSON3 are - # ignored; all other JET reports still fail the job. - JET.test_package(BowtieRisk; ignored_modules=(JET.AnyFrameModule(:JSON3),))' + JET.test_package(BowtieRisk)' > jet.log 2>&1 + rc=$? + cat jet.log + exit $rc + - name: diag + if: failure() + run: bash .github/scripts/emit-job-diagnostics.sh jet.log test: name: Test (Test + Aqua) @@ -115,7 +127,21 @@ jobs: - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 - name: Install dependencies run: | - julia --project=. -e 'using Pkg; Pkg.add(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]); Pkg.instantiate()' + set +e + julia --project=. -e 'using Pkg; Pkg.add(url=ENV["ACCELERATORGATE_URL"], rev=ENV["ACCELERATORGATE_REV"]); Pkg.instantiate()' > install.log 2>&1 + rc=$? + cat install.log + exit $rc + - name: diag + if: failure() + run: bash .github/scripts/emit-job-diagnostics.sh install.log - name: Pkg.test() run: | - julia --project=. -e 'using Pkg; Pkg.test()' + set +e + julia --project=. -e 'using Pkg; Pkg.test()' > test.log 2>&1 + rc=$? + cat test.log + exit $rc + - name: diag + if: failure() + run: bash .github/scripts/emit-job-diagnostics.sh test.log diff --git a/.github/workflows/tmp-format-apply.yml b/.github/workflows/tmp-format-apply.yml new file mode 100644 index 0000000..6990dc0 --- /dev/null +++ b/.github/workflows/tmp-format-apply.yml @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: MPL-2.0 +# TEMPORARY (#61): apply JuliaFormatter to src/ and test/ on this branch and +# commit the result through the GitHub API, so GitHub signs it. Removed once +# the formatted sources are on the branch. No-op when nothing changes. +name: TEMP format-apply + +on: + push: + branches: [arena/ebd419f3-bowtierisk-jl] + +permissions: + contents: read + +concurrency: + group: tmp-format-apply-${{ github.ref }} + cancel-in-progress: false + +jobs: + apply: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 + with: + version: "1" + - name: Apply JuliaFormatter (DefaultStyle) + run: julia .github/scripts/tmp-format-apply.jl + - name: Commit formatted files through the API (GitHub-signed) + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} + run: bash .github/scripts/tmp-commit-formatted.sh diff --git a/src/BowtieRisk.jl b/src/BowtieRisk.jl index 89115a9..e8f8a6a 100644 --- a/src/BowtieRisk.jl +++ b/src/BowtieRisk.jl @@ -265,10 +265,6 @@ function _apply_distributions(model::BowtieModel, dists::Dict{Symbol,BarrierDist BowtieModel(model.hazard, model.top_event, threats, cons, model.probability_model) end -_check_mode(model::ProbabilityModel) = - model.mode in (:independent, :dependent) ? nothing : - error("unknown probability model mode: $(model.mode)") - function _combined_barrier_reduction( barriers::Vector{Barrier}, factors::Vector{EscalationFactor}, @@ -319,7 +315,10 @@ end Compute residual probabilities, top event probability, and consequence risk. """ function evaluate(model::BowtieModel) - _check_mode(model.probability_model) + if !(model.probability_model.mode in (:independent, :dependent)) + error("unknown probability model mode: $(model.probability_model.mode)") + end + threat_residuals = Dict{Symbol,Float64}() residual_values = Float64[] @@ -960,11 +959,12 @@ function write_model_json(path::AbstractString, model::BowtieModel) nothing end -# Typed accessors for JSON3 values. JSON3 yields a Union of JSON types, so each -# value is checked once here; a malformed model file raises ArgumentError rather +# Typed accessors for materialized JSON values. Concrete dictionaries avoid +# indexing JSON3's abstract lazy buffer/tape types during static analysis. +# Each value is checked here so malformed fields raise ArgumentError rather # than a MethodError deep inside the constructors. -_json_obj(x)::JSON3.Object = - x isa JSON3.Object ? x : +_json_obj(x)::Dict{String,Any} = + x isa Dict{String,Any} ? x : throw(ArgumentError("model JSON: expected an object, got $(typeof(x))")) _json_arr(x)::AbstractVector = x isa AbstractVector ? x : @@ -976,9 +976,7 @@ _json_num(x)::Float64 = x isa Real ? Float64(x) : throw(ArgumentError("model JSON: expected a number, got $(typeof(x))")) _json_sym(x)::Symbol = Symbol(_json_str(x)) -_json_field(o, key::String) = _json_req(get(_json_obj(o), Symbol(key), nothing), key) -_json_req(x, key::String) = - x === nothing ? throw(ArgumentError("model JSON: missing or null field \"$key\"")) : x +_json_field(o, key::String) = _json_obj(o)[key] function _read_barrier(b) Barrier( @@ -1009,7 +1007,7 @@ end Read a bowtie model from JSON produced by write_model_json. """ function read_model_json(path::AbstractString) - obj = _json_obj(JSON3.read(read(path, String))) + obj = _json_obj(JSON3.read(read(path, String), Any)) hz = _json_obj(_json_field(obj, "hazard")) te = _json_obj(_json_field(obj, "top_event")) hazard = Hazard( diff --git a/test/e2e_test.jl b/test/e2e_test.jl index 6d2c4b5..4218296 100644 --- a/test/e2e_test.jl +++ b/test/e2e_test.jl @@ -5,6 +5,7 @@ using Test using BowtieRisk +using JSON3 @testset "E2E Pipeline Tests" begin @@ -133,6 +134,27 @@ using BowtieRisk ) @test_throws ErrorException evaluate(bad_model) + # Mode validation also applies when there are no paths to evaluate. + empty_model = BowtieModel( + bad_model.hazard, + bad_model.top_event, + ThreatPath[], + ConsequencePath[], + bad_model.probability_model, + ) + @test_throws ErrorException evaluate(empty_model) + + for mode in (:independent, :dependent) + valid_model = BowtieModel( + bad_model.hazard, + bad_model.top_event, + bad_model.threat_paths, + bad_model.consequence_paths, + ProbabilityModel(mode), + ) + @test evaluate(valid_model).top_event_probability ≈ 0.1 + end + # Unknown barrier distribution in simulation simple_model = BowtieModel( Hazard(:H, "Hazard"), @@ -165,6 +187,10 @@ using BowtieRisk model2 = read_model_json(path) summary2 = evaluate(model2) + roundtrip_path = joinpath(dir, "roundtrip.json") + write_model_json(roundtrip_path, model2) + @test JSON3.read(read(roundtrip_path, String)) == JSON3.read(read(path, String)) + @test summary2.top_event_probability ≈ summary.top_event_probability for (k, v) in summary.threat_residuals @test summary2.threat_residuals[k] ≈ v @@ -174,4 +200,34 @@ using BowtieRisk end end + @testset "Malformed JSON field types" begin + mktempdir() do dir + path = joinpath(dir, "model.json") + write_model_json(path, template_model(:process_safety)) + original = read(path, String) + + for (keys, value) in ( + (("hazard",), nothing), + (("hazard", "name"), 42), + (("top_event", "description"), []), + (("threat_paths",), nothing), + (("threat_paths", 1, "threat", "probability"), "0.1"), + (("threat_paths", 1, "barriers", 1), false), + (("threat_paths", 1, "escalation_factors"), "invalid"), + ) + obj = JSON3.read(original, Dict{String,Any}) + parent = obj + for key in keys[1:(end-1)] + parent = parent[key] + end + parent[last(keys)] = value + write(path, JSON3.write(obj)) + @test_throws ArgumentError read_model_json(path) + end + + write(path, "[]") + @test_throws ArgumentError read_model_json(path) + end + end + end