From 97238fa1942c5262a01482009d528811b338a1e5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:59:59 +0100 Subject: [PATCH] fix(ci): clear red main checks on standards (detect, kyaml, K9, Hypatia) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci-pipeline: Haskell is detected only from a ROOT cabal.project or root *.cabal (`:(glob)*.cabal`), the only layout `cabal build` accepts at the repo root (Cabal-7136). Nested cabal/stack/hs trees are reported as an unsupported ecosystem instead of failing the gate (standards#1161). - scripts/kyaml-format.sh: restore the tested implementation (df20618b) that 5b56aec0 overwrote; YAML-POLICY.adoc and kyaml-format-test.sh pin exit 2 = refused, exit 1 = not KYAML (17/17 pass). - kyaml-migrate.sh, test_kyaml_comment_preservation.sh: mktemp instead of hardcoded /tmp paths (10 Hypatia hardcoded_tmp findings). - k9_contract.ncl: restore the `..` open-record tails that #1154 dropped from Signature/Metadata/Security/Pedigree/Component (spec 5.5), keeping #1154's template wording. Fixtures: 6 positive, 21 negative, 0 failures. - allowed-actions.json: add the lower-case swatinem/rust-cache@* spelling the live repo policy already allows (rust-ci-reusable.yml uses it). - apply-baseline.sh: the gate message counts kept findings AT OR ABOVE the threshold, not every kept finding (said 12 when 1 was high). - .hypatia-baseline.json: ACK RE001 on k9-contractile.yml under the #941 pending decision, and the K9-S001 negative-control fixture (exact path). - REGISTRY.a2ml: regenerated by scripts/build-registry.sh (3 source hashes). Ratchet-exception: .hypatia-baseline.json grows by 2 — RE001 k9-contractile.yml joins the #941 pending decision; the K9-S001 negative-control fixture must lack a pedigree Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/ci-pipeline.yml | 23 ++- .hypatia-baseline.json | 16 ++ .machine_readable/REGISTRY.a2ml | 8 +- 1-formats/k9/spec/contract/k9_contract.ncl | 62 +++++- .../actions-allowlist/allowed-actions.json | 1 + scripts/apply-baseline.sh | 4 +- scripts/kyaml-format.sh | 181 +++++++++--------- scripts/kyaml-migrate.sh | 17 +- scripts/test_kyaml_comment_preservation.sh | 4 +- 9 files changed, 213 insertions(+), 103 deletions(-) diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 237988754..3c65fc148 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -147,7 +147,16 @@ jobs: # proof scripts and Verilog sources, so globbing `*.v` would install # a V toolchain to lint a Coq development. N_V=$(count 'v.mod' '*/v.mod') - N_HASKELL=$(count '*.cabal' 'stack.yaml' '*/stack.yaml' '*.hs') + # Haskell is counted ONLY where its consuming job can run: the `haskell` + # job runs `cabal build all` at the REPOSITORY ROOT, which needs a root + # `cabal.project` or a root `*.cabal`. `:(glob)` stops `*` crossing `/`. + # The old marker set (`*.cabal`, `*.hs`, `stack.yaml` at any depth) + # went true on a nested satellite (standards: sanctify-php, + # palimpsest-license/TOOLS/validation/haskell) and then failed with + # Cabal-7136 "There is no .cabal package file or cabal.project" — + # a guard counting a different set than its consumer checks + # (standards#1161). Nested Haskell is reported below as unsupported. + N_HASKELL=$(count 'cabal.project' ':(glob)*.cabal') # ⚠ DO NOT REMOVE THE DENO PROBE. It does not exist to lint Deno — # it is the trigger for the REFUSAL job `deno` below, i.e. the teeth # of the estate's standing no-Deno ruling, with slugs still live in @@ -220,6 +229,14 @@ jobs: # which repositories get linted. probe 'Bun' 'bun.lock' '*/bun.lock' 'bun.lockb' '*/bun.lockb' 'bunfig.toml' '*/bunfig.toml' 'package.json' '*/package.json' + # Haskell the `haskell` job cannot build (no root cabal.project or + # `*.cabal`): nested projects, stack-only projects, loose `*.hs`. + # Probed only when N_HASKELL is 0 — under a root project, nested + # sources belong to it and ARE built. + if [ "$N_HASKELL" -eq 0 ]; then + probe 'Haskell (no root cabal.project/*.cabal)' '*.cabal' 'cabal.project' '*/cabal.project' 'stack.yaml' '*/stack.yaml' '*.hs' + fi + bool() { [ "$1" -gt 0 ] && echo true || echo false; } { @@ -246,7 +263,7 @@ jobs: echo "| Nickel | \`*.ncl\` excluding \`*.k9.ncl\` | ${N_NICKEL} |" echo "| ReScript | \`rescript.json\`, \`bsconfig.json\`, \`*.res\` | ${N_RESCRIPT} |" echo "| V | \`v.mod\` only | ${N_V} |" - echo "| Haskell | \`*.cabal\`, \`stack.yaml\`, \`*.hs\` | ${N_HASKELL} |" + echo "| Haskell | root \`cabal.project\` or root \`*.cabal\` | ${N_HASKELL} |" echo "| Deno | \`deno.json(c)\` | ${N_DENO} |" echo "| **Total checkable** | | **${TOTAL}** |" echo "" @@ -959,7 +976,7 @@ jobs: row "Deno" "$R_DENO" "BANNED — refusal gate; skipped means no deno.json(c) tracked (has_deno=${H_DENO})" row "ReScript" "$R_RESCRIPT" "no rescript.json/bsconfig.json/*.res tracked (has_rescript=${H_RESCRIPT})" row "V" "$R_V" "no v.mod tracked (has_v=${H_V})" - row "Haskell" "$R_HASKELL" "no *.cabal/stack.yaml/*.hs tracked (has_haskell=${H_HASKELL})" + row "Haskell" "$R_HASKELL" "no root cabal.project/*.cabal tracked (has_haskell=${H_HASKELL})" echo "" echo "_A skipped gate examined nothing. It is not a pass._" echo "" diff --git a/.hypatia-baseline.json b/.hypatia-baseline.json index 40b90b6ea..58f27f3b1 100644 --- a/.hypatia-baseline.json +++ b/.hypatia-baseline.json @@ -1663,5 +1663,21 @@ "note": "TRIAGED (round-2 fix-forward 2026-09-22; collapsed 2026-09-22 per #966): Hypatia emits invalid_actions_lock from TWO rule modules for ONE defect -- workflow_audit/parse_actions_lock (file `actions.lock`) and WH004 standalone (workflow_hardening, full path `.github/workflows/actions.lock`). Both verified by running the real scanner locally. The defect: asana/push-signed-commits@d615 (immutable pin, ref v1.3) declares transitive actions/setup-python@v2, which floats upstream and can never be a lock key. gh-actions-lock v0.1.6 verify says valid:true on the same file; Hypatia is stricter than the authoritative tool. Exposure is upstream float only. Exit: Asana pins setup-python, we replace the action, or Hypatia downgrades float-transitives. #947 deleted the metadata to silence the scanner; round 2 restored it verbatim and acked honestly. Tracking moved from #951 to #963 on 2026-09-22: #951 was closed COMPLETED with zero comments while the defect is demonstrably live (actions.lock:210 still declares the floating transitive under the asana pin, and setup-python is never a top-level lock key), because #963 superseded it. An acknowledgement expiring 2026-12-22 against a CLOSED issue has nobody owning its exit criteria -- the ack keeps working and the cure stops being anyone job. This was two entries until list-valued rule_module landed (#971) -- two entries meant two expiry dates for one decision, which is how a half-expired acknowledgement silently reopens a gate. DELIBERATE SCOPE CHANGE, recorded because it is a widening and not a refactor: the workflow_hardening half previously matched the exact path `.github/workflows/actions.lock`, and one entry carries one matcher, so it now matches `**actions.lock` -- the breadth the workflow_audit half already had at the same severity and type. Measured consequence: a workflow_hardening invalid_actions_lock at e.g. `vendor/actions.lock` was KEPT before and is SUPPRESSED now. Accepted because both halves are the same defect with the same exit criteria; if a second actions.lock is ever vendored, narrow this entry rather than trusting it.", "expires_at": "2026-12-22", "tracking_issue": "hyperpolymath/standards#963" + }, + { + "severity": "warn", + "rule_module": "research_extensions", + "type": "RE001", + "file": ".github/workflows/k9-contractile.yml", + "note": "ESTATE DECISION PENDING (hypatia triage 2026-09-22): adopt step-security/harden-runner or record a principled WONTFIX; the estate uses it nowhere today. Same decision as the other RE001 entries; this workflow's 'Publish the K9 verdict' job (GITHUB_TOKEN only) joined the set on 2026-10-06.", + "expires_at": "2026-12-22", + "tracking_issue": "hyperpolymath/standards#941" + }, + { + "severity": "high", + "rule_module": "code_safety", + "type": "ncl_k9_missing_pedigree", + "file": "1-formats/k9/tools/fixtures/invalid/L1-K9-S001-no-pedigree.k9.ncl", + "note": "FALSE POSITIVE BY DESIGN: this is the NEGATIVE CONTROL for K9-S001 (no pedigree). k9-validate.sh --fixtures requires it to be rejected at L1 for exactly this reason; adding a pedigree would destroy the control. Exact path, not a pattern, so a real component that loses its pedigree is still reported." } ] diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index 6943c2c47..d5c96f198 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -45,7 +45,7 @@ name = "K9 Self-Validating Components" stream = "foundation" home = "1-formats/k9/" canonical_doc = "1-formats/k9/README.adoc" -source_hash = "sha256:d10e71f64586a5c6faac5833d6a52225d22042170e63df0ad2076ee610be8831" +source_hash = "sha256:dbf336e383f49c098a9cc7a3126fe817b824f41c054a7d2977ff47150db59281" route = "the K9 specification, security analysis and adoption guidance (implementations live in hyperpolymath/k9-ecosystem)" [[spec]] @@ -153,7 +153,7 @@ name = "AXEL Protocol" stream = "protocol" home = "2-protocols/axel/" canonical_doc = "2-protocols/axel/README.adoc" -source_hash = "sha256:c67b62c6dcbb730664318eb235b25c687493bf74cabbc20af5a71c0d7849acea" +source_hash = "sha256:dbfe6d40af030e4dd23575d7f01dc86d1557b1d395534af73aa1ab0ac133c59c" route = "age-gating + explicit-content enforcement" [[spec]] @@ -207,7 +207,7 @@ name = "RSR — Rhodium Standard Repositories" stream = "governance" home = "rhodium-standard-repositories/" canonical_doc = "rhodium-standard-repositories/README.adoc" -source_hash = "sha256:8bcf1e52445efa2c7f9c83850ca98f7a92190230b76f897d8442c5a377e74c39" +source_hash = "sha256:daeb002c345125c6a5132a71ae07a120691840ca0048b49674a7aab02e40acad" route = "the repository-compliance standard every repo is graded against" [[spec]] @@ -216,7 +216,7 @@ name = "Session Management Standards" stream = "governance" home = "3-practice/session-management-standards/" canonical_doc = "3-practice/session-management-standards/README.adoc" -source_hash = "sha256:beea95b19ff9565abf30d34b758bfb669c5140fc5eef5046a494eafe20a7a91f" +source_hash = "sha256:9e3e5f7bc1469e0736359e3e85cba5311b8ed87056d137d86a98e81c4e3c4b5a" route = "continuity / verify / handover protocols" [[spec]] diff --git a/1-formats/k9/spec/contract/k9_contract.ncl b/1-formats/k9/spec/contract/k9_contract.ncl index da8c3176f..bb38dad32 100644 --- a/1-formats/k9/spec/contract/k9_contract.ncl +++ b/1-formats/k9/spec/contract/k9_contract.ncl @@ -282,6 +282,11 @@ in # The host supplies one Bool of EVIDENCE per precondition. Evidence is # runtime fact, not a static component field — which is why `check_level` # in leash.ncl can never authorise Hunt on its own. + # Host-side, and deliberately CLOSED unlike the component-facing records + # above: §9.2 says the host supplies one Boolean of evidence per + # precondition, so a misspelt evidence field must be an error rather than a + # silently ignored precondition. §5.5's openness is about a *component's* + # fields. HuntEvidence = { signature | Bool, policy | Bool, @@ -362,6 +367,10 @@ in payload_hash | String | optional, signed_at | String | optional, key_id | String | default = "primary", + # §5.5 — unknown fields are PERMITTED; see the `..` block above `Metadata`. + # A host that reads `signature.public_key` MUST NOT be the reason a future + # `counter_signature` field is rejected. + .. }, # ── §6 The component pedigree, v1 ──────────────────────────────────── @@ -372,16 +381,50 @@ in # never loaded; MIGRATION-1058 M3), with two normative tightenings: # `component_type` is REQUIRED (§6.2), and the security flags must be paid # for in the capability grant (§8.4). + # + # ── THE `..` TAIL, AND WHY EVERY COMPONENT-FACING RECORD HERE HAS ONE ── + # + # §5.5's table ends with "Unknown fields anywhere | PERMITTED. A reader SHOULD + # report them and MUST NOT consult them to relax any requirement of §8 or §9." + # That is normative prose, and by this file's own first rule the prose wins: + # where the two disagree, this file is the bug. + # + # They disagreed until 2026-10-05. A Nickel record contract is CLOSED by + # default — the sharp edge is recorded in Nickel's own integration suite, + # core/tests/integration/inputs/contracts/record_contract_extra_field.ncl, + # which asserts `EvalError::BlameError` with the message "extra field" and + # advises appending `, ..`. So a component carrying a legitimate extension + # field was blamed by this file while §5.5 permitted the field. The six + # `.machine_readable/contractiles/*/*.k9.ncl` components are the live case + # (they keep `semantics`, `contractile_verb`, `tier`, `authority`, + # `variance_schema`, `execution`, `failure_mode_defenses` in the pedigree; + # `probe_scope`, `probe_kinds_*`, `allow_filesystem_write_conditional` in + # security; `paired_xfile`, `paired_runner` in metadata), and + # `2-protocols/axel/config/*.k9.ncl` keep their payload at top level. + # + # Opening these five records is a CONFORMANCE fix, not a contract revision: + # the specified contract (the prose) never changed, so `contract_version` + # stays 1.0.0. It does not weaken §8 or §9 either — §5.5's own last clause + # forbids that, and the validator enforces the capability arithmetic over the + # extracted facts whatever an extension field claims (K9-S007, K9-S008). + # + # The positive control `tools/fixtures/valid/extension-fields.k9.ncl` exists + # so this cannot silently regress: it fails at L2 the moment any of these + # records closes again. Metadata = { name | String, version | String | default = "0.0.0", description | String | optional, author | String | optional, + # §5.5 — see the block comment above. The estate's contractiles add + # `paired_xfile` / `paired_runner` here. + .. }, Security = { - # §7 — closed set. An unknown tag is a contract violation, not a warning. + # §7 — closed set. An unknown LEASH TAG is a contract violation, not a + # warning; an unknown FIELD in this block is §5.5's business. leash | SecurityLevel, trust_level | String | default = "unset", allow_network | Bool | default = false, @@ -392,6 +435,11 @@ in signature_required | Bool | default = false, # §8.3 — the explicit grant. Default-deny: absent means empty. capabilities | CapabilityGrant | default = [], + # §5.5 — unknown fields are PERMITTED, and the prose names this block's own + # examples: "the estate's contractiles use `probe_scope` and + # `authorised_probes_only`". No conforming reader may let one of them + # weaken §8 or §9 — the grant above is what a host enforces. + .. }, Pedigree = { @@ -411,6 +459,12 @@ in side_effects | Array String | default = [], # §10.1 — the signature claim. Presence is per-leash; see Signature above. signature | Signature | optional, + # §5.5 — unknown fields are PERMITTED. This is the line MIGRATION-1058 M1 + # turned on: the contractile family keeps its own vocabulary in the pedigree + # (`semantics`, `contractile_verb`, `tier`, `authority`, `variance_schema`, + # `execution`, `failure_mode_defenses`), and nothing in this file may force + # those names out of it. + .. }, # The whole component: the pedigree plus the optional Nickel payload. @@ -435,6 +489,12 @@ in config | { _ : Dyn } | optional, recipes | { _ : Dyn } | optional, validation | { _ : Dyn } | optional, + # §5.5 — unknown fields are PERMITTED at top level too, which is the case + # MIGRATION-1058 M4 recorded for whoever first ran the corpus at L2: + # `2-protocols/axel/config/*.k9.ncl` keep their payload (`ci`, `local_dev`, + # `quality`; `metadata.k9.ncl`: `project`, `tech_stack`, `deployment`, + # `features`, `status`) beside `pedigree`. + .. }, # ── §11 Library dialect ────────────────────────────────────────────── diff --git a/rhodium-standard-repositories/actions-allowlist/allowed-actions.json b/rhodium-standard-repositories/actions-allowlist/allowed-actions.json index 777a2ee7e..e2f08d069 100644 --- a/rhodium-standard-repositories/actions-allowlist/allowed-actions.json +++ b/rhodium-standard-repositories/actions-allowlist/allowed-actions.json @@ -88,6 +88,7 @@ "SonarSource/sonarqube-scan-action@*", "step-security/harden-runner@*", "Swatinem/rust-cache@*", + "swatinem/rust-cache@*", "taiki-e/install-action@*", "trufflesecurity/trufflehog@*", "webfactory/ssh-agent@*" diff --git a/scripts/apply-baseline.sh b/scripts/apply-baseline.sh index b0f7f9f62..85ad9c83c 100755 --- a/scripts/apply-baseline.sh +++ b/scripts/apply-baseline.sh @@ -293,9 +293,11 @@ rank() { THRESHOLD_RANK="$(rank "$BLOCKING_THRESHOLD")" MAX_KEPT_RANK=0 +BLOCKING_COUNT=0 # kept findings at or above the threshold (the gate message names these, not all kept) while IFS= read -r sev; do r="$(rank "$sev")" if (( r > MAX_KEPT_RANK )); then MAX_KEPT_RANK=$r; fi + if (( r >= THRESHOLD_RANK )); then BLOCKING_COUNT=$((BLOCKING_COUNT + 1)); fi done < <(jq -r '.[].severity' <<<"$KEPT") KEPT_COUNT="$(jq 'length' <<<"$KEPT")" @@ -334,7 +336,7 @@ if [[ "$MODE" == "advisory" ]]; then fi if (( MAX_KEPT_RANK >= THRESHOLD_RANK )); then - echo "::error::Gate failed: $KEPT_COUNT unfiltered finding(s) at or above '$BLOCKING_THRESHOLD'." >&2 + echo "::error::Gate failed: $BLOCKING_COUNT unfiltered finding(s) at or above '$BLOCKING_THRESHOLD' ($KEPT_COUNT kept in total)." >&2 exit 1 fi diff --git a/scripts/kyaml-format.sh b/scripts/kyaml-format.sh index 328aa18bc..145b157d7 100755 --- a/scripts/kyaml-format.sh +++ b/scripts/kyaml-format.sh @@ -1,105 +1,112 @@ #!/usr/bin/env bash -# SPDX-License-Identifier: CC-BY-SA-4.0 -# KYAML formatter/linter for arbitrary YAML files -# Part of standards#1022: formatter/linter for arbitrary YAML +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) # -# Usage: -# kyaml-format.sh [--check] FILE... +# kyaml-format.sh — format arbitrary YAML as KYAML, or check that it already is +# (YAML-POLICY Y-3; KYAML adoption step 3, standards#1022). # -# Flags: -# --check Check only, exit 2 if file would be changed +# The rewriter is `yq -p yaml -o kyaml '.'`, verbatim — the one rewriter the +# step-2 comment-preservation proof (tools/yaml-comment-proof, standards#1021) +# covers. That proof does not generalise to any other rewriter, so this script +# deliberately adds none: no post-processing, no re-indenting, no sed. # -# Exit codes: -# 0 All files OK (no changes needed in --check mode, or all formatted successfully) -# 1 Error (parse failure, missing file, etc.) -# 2 File would be changed (--check mode only) -# -# Preconditions (per YAML-POLICY.adoc §3.3): -# - GitHub Actions must parse KYAML workflows (proven in standards#1020) -# - Comment preservation must be proven (standards#1021) +# Usage: +# kyaml-format.sh [--check] FILE... # -# This script runs `yq -o kyaml` verbatim and: -# - Refuses (exit 1) a file that does not parse -# - Refuses (exit 1) a file that has no final newline -# - Refuses (exit 2 in --check mode) a file that would change its data -# - Is idempotent: yq -o kyaml round-trips cleanly +# (default) rewrite each FILE in place as KYAML when it is not already. +# --check rewrite nothing; exit 1 if any FILE is not already KYAML. # -# KYAML definition (KEP-5295): -# - {} for every map and [] for every list (flow style) -# - every string value double-quoted; keys unquoted where unambiguous -# - trailing commas permitted -# - two-space indentation -# - --- document header +# Exit contract: +# 0 = every FILE is KYAML (after the rewrite, in format mode) +# 1 = --check: at least one FILE is not KYAML (each one is named) +# 2 = refused: no FILE given, a FILE is missing or unparseable, a FILE has no +# final newline, or the rewrite would change the parsed data. A refusal +# never rewrites that FILE and never counts as clean. # -# Note: KYAML is a subset of YAML, not a new format. Every existing YAML -# parser already handles KYAML syntax. - -set -euo pipefail - -CHECK_MODE=false -FILES=() +# Why the final-newline refusal: a `|` block scalar that ends at EOF with no +# line break is read differently by go-yaml (yq) and eemeli/yaml — measured on +# k9-contractile.yml, the trailing "\n" of its last run: body exists for one +# parser and not the other. Formatting such a file would silently pick one +# reading, so the script refuses it and names the fix instead. +set -uo pipefail -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - --check) - CHECK_MODE=true - shift - ;; - *) - FILES+=("$1") - shift - ;; - esac -done +MODE=format +if [ "${1:-}" = "--check" ]; then + MODE=check + shift +fi -if [[ ${#FILES[@]} -eq 0 ]]; then - echo "Error: No files specified" >&2 - echo "Usage: $0 [--check] FILE..." >&2 - exit 1 +if [ "$#" -eq 0 ]; then + echo "kyaml-format: no files given (usage: kyaml-format.sh [--check] FILE...)" >&2 + exit 2 fi -RETCODE=0 -CHANGED=false +command -v yq >/dev/null 2>&1 || { echo "kyaml-format: yq is not on PATH" >&2; exit 2; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT -for file in "${FILES[@]}"; do - if [[ ! -f "$file" ]]; then - echo "Error: File not found: $file" >&2 - exit 1 - fi +# canonical_data — prints the file's parsed value as key-sorted JSON; +# fails when the file does not parse. +canonical_data() { + yq -p yaml -o json -I 0 'sort_keys(..)' "$1" +} - # Check for final newline - if [[ -n "$(tail -c 1 "$file" 2>/dev/null)" ]]; then - echo "Error: File has no final newline: $file" >&2 - exit 1 - fi +# kyaml_of — writes the KYAML rendering of to ; +# fails when does not parse. +kyaml_of() { + yq -p yaml -o kyaml '.' "$1" > "$2" +} - # Try to parse with yq (dry run first) - if ! yq -o kyaml "$file" >/dev/null 2>&1; then - echo "Error: File does not parse as YAML: $file" >&2 - exit 1 - fi +# refuse — reports a file this script will not judge. +refuse() { + echo "::error file=$1::kyaml-format: REFUSED $1 — $2" >&2 + refused=$((refused + 1)) +} - if $CHECK_MODE; then - # Check if formatting would change the file - yq -o kyaml "$file" > /tmp/kyaml_check_$$ - if ! cmp -s "$file" /tmp/kyaml_check_$$; then - echo "File would be changed: $file" >&2 - CHANGED=true - fi - rm -f /tmp/kyaml_check_$$ - else - # Format the file in place - yq -i -o kyaml "$file" - if [[ $? -ne 0 ]]; then - echo "Error: Failed to format: $file" >&2 - exit 1 - fi - fi +refused=0 drift=0 clean=0 rewritten=0 +i=0 +for f in "$@"; do + i=$((i + 1)) + out="$TMP/$i.kyaml" + if [ ! -f "$f" ]; then + refuse "$f" "no such file"; continue + fi + if [ -s "$f" ] && [ -n "$(tail -c1 "$f")" ]; then + refuse "$f" "no final newline; add one first (a trailing block scalar is ambiguous without it)"; continue + fi + if ! kyaml_of "$f" "$out" 2>"$TMP/err"; then + refuse "$f" "does not parse: $(head -1 "$TMP/err")"; continue + fi + if cmp -s "$f" "$out"; then + clean=$((clean + 1)); continue + fi + # The rewrite is only acceptable when it preserves the data exactly. + if ! before=$(canonical_data "$f") || ! after=$(canonical_data "$out") || [ "$before" != "$after" ]; then + refuse "$f" "the KYAML rendering does not parse to the same data"; continue + fi + if [ "$MODE" = check ]; then + echo "::error file=$f::kyaml-format: $f is not KYAML — run scripts/kyaml-format.sh $f" >&2 + drift=$((drift + 1)) + else + cat "$out" > "$f" + echo "kyaml-format: rewrote $f" + rewritten=$((rewritten + 1)) + fi done -if $CHANGED; then - exit 2 +total=$# +if [ "$refused" -gt 0 ]; then + echo "kyaml-format: $refused of $total file(s) REFUSED — not judged, not clean" >&2 + exit 2 fi - -exit $RETCODE +if [ "$drift" -gt 0 ]; then + echo "kyaml-format: $drift of $total file(s) are not KYAML" >&2 + exit 1 +fi +if [ "$MODE" = check ]; then + echo "kyaml-format: all $total file(s) are KYAML" +else + echo "kyaml-format: $total file(s) KYAML ($rewritten rewritten, $clean already clean)" +fi +exit 0 diff --git a/scripts/kyaml-migrate.sh b/scripts/kyaml-migrate.sh index fa5a7fa03..34e33c98d 100755 --- a/scripts/kyaml-migrate.sh +++ b/scripts/kyaml-migrate.sh @@ -69,6 +69,11 @@ if $DRY_RUN; then exit 0 fi +# Scratch file for each candidate rewrite: private (mktemp), never a fixed +# /tmp name, and removed on every exit path. +NEW_FILE="$(mktemp)" +trap 'rm -f "$NEW_FILE"' EXIT + CHANGED=false ERRORS=0 CONVERTED=0 @@ -85,24 +90,24 @@ for file in "${YAML_FILES[@]}"; do fi # Try to parse with yq - if ! yq -o kyaml "$file" > /tmp/kyaml_new_$$ 2>&1; then + if ! yq -o kyaml "$file" > "$NEW_FILE" 2>&1; then echo "ERROR (parse failed)" ERRORS=$((ERRORS + 1)) - rm -f /tmp/kyaml_new_$$ + : > "$NEW_FILE" continue fi # Check if conversion would change the file - if cmp -s "$file" /tmp/kyaml_new_$$; then + if cmp -s "$file" "$NEW_FILE"; then echo "OK (already KYAML)" - rm -f /tmp/kyaml_new_$$ + : > "$NEW_FILE" continue fi if $CHECK_MODE; then echo "WOULD CHANGE" CHANGED=true - rm -f /tmp/kyaml_new_$$ + : > "$NEW_FILE" continue fi @@ -111,7 +116,7 @@ for file in "${YAML_FILES[@]}"; do echo "WOULD CONVERT" CHANGED=true else - mv /tmp/kyaml_new_$$ "$file" + cat "$NEW_FILE" > "$file" # keep the target mode; mktemp is 0600 echo "CONVERTED" CONVERTED=$((CONVERTED + 1)) fi diff --git a/scripts/test_kyaml_comment_preservation.sh b/scripts/test_kyaml_comment_preservation.sh index 639477c4b..a46939cf8 100755 --- a/scripts/test_kyaml_comment_preservation.sh +++ b/scripts/test_kyaml_comment_preservation.sh @@ -25,7 +25,9 @@ log() { } # Create a test file with various comment types -TEST_FILE="/tmp/kyaml_test_$$" +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT +TEST_FILE="$TEST_DIR/kyaml_test" cat > "$TEST_FILE" << 'EOF' # Header comment # Another header line