Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/scripts/emit-job-diagnostics.sh
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions .github/scripts/tmp-commit-formatted.sh
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions .github/scripts/tmp-format-apply.jl
Original file line number Diff line number Diff line change
@@ -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
42 changes: 34 additions & 8 deletions .github/workflows/julia-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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())
Expand All @@ -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)
Expand All @@ -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())
Expand All @@ -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)
Expand All @@ -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
39 changes: 39 additions & 0 deletions .github/workflows/tmp-format-apply.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 11 additions & 13 deletions src/BowtieRisk.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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[]

Expand Down Expand Up @@ -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 :
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions test/e2e_test.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

using Test
using BowtieRisk
using JSON3

@testset "E2E Pipeline Tests" begin

Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading