#!/usr/bin/env fsh
# Native dependency: jq 1.7.1. Jq exposes bounded string observations; Flash
# owns path acceptance, ordering, uniqueness, and every classification decision.
import { require_jq } from './lib/tools.fsh'
import { repository_root } from './lib/repository.fsh'
import { expect_equal, expect_unique } from './lib/validation.fsh'
def usage_error(message) {
^printf '%s\n' 'usage: classify_changes.fsh [-h] [--json] [--null] [paths ...]' 1>&2
^printf 'classify_changes.fsh: error: %s\n' $message 1>&2
exit 2
}
def print_help() {
^printf '%s\n' \
'usage: classify_changes.fsh [-h] [--json] [--null] [paths ...]' \
'' \
'positional arguments:' \
' paths repository-relative changed paths; stdin is used when omitted' \
'' \
'options:' \
' -h, --help show this help message and exit' \
' --json emit JSON' \
' --null read NUL-delimited paths from stdin' || exit 1
exit 0
}
def observe(projected) {
if $projected.blank {
return null
}
if $projected.absolute || $projected.parent_segment {
throw "invalid repository-relative path: '${$projected.raw}'"
}
let low_risk_files = [
'.github/SECURITY.md', '.github/dependabot.yml', '.gitignore',
'CODE_OF_CONDUCT.md', 'CHANGELOG.md', 'CONTRIBUTING.md',
'DOCUMENTATION.md', 'HARDWARE.md', 'LICENSE', 'NOTICE', 'README.md',
'TRADEMARK.md', 'codecov.yml', 'flashos.sh', 'flashos.zsh',
]
let security_files = [
'.github/dependabot.yml', '.github/workflows/security.yml', 'Cargo.lock',
'Cargo.toml', 'deny.toml', 'components/flash/Cargo.lock',
'components/flash/Cargo.toml', 'components/flash/deny.toml',
]
mut low_risk = false
if $projected.normalized in $low_risk_files {
$low_risk = true
} else if $projected.low_risk_prefix {
$low_risk = true
} else if $projected.adjacent_doc_prefix && $projected.markdown_suffix {
$low_risk = true
}
mut target = false
if $projected.fsh_suffix {
$target = true
} else if $projected.component_prefix {
$target = true
} else if $projected.config_prefix {
$target = true
} else if $projected.flash_recipe_prefix {
$target = true
}
mut security = false
if $projected.normalized in $security_files {
$security = true
} else if $projected.security_nested_manifest {
$security = true
}
let observation = {
position: $projected.position,
path: $projected.normalized,
low_risk: $low_risk,
target: $target,
security: $security,
}
return $observation
}
def first_occurrence(all, selected) -> Bool {
for candidate in $all {
if $candidate.normalized == $selected.normalized && $candidate.position < $selected.position {
return false
}
}
return true
}
def select_observation(item) {
return observe($item.selected)
}
def product_reason(observation) -> String {
return "product: ${$observation.path}"
}
def decide(observations) {
mut product_count = 0
mut security_required = false
mut target_required = false
for observation in $observations {
if $observation.security {
$security_required = true
}
if !$observation.low_risk {
$product_count = $product_count + 1
if $observation.target {
$target_required = true
}
}
}
if $observations == [] {
let empty = {
lane: 'product',
image_required: true,
target_required: true,
security_required: true,
product_count: 0,
}
return $empty
}
if $product_count > 0 {
let product = {
lane: 'product',
image_required: true,
target_required: $target_required,
security_required: $security_required,
product_count: $product_count,
}
return $product
}
let fast = {
lane: 'fast',
image_required: false,
target_required: false,
security_required: $security_required,
product_count: 0,
}
return $fast
}
def validate(envelope) {
let result = $envelope.document
let observations = $envelope.observations
expect_equal($envelope.keys, ['image_required', 'lane', 'paths', 'reasons', 'schema', 'security_required', 'target_required'], 'classification fields are invalid')
expect_equal($result.schema, 1, 'classification schema is invalid')
expect_unique($result.paths, 'classification paths contain duplicates')
mut previous = null
mut product_count = 0
mut security_required = false
mut target_required = false
mut path_index = 0
for observation in $observations {
let path = $result.paths[$path_index]
expect_equal($observation.path, $path, 'classification path projection differs')
if $previous != null && $path < $previous {
throw 'classification paths are not sorted'
}
$previous = $path
if $observation.security {
$security_required = true
}
if !$observation.low_risk {
$product_count = $product_count + 1
if $observation.target {
$target_required = true
}
}
$path_index = $path_index + 1
}
mut path_count = 0
for path in $result.paths {
$path_count = $path_count + 1
}
expect_equal($path_index, $path_count, 'classification observation count differs')
if $result.paths == [] {
expect_equal($result.lane, 'product', 'empty classification lane differs')
expect_equal($result.image_required, true, 'empty classification image decision differs')
expect_equal($result.target_required, true, 'empty classification target decision differs')
expect_equal($result.security_required, true, 'empty classification security decision differs')
expect_equal($result.reasons, ['no changed paths were supplied; qualification fails closed'], 'empty classification reasons differ')
return $result
}
expect_equal($result.security_required, $security_required, 'classification security decision differs')
if $product_count == 0 {
expect_equal($result.lane, 'fast', 'fast classification lane differs')
expect_equal($result.image_required, false, 'fast classification image decision differs')
expect_equal($result.target_required, false, 'fast classification target decision differs')
expect_equal($result.reasons, ['every changed path is explicitly isolated documentation, policy, reporting, or host tooling'], 'fast classification reasons differ')
return $result
}
expect_equal($result.lane, 'product', 'product classification lane differs')
expect_equal($result.image_required, true, 'product classification image decision differs')
expect_equal($result.target_required, $target_required, 'product classification target decision differs')
mut expected_reason_count = 1 + $product_count
if $target_required {
$expected_reason_count = $expected_reason_count + 1
}
mut actual_reason_count = 0
for reason in $result.reasons {
$actual_reason_count = $actual_reason_count + 1
}
expect_equal($actual_reason_count, $expected_reason_count, 'product classification reason count differs')
expect_equal($result.reasons[0], 'product or unknown paths require image and runtime qualification', 'product classification reason heading differs')
mut reason_index = 1
for observation in $observations {
if !$observation.low_risk {
expect_equal($result.reasons[$reason_index], "product: ${$observation.path}", 'product classification reason order differs')
$reason_index = $reason_index + 1
}
}
if $target_required {
expect_equal($result.reasons[$reason_index], 'target-affecting paths are compiled by the image producer', 'target classification reason differs')
}
return $result
}
let root = repository_root('versions.env')
let jq = require_jq()
mut temporary_parent = env('TMPDIR')
if $temporary_parent == null {
$temporary_parent = '/tmp'
}
let temporary = "$(^mktemp -d "$temporary_parent/flashos-classify.XXXXXX")"
if !$status.ok {
^printf '%s\n' 'classify changes: cannot create temporary directory' 1>&2
exit 1
}
let input = "$temporary/paths"
let decoded = "$temporary/paths.json"
let expanded = "$temporary/expanded.json"
let observations_raw = "$temporary/observations-raw.json"
let observations = "$temporary/observations.json"
let decision = "$temporary/decision.json"
let paths_json = "$temporary/paths-output.json"
let reason_documents = "$temporary/reason-documents.json"
let reasons_json = "$temporary/reasons.json"
let candidate = "$temporary/candidate.json"
let envelope = "$temporary/envelope.json"
let raw_payload = "$temporary/raw.json"
let payload_path = "$temporary/classification.json"
let errors = "$temporary/errors"
^printf '%s' '' > $input || exit 1
mut json_output = false
mut null_input = false
mut positional_count = 0
for argument in $args {
if $argument == '--json' {
$json_output = true
} else if $argument == '--null' {
$null_input = true
} else if $argument in ['-h', '--help'] {
^rm -rf $temporary
print_help()
} else if $argument != '' && $argument[0] == '-' {
^rm -rf $temporary
usage_error("unrecognized arguments: $argument")
} else {
^printf '%s\0' $argument >> $input || exit 1
$positional_count = $positional_count + 1
}
}
if $positional_count == 0 {
^cat > $input || exit 1
}
mut nul_mode = 'false'
if $null_input || $positional_count > 0 {
$nul_mode = 'true'
}
let project_paths = '
($raw | if $nul then split("\u0000") else split("\n") end) |
to_entries |
map(.key as $position | .value as $raw |
($raw | gsub("^\\s+|\\s+$"; "") | gsub("\\\\"; "/") | gsub("^(\\./)+"; "")) as $prepared |
($prepared | if . == "." then . else split("/") | map(select(. != "" and . != ".")) | join("/") end) as $normalized |
{
position:$position,
raw:$raw,
normalized:$normalized,
blank:($prepared == ""),
absolute:($prepared|startswith("/")),
parent_segment:($prepared|test("(^|/)\\.\\.(/|$)")),
low_risk_prefix:($normalized|test("^(\\.github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)/|docs/|LICENSES/)")),
adjacent_doc_prefix:($normalized|startswith("components/flash/docs/")),
markdown_suffix:($normalized|endswith(".md")),
fsh_suffix:($normalized|endswith(".fsh")),
component_prefix:($normalized|startswith("components/flash/")),
config_prefix:($normalized|startswith("config/")),
flash_recipe_prefix:($normalized|startswith("recipes/terminal/flash/")),
security_nested_manifest:($normalized|test("^components/flash/.+/Cargo\\.toml$"))
})'
^env $jq --null-input --argjson nul $nul_mode --rawfile raw $input $project_paths > $decoded 2> $errors
if !$status.ok {
^rm -rf $temporary
usage_error('cannot decode changed paths')
}
^env $jq '[group_by(.normalized)[] | {all:., selected:min_by(.position)}]' $decoded > $expanded || exit 1
try {
open $expanded \
| from json array \
| where {|item| first_occurrence($item.all, $item.selected)} \
| each {|item| select_observation($item)} \
| where {|observation| $observation != null} \
| sort path \
| collect \
| to json > $observations_raw
} catch error {
let message = $error.message
^rm -rf $temporary
usage_error($message)
}
^cp $observations_raw $observations || exit 1
try {
open $observations | from json | each {|values| decide($values)} | to json > $decision
} catch error {
let message = $error.message
^rm -rf $temporary
usage_error($message)
}
open $observations | from json array | get path | collect | to json > $paths_json
let selected_lane = "$(^env $jq --raw-output .lane $decision)"
let selected_image = "$(^env $jq --raw-output .image_required $decision)"
let selected_target = "$(^env $jq --raw-output .target_required $decision)"
let selected_security = "$(^env $jq --raw-output .security_required $decision)"
if $selected_lane == 'fast' {
^printf '%s' '"every changed path is explicitly isolated documentation, policy, reporting, or host tooling"' > $reason_documents || exit 1
} else if "$(^env $jq --raw-output 'length' $observations)" == '0' {
^printf '%s' '"no changed paths were supplied; qualification fails closed"' > $reason_documents || exit 1
} else {
^printf '%s' '"product or unknown paths require image and runtime qualification"' > $reason_documents || exit 1
open $observations \
| from json array \
| where {|observation| !$observation.low_risk} \
| each {|observation| product_reason($observation)} \
| to json >> $reason_documents
if $selected_target == 'true' {
^printf '%s' '"target-affecting paths are compiled by the image producer"' >> $reason_documents || exit 1
}
}
^env $jq --slurp . $reason_documents > $reasons_json || exit 1
let assemble = '{schema:1,lane:$lane,image_required:$image,target_required:$target,security_required:$security,reasons:$reasons[0],paths:$paths[0]}'
^env $jq --null-input \
--arg lane $selected_lane \
--argjson image $selected_image \
--argjson target $selected_target \
--argjson security $selected_security \
--slurpfile reasons $reasons_json \
--slurpfile paths $paths_json \
$assemble > $candidate || exit 1
^env $jq --slurpfile observations $observations '{document:., keys:(keys|sort), observations:$observations[0]}' $candidate > $envelope || exit 1
try {
open $envelope | from json | each {|value| validate($value)} | to json > $raw_payload
} catch error {
let message = $error.message
^rm -rf $temporary
usage_error($message)
}
^env $jq --compact-output --sort-keys . $raw_payload > $payload_path || exit 1
let lane = "$(^env $jq --raw-output .lane $payload_path)"
let image_required = "$(^env $jq --raw-output .image_required $payload_path)"
let target_required = "$(^env $jq --raw-output .target_required $payload_path)"
let security_required = "$(^env $jq --raw-output .security_required $payload_path)"
let payload = "$(^cat $payload_path)"
let github_output = env('GITHUB_OUTPUT')
if $github_output != null && $github_output != '' {
^printf '%s\n' \
"lane=$lane" \
"image_required=$image_required" \
"target_required=$target_required" \
"security_required=$security_required" \
"classification=$payload" >> $github_output || exit 1
}
let summary = env('GITHUB_STEP_SUMMARY')
if $summary != null && $summary != '' {
^printf '%s\n' \
'## FlashOS change classification' \
'' \
"- lane: `$lane`" \
"- image required: `$image_required`" \
"- target-affecting paths: `$target_required`" \
"- dependency policy required: `$security_required`" \
'- reasons:' >> $summary || exit 1
^env $jq --raw-output '.reasons[] | " - " + .' $payload_path >> $summary || exit 1
}
if $json_output || $github_output == null || $github_output == '' {
^cat $payload_path || exit 1
}
^rm -rf $temporary