From 51a4f00fe611822dea7870f08b4ce66e4dced536 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:31:26 +0000 Subject: [PATCH 01/24] Account for scan coverage and add native PHP syntax checks --- Cargo.lock | 51 +++++- Cargo.toml | 2 + chapel/tests/expected_contract.json | 2 +- docs/php-coverage.adoc | 80 +++++++++ src/a2ml/mod.rs | 1 + src/assail/analyzer.rs | 196 +++++--------------- src/assail/coverage.rs | 269 ++++++++++++++++++++++++++++ src/assail/mod.rs | 4 + src/assail/php.rs | 148 +++++++++++++++ src/diagnostics.rs | 1 + src/kanren/core.rs | 1 + src/kanren/strategy.rs | 2 + src/main.rs | 37 +++- src/panll/mod.rs | 1 + src/query/mod.rs | 1 + src/report/sarif.rs | 25 +++ src/storage/mod.rs | 2 + src/types.rs | 9 +- tests/coverage_php_tests.rs | 145 +++++++++++++++ tests/panll_tests.rs | 1 + tests/report_tests.rs | 1 + tests/sarif_tests.rs | 2 + tests/seam_contract_tests.rs | 1 + tests/types_tests.rs | 1 + 24 files changed, 818 insertions(+), 165 deletions(-) create mode 100644 docs/php-coverage.adoc create mode 100644 src/assail/coverage.rs create mode 100644 src/assail/php.rs create mode 100644 tests/coverage_php_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 63b6054..67b8933 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -737,9 +737,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.55" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -1642,9 +1642,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fixedbitset" @@ -3066,6 +3066,8 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "tree-sitter", + "tree-sitter-php", "ureq", "walkdir", ] @@ -3810,6 +3812,8 @@ version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", + "indexmap", "itoa", "memchr", "serde", @@ -3841,9 +3845,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -4034,6 +4038,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -4277,6 +4287,35 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree-sitter" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d" +dependencies = [ + "cc", + "regex", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" + +[[package]] +name = "tree-sitter-php" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9779e392d38af4254f48be0e57b7eed4d50ec7a6e55a7e5e2e74df8f8f032d3" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" diff --git a/Cargo.toml b/Cargo.toml index a25377b..89b94fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ description = "Universal static analysis, stress testing, and logic-based bug si repository = "https://github.com/hyperpolymath/panic-attack" [dependencies] +tree-sitter = "=0.27.0" +tree-sitter-php = "=0.25.1" clap = { version = "4.6", features = ["derive"] } clap_complete = "4.6" clap_complete_nushell = "4.6" diff --git a/chapel/tests/expected_contract.json b/chapel/tests/expected_contract.json index 4db2ba2..5538dc0 100644 --- a/chapel/tests/expected_contract.json +++ b/chapel/tests/expected_contract.json @@ -1,6 +1,6 @@ { "_comment": "Fixture for the chapel-cli-contract CI gate. Lists the subset of the panic-attack CLI contract that the Chapel mass-panic harness depends on. The CI job runs `panic-attack describe-contract` and asserts every entry here is present in the live output. Adding a flag/mode to Chapel's argv builder requires adding it here; removing one from Rust without updating here is a drift the gate will catch.", - "report_schema_version": "2.5", + "report_schema_version": "2.6", "required_modes": [ "assail", "assault", diff --git a/docs/php-coverage.adoc b/docs/php-coverage.adoc new file mode 100644 index 0000000..c411d51 --- /dev/null +++ b/docs/php-coverage.adoc @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += PHP and scan coverage +:toc: + +== Assail schema 2.6 + +New assail reports include coverage independently of file_statistics. +The latter contains files with nonzero counters; it has never been a reliable +inventory. Legacy reports deserialize with coverage status unknown. + +Coverage status is complete, partial, failed, no_applicable_input or unknown. +Complete means every candidate in the declared traversal scope was analysed by +its stated adapter. It does not mean the code is secure, that every adapter +parses its language, or that dynamic attacks ran. + +Each input records its path, kind, language, outcome, analysis depth, exclusion +or failure reason, and a BLAKE3 content digest when read. Named build/vendor +directories are recorded as pruned roots; their contents are not enumerated. +Symlinks are recorded and never followed. A symlink scan root is rejected. +Unrecognised source/config formats are gaps; other extensions are explicitly +outside scope. The extension classifier is bounded and does not claim universal +recognition of extensionless or custom-language source. + +A source read is bounded to 16 MiB. Read/decode/parse failures retain the other +results and leave incomplete coverage. There is not yet a global analysis +deadline or an interprocedural PHP dataflow engine. + +== CLI and report compatibility + +panic-attack assail PATH --headless --fail-on-incomplete emits the report and +exits 2 for incomplete or empty analysis. The flag does not impose a severity +threshold. Without it, exploratory scans keep their previous exit behavior; +consumers must inspect coverage. Other operational failures retain the CLI's +existing error behavior. + +Headless stdout is JSON when no output file is selected. Attestation, when +requested, seals the emitted report including coverage; that binds the evidence, +not a claim that the target passed a security audit. + +The CLI contract's report_schema_version tracks assail at 2.6. The additive +report_schema_versions map distinguishes assail 2.6 from assault 2.5. +Legacy deserialize defaults remain 2.5 rather than upgrading historical reports. +SARIF run properties carry coverage too. + +== PHP vertical slice + +Pinned dependencies: tree-sitter 0.27.0 and tree-sitter-php 0.25.1. The native +mixed PHP/template grammar parses namespaces, classes, traits and closures. +Recovered syntax-error trees are not analysed. PHP, versioned PHP extensions, +PHTML and INC files enter this adapter. + +Three local, explicitly heuristic checks are implemented: + +* PHP-EMPTY-CALLABLE: concrete functions/methods with comment-only or empty bodies. + Intentional no-op hooks can be valid. +* PHP-ALGORITHM-SUBSTITUTION: a callable named blake3 invokes the sodium BLAKE2b + primitive. This is a contract-review signal, not a proven execution path. +* PHP-UNSERIALIZE: a call omits its object-instantiation options. Input trust and + allowed-class policy require review; no taint flow is claimed. + +Comments and string literals are not PHP calls. Abstract declarations are not +empty concrete functions. Cross-language heuristics still run separately; +their results are not upgraded to AST/dataflow evidence by PHP's parser. + +== Portfolio evidence and remaining work + +The fixtures reproduce empty WordPress crypto implementations and the Aegis +algorithm-substitution pattern from the 25 October 2026 audit. CLI tests cover +clean PHP without finding counters, malformed PHP, unsupported config input, +empty roots, symlink cycles and legacy report decoding. + +Next: contextual PHP taint, Composer/WordPress bootstrap and symbol resolution, +typed graph edges and stable per-rule fingerprints, resource/time budgets across +all rules, externally verified runtime/build/provenance evidence, and protocol +conformance harnesses. Haskell/Elixir retain their existing heuristic adapters; +this change does not claim native parsing for them. + +Do not infer PHP engine patch status from these source checks. Runtime provenance, +startup configuration and deployment isolation require independent evidence. + diff --git a/src/a2ml/mod.rs b/src/a2ml/mod.rs index 5ce8e5a..5c2cdda 100644 --- a/src/a2ml/mod.rs +++ b/src/a2ml/mod.rs @@ -829,6 +829,7 @@ mod tests { fn sample_assail_report() -> AssailReport { AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: PathBuf::from("src/main.rs"), language: Language::Rust, diff --git a/src/assail/analyzer.rs b/src/assail/analyzer.rs index 4b86ad1..ddc7ae9 100644 --- a/src/assail/analyzer.rs +++ b/src/assail/analyzer.rs @@ -395,7 +395,7 @@ impl Analyzer { let mut all_weak_points = Vec::new(); let mut file_statistics = Vec::new(); - let files = self.collect_source_files()?; + let (files, mut coverage) = super::coverage::discover(&self.target)?; let base = if self.target.is_dir() { self.target.clone() @@ -418,9 +418,23 @@ impl Analyzer { // Each source file is analyzed independently; this keeps weak-point attribution precise. for file in &files { - let raw_bytes = match fs::read(file) { + let raw_bytes = match (|| -> std::io::Result> { + use std::io::Read as _; + const MAX_BYTES: u64 = 16 * 1024 * 1024; + let mut bytes = Vec::new(); + fs::File::open(file)? + .take(MAX_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_BYTES { + return Err(std::io::Error::other( + "source exceeds 16 MiB analysis limit", + )); + } + Ok(bytes) + })() { Ok(b) => b, Err(e) => { + coverage.mark(file, "unreadable", Some(e.to_string()), None); if self.verbose { log::debug!("Skipping unreadable file: {} ({})", file.display(), e); } @@ -435,6 +449,12 @@ impl Analyzer { Err(_) => { let (cow, _, had_errors) = encoding_rs::WINDOWS_1252.decode(&raw_bytes); if had_errors { + coverage.mark( + file, + "decode_error", + Some("text decoding failed".into()), + Some(&raw_bytes), + ); if self.verbose { log::debug!( "Skipping non-text file: {} (neither UTF-8 nor Latin-1)", @@ -477,6 +497,18 @@ impl Analyzer { } match file_lang { + Language::Php => match super::php::analyze(&content, &rel_path) { + Ok(findings) => file_weak_points.extend(findings), + Err(error) => { + coverage.mark( + file, + "parse_error", + Some(error.to_string()), + Some(&raw_bytes), + ); + continue; + } + }, Language::Rust => { self.analyze_rust(&content, &mut file_stats, &mut file_weak_points, &rel_path)?; } @@ -696,6 +728,7 @@ impl Analyzer { // Cross-language security checks (run on all files) self.analyze_cross_language(&content, &mut file_weak_points, &rel_path)?; + coverage.mark(file, "analyzed", None, Some(&raw_bytes)); // Accumulate global stats global_stats.total_lines += file_stats.total_lines; global_stats.unsafe_blocks += file_stats.unsafe_blocks; @@ -765,8 +798,10 @@ impl Analyzer { .map(|wp| wp.with_parsed_location()) .collect(); + coverage.finish(); let mut report = AssailReport { - schema_version: "2.5".to_string(), + coverage: Box::new(coverage), + schema_version: "2.6".to_string(), program_path: self.target.clone(), language: self.language, frameworks, @@ -796,153 +831,21 @@ impl Analyzer { Ok(report) } - fn collect_source_files(&self) -> Result> { - let mut files = Vec::new(); - - if self.target.is_file() { - files.push(self.target.clone()); - } else { - // Directory mode performs a conservative recursive walk with language filtering. - self.walk_directory(&self.target, &mut files)?; - } - - Ok(files) - } - - fn walk_directory(&self, dir: &Path, files: &mut Vec) -> Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - - if path.is_dir() { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - // Skip build artifacts, hidden dirs, dependency dirs, and - // generated runtime artifacts (e.g. amuck mutation runs). - if ![ - "target", - "build", - "node_modules", - ".git", - "vendor", - "_build", - "_opam", - ".stack-work", - "dist-newstyle", - "deps", - "_deps", - "zig-cache", - ".zig-cache", - "zig-out", - ".elixir_ls", - ".lexical", - "__pycache__", - "ebin", - "_checkouts", - ".fetch", - ".hex", - ".nimble", - ".dub", - "obj", - "runtime", - // Vendored upstream snapshots — analysing these flags - // findings against code the project does not own. - ".yarn", - "idaptik-rescript13-staging", - "rescript-ecosystem", - ] - .contains(&name) - { - self.walk_directory(&path, files)?; - } - } else if path.is_file() { - let lang = Language::detect(path.to_str().unwrap_or("")); - if lang != Language::Unknown { - files.push(path); - } - } - } - - Ok(()) - } - fn detect_directory_language(dir: &Path) -> Result { + let (files, _) = super::coverage::discover(dir)?; let mut counts = std::collections::HashMap::new(); - - // Cap recursion depth for responsiveness on very large trees. - Self::count_languages_recursive(dir, &mut counts, 0)?; - - counts.remove(&Language::Unknown); - - counts + for path in files { + *counts + .entry(Language::detect(path.to_str().unwrap_or(""))) + .or_insert(0usize) += 1; + } + Ok(counts .into_iter() - .max_by_key(|(_, count)| *count) + .max_by_key(|(lang, count)| (*count, format!("{lang:?}"))) .map(|(lang, _)| lang) - .ok_or_else(|| anyhow::anyhow!("Could not detect language")) - } - - fn count_languages_recursive( - dir: &Path, - counts: &mut std::collections::HashMap, - depth: usize, - ) -> Result<()> { - if depth > 10 { - return Ok(()); - } - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - let name = entry.file_name(); - let name_str = name.to_str().unwrap_or(""); - - if path.is_dir() { - if name_str.starts_with('.') - || [ - "target", - "node_modules", - "vendor", - "build", - "_build", - "_opam", - ".stack-work", - "dist-newstyle", - "deps", - "zig-cache", - ".zig-cache", - "zig-out", - "ebin", - "external_corpora", - "third_party", - "testdata", - "test_fixtures", - "fixtures", - "corpus", - "corpora", - "runtime", - // Vendored upstream snapshots — see walk_directory. - "idaptik-rescript13-staging", - "rescript-ecosystem", - ] - .contains(&name_str) - { - continue; - } - Self::count_languages_recursive(&path, counts, depth + 1)?; - } else if path.is_file() { - let lang = Language::detect(path.to_str().unwrap_or("")); - *counts.entry(lang).or_insert(0) += 1; - } - } - Ok(()) + .unwrap_or(Language::Unknown)) } - // ============================================================ - // Original language analyzers - // ============================================================ - - /// Accumulate Rust statistics and append heuristic findings for `file_path`. - /// Dangerous-pattern checks exclude string contents, comments, `cfg`-selected - /// test bodies and `should_panic` bodies. Panic counts and findings also exclude - /// test paths and build scripts. fn analyze_rust( &self, content: &str, @@ -8205,10 +8108,7 @@ pub fn safe_get_x() -> Option { // --------------------------------------------------------------- fn walk_collects(root: &std::path::Path) -> Vec { - let analyzer = Analyzer::new(std::path::Path::new(".")).expect("analyzer construction"); - let mut files = Vec::new(); - analyzer.walk_directory(root, &mut files).expect("walk"); - files + super::super::coverage::discover(root).expect("walk").0 } #[test] diff --git a/src/assail/coverage.rs b/src/assail/coverage.rs new file mode 100644 index 0000000..1ba7ae4 --- /dev/null +++ b/src/assail/coverage.rs @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Explicit scan inventory. Finding counters are never a coverage denominator. +use crate::types::Language; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +const SKIP_DIRS: &[&str] = &[ + "target", + "build", + "node_modules", + ".git", + "vendor", + "_build", + "_opam", + ".stack-work", + "dist-newstyle", + "deps", + "_deps", + "zig-cache", + ".zig-cache", + "zig-out", + ".elixir_ls", + ".lexical", + "__pycache__", + "ebin", + "_checkouts", + ".fetch", + ".hex", + ".nimble", + ".dub", + "obj", + "runtime", + ".yarn", + "idaptik-rescript13-staging", + "rescript-ecosystem", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InputCoverage { + pub path: PathBuf, + pub kind: String, + pub status: String, + pub language: Language, + pub analysis: Option, + pub reason: Option, + pub content_blake3: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnalysisCoverage { + pub status: String, + pub mode: String, + pub scope: String, + pub candidate_files: usize, + pub analyzed_files: usize, + pub inputs: Vec, + #[serde(skip)] + index: std::collections::HashMap, +} + +impl Default for AnalysisCoverage { + fn default() -> Self { + Self { + status: "unknown".into(), + mode: "not_recorded".into(), + scope: "legacy report: coverage was not recorded".into(), + candidate_files: 0, + analyzed_files: 0, + inputs: Vec::new(), + index: Default::default(), + } + } +} + +impl AnalysisCoverage { + pub fn incomplete(&self) -> bool { + self.status != "complete" + } + pub fn mark( + &mut self, + path: &Path, + status: &str, + reason: Option, + bytes: Option<&[u8]>, + ) { + if let Some(input) = self + .index + .get(path) + .and_then(|index| self.inputs.get_mut(*index)) + { + input.status = status.into(); + input.reason = reason; + if let Some(bytes) = bytes { + input.content_blake3 = Some(blake3::hash(bytes).to_hex().to_string()); + } + } + } + pub fn finish(&mut self) { + self.inputs.sort_by(|a, b| a.path.cmp(&b.path)); + self.index = self + .inputs + .iter() + .enumerate() + .map(|(i, input)| (input.path.clone(), i)) + .collect(); + self.candidate_files = self + .inputs + .iter() + .filter(|i| i.kind == "file" && i.status != "excluded") + .count(); + self.analyzed_files = self + .inputs + .iter() + .filter(|i| i.status == "analyzed") + .count(); + let gaps = self + .inputs + .iter() + .any(|i| i.status != "analyzed" && i.status != "excluded"); + self.status = if self.analyzed_files == 0 { + if gaps { + "failed" + } else { + "no_applicable_input" + } + } else if gaps { + "partial" + } else { + "complete" + } + .into(); + } +} + +fn unsupported_candidate(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase() + .as_str(), + "php" + | "phtml" + | "inc" + | "kt" + | "swift" + | "cs" + | "fs" + | "scala" + | "r" + | "sql" + | "yaml" + | "yml" + | "toml" + | "json" + | "xml" + | "ps1" + | "html" + | "css" + ) || matches!( + path.file_name().and_then(|s| s.to_str()), + Some("Dockerfile" | "Containerfile" | "Makefile" | "Justfile" | "justfile") + ) +} + +pub fn discover(root: &Path) -> anyhow::Result<(Vec, AnalysisCoverage)> { + let meta = std::fs::symlink_metadata(root)?; + anyhow::ensure!( + !meta.file_type().is_symlink(), + "scan root must not be a symlink" + ); + let mut report = AnalysisCoverage { + mode: "static_assail".into(), + scope: "Explicit regular-file inventory; named build/vendor directories and symlinks excluded without following them. Unknown source/config formats are unsupported; other extensions are outside declared scope. Non-PHP adapters are heuristic, not parse or dataflow proofs. No dynamic attacks executed.".into(), + ..Default::default() + }; + let mut excluded = Vec::new(); + let walker = walkdir::WalkDir::new(root) + .follow_links(false) + .into_iter() + .filter_entry(|entry| { + let prune = entry.depth() > 0 + && entry.file_type().is_dir() + && entry + .file_name() + .to_str() + .is_some_and(|name| SKIP_DIRS.contains(&name)); + if prune { + excluded.push(InputCoverage { + path: entry.path().to_path_buf(), + kind: "directory".into(), + status: "excluded".into(), + language: Language::Unknown, + analysis: None, + reason: Some("build/vendor directory policy; subtree not enumerated".into()), + content_blake3: None, + }); + } + !prune + }); + let mut files = Vec::new(); + for entry in walker { + let entry = match entry { + Ok(e) => e, + Err(e) => { + report.inputs.push(InputCoverage { + path: e.path().unwrap_or(root).to_path_buf(), + kind: "unknown".into(), + status: "discovery_error".into(), + language: Language::Unknown, + analysis: None, + reason: Some(e.to_string()), + content_blake3: None, + }); + continue; + } + }; + if entry.file_type().is_dir() { + continue; + } + let path = entry.path(); + let language = Language::detect(path.to_str().unwrap_or("")); + let (status, reason, kind) = if !entry.file_type().is_file() { + ( + "excluded", + Some("non-regular input; symlinks are not followed".into()), + "non_regular", + ) + } else if language != Language::Unknown { + files.push(path.to_path_buf()); + ("pending", None, "file") + } else if unsupported_candidate(path) { + ( + "unsupported", + Some( + "no source adapter; project manifest checks are not whole-file analysis".into(), + ), + "file", + ) + } else { + ( + "excluded", + Some("extension outside declared source/config scope".into()), + "file", + ) + }; + report.inputs.push(InputCoverage { + path: path.to_path_buf(), + kind: kind.into(), + status: status.into(), + language, + analysis: (language != Language::Unknown).then(|| { + if language == Language::Php { + "tree-sitter-php 0.25.1; local T0 syntax checks; no cross-file taint" + } else { + "language and cross-language heuristics; no parser coverage claim" + } + .into() + }), + reason, + content_blake3: None, + }); + } + report.inputs.extend(excluded); + files.sort(); + report.finish(); + Ok((files, report)) +} diff --git a/src/assail/mod.rs b/src/assail/mod.rs index aaf369e..bb73f20 100644 --- a/src/assail/mod.rs +++ b/src/assail/mod.rs @@ -6,7 +6,9 @@ //! Pre-analyzes target programs to identify weak points and recommend attacks pub mod analyzer; +pub mod coverage; pub mod patterns; +pub mod php; use crate::kanren::core::{FactDB, LogicEngine}; use crate::kanren::crosslang::CrossLangAnalyzer; @@ -698,6 +700,7 @@ mod classifications_tests { ); let mut report = AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: tmp.path().to_path_buf(), language: Language::Rust, @@ -770,6 +773,7 @@ mod v255_context_suppression_tests { fn report_with(wp: Vec) -> AssailReport { AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: std::path::PathBuf::from("test"), language: crate::types::Language::Rust, diff --git a/src/assail/php.rs b/src/assail/php.rs new file mode 100644 index 0000000..54d49d2 --- /dev/null +++ b/src/assail/php.rs @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Native PHP parsing with deliberately local syntax checks. No taint proof. +use crate::types::{Severity, WeakPoint, WeakPointCategory}; +use tree_sitter::Node; + +fn finding( + path: &str, + node: Node<'_>, + category: WeakPointCategory, + description: &str, +) -> WeakPoint { + WeakPoint { + category, + location: Some(format!("{path}:{}", node.start_position().row + 1)), + file: None, + line: None, + severity: Severity::Medium, + description: description.into(), + recommended_attack: Vec::new(), + suppressed: false, + test_context: None, + } +} + +fn has_call(node: Node<'_>, text: &str, name: &str) -> bool { + let mut pending = vec![node]; + while let Some(n) = pending.pop() { + if n.kind() == "function_call_expression" + && n.child_by_field_name("function") + .and_then(|f| f.utf8_text(text.as_bytes()).ok()) + .is_some_and(|f| f.trim_start_matches('\\').eq_ignore_ascii_case(name)) + { + return true; + } + let mut cursor = n.walk(); + pending.extend(n.named_children(&mut cursor)); + } + false +} + +pub fn analyze(text: &str, path: &str) -> anyhow::Result> { + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&tree_sitter_php::LANGUAGE_PHP.into())?; + let tree = parser + .parse(text, None) + .ok_or_else(|| anyhow::anyhow!("PHP parser produced no tree"))?; + anyhow::ensure!( + !tree.root_node().has_error(), + "PHP syntax error: recovered trees are not analyzed" + ); + let mut findings = Vec::new(); + let mut pending = vec![tree.root_node()]; + while let Some(node) = pending.pop() { + if matches!(node.kind(), "function_definition" | "method_declaration") { + if let Some(body) = node.child_by_field_name("body") { + let mut cursor = body.walk(); + if body + .named_children(&mut cursor) + .all(|n| n.kind() == "comment") + { + findings.push(finding(path, node, WeakPointCategory::UncheckedError, + "PHP-EMPTY-CALLABLE: concrete callable has no executable body and may return null (T0 syntax heuristic; an intentional no-op may be valid)")); + } + let name = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(text.as_bytes()).ok()) + .unwrap_or(""); + if name.eq_ignore_ascii_case("blake3") + && has_call(body, text, "sodium_crypto_generichash") + { + findings.push(finding(path, node, WeakPointCategory::CryptoMisuse, + "PHP-ALGORITHM-SUBSTITUTION: blake3 callable invokes sodium_crypto_generichash (BLAKE2b); review exact algorithm/encoding contract (T0 syntax heuristic, not path proof)")); + } + } + } + if node.kind() == "function_call_expression" { + let function = node + .child_by_field_name("function") + .and_then(|n| n.utf8_text(text.as_bytes()).ok()) + .unwrap_or(""); + if function + .trim_start_matches('\\') + .eq_ignore_ascii_case("unserialize") + { + if let Some(args) = node.child_by_field_name("arguments") { + let mut cursor = args.walk(); + if args + .named_children(&mut cursor) + .filter(|n| n.kind() != "comment") + .count() + == 1 + { + findings.push(finding(path, node, WeakPointCategory::UnsafeDeserialization, + "PHP-UNSERIALIZE: call has no object-instantiation options; confirm input trust and allowed classes (T0 syntax heuristic; no source-to-sink proof)")); + } + } + } + } + let mut cursor = node.walk(); + pending.extend(node.named_children(&mut cursor)); + } + Ok(findings) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn detects_comment_only_callable_and_crypto_substitution() { + let source = r#"

Template

"#; + assert!(analyze(source, "fixture.phtml").unwrap().is_empty()); + } + #[test] + fn parse_errors_do_not_become_empty_successes() { + assert!(analyze(" false]);", + "b.php" + ) + .unwrap() + .is_empty()); + } +} diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 5890014..16c696c 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -210,6 +210,7 @@ fn check_panicbot_readiness() -> Diagnostic { // Verify JSON output contract by serialising a minimal AssailReport let test_report = crate::types::AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: std::path::PathBuf::from("/test"), language: crate::types::Language::Rust, diff --git a/src/kanren/core.rs b/src/kanren/core.rs index e0730c4..87667fa 100644 --- a/src/kanren/core.rs +++ b/src/kanren/core.rs @@ -1310,6 +1310,7 @@ mod tests { weak_points: Vec, ) -> AssailReport { AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: PathBuf::from("/tmp/test"), language: Language::Rust, diff --git a/src/kanren/strategy.rs b/src/kanren/strategy.rs index b6ea675..ca08757 100644 --- a/src/kanren/strategy.rs +++ b/src/kanren/strategy.rs @@ -289,6 +289,7 @@ mod tests { #[test] fn test_strategy_auto_select() { let report = AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: ".".into(), language: Language::Rust, @@ -322,6 +323,7 @@ mod tests { #[test] fn test_risk_weighted_ordering() { let report = AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: ".".into(), language: Language::Rust, diff --git a/src/main.rs b/src/main.rs index a7bc097..3346921 100644 --- a/src/main.rs +++ b/src/main.rs @@ -151,6 +151,10 @@ enum Commands { /// Headless mode: emit JSON to stdout, suppress interactive prompts (CI-safe) #[arg(long, default_value_t = false)] headless: bool, + + /// Exit 2 if the declared source/config scan is incomplete or empty. + #[arg(long)] + fail_on_incomplete: bool, }, /// Execute a single attack on a target program @@ -1393,9 +1397,10 @@ fn run_main() -> Result<()> { signing_key, browser_extension, headless, + fail_on_incomplete, } => { qprintln!( - cli.quiet, + cli.quiet || headless, "Running assail analysis on: {}", target.display() ); @@ -1405,7 +1410,7 @@ fn run_main() -> Result<()> { // Optionally start attestation chain before scanning let mut chain_builder = if attest { - qprintln!(cli.quiet, "Attestation enabled"); + qprintln!(cli.quiet || headless, "Attestation enabled"); Some(attestation::AttestationChainBuilder::begin( &target, &cli_args, )?) @@ -1449,13 +1454,23 @@ fn run_main() -> Result<()> { if let Some(output_path) = &output { fs::write(output_path, &report_json)?; - qprintln!(cli.quiet, "Report saved to: {}", output_path.display()); + qprintln!( + cli.quiet || headless, + "Report saved to: {}", + output_path.display() + ); } else if cli.quiet || headless { // Machine-readable mode: emit JSON to stdout for pipeline consumers. // `--headless` makes this explicit for CI callers that cannot pass --quiet. print!("{report_json}"); } else { println!("\nAssail Summary:"); + println!( + " Coverage: {} ({} analysed / {} candidate files)", + report.coverage.status, + report.coverage.analyzed_files, + report.coverage.candidate_files + ); println!(" Language: {:?}", report.language); println!(" Weak points: {}", report.weak_points.len()); println!(" Recommended attacks: {:?}", report.recommended_attacks); @@ -1476,11 +1491,15 @@ fn run_main() -> Result<()> { fs::write(&sidecar_path, attestation_json)?; qprintln!( - cli.quiet, + cli.quiet || headless, "Attestation written to: {}", sidecar_path.display() ); } + if fail_on_incomplete && report.coverage.incomplete() { + eprintln!("assail: incomplete coverage ({})", report.coverage.status); + std::process::exit(2); + } } Commands::Attack { @@ -2705,15 +2724,15 @@ fn run_main() -> Result<()> { .map(|a| a.get_id().to_string()) .collect(); global_flags.sort(); - // Report-schema version mirrors types.rs::assail_schema_version - // and types.rs::assault_schema_version (both "2.5" in v2.5.0). - // The chapel-cli-contract CI gate catches drift between this - // string and the serialiser defaults. + // The compatibility field tracks assail, consumed by Chapel. + // Other report families retain their existing schema versions; + // deserialize defaults preserve legacy inputs rather than upgrading them. let contract = serde_json::json!({ "schema_version": "1", "tool": "panic-attack", "cli_version": env!("CARGO_PKG_VERSION"), - "report_schema_version": "2.5", + "report_schema_version": "2.6", + "report_schema_versions": {"assail": "2.6", "assault": "2.5"}, "detachability": { "standalone": true, "orchestrator_agnostic": true, diff --git a/src/panll/mod.rs b/src/panll/mod.rs index 4f26a25..7a34a95 100644 --- a/src/panll/mod.rs +++ b/src/panll/mod.rs @@ -606,6 +606,7 @@ mod tests { AssaultReport { schema_version: "2.5".to_string(), assail_report: AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: PathBuf::from("/tmp/test-target"), language: Language::Rust, diff --git a/src/query/mod.rs b/src/query/mod.rs index 2357207..9b1ffad 100644 --- a/src/query/mod.rs +++ b/src/query/mod.rs @@ -772,6 +772,7 @@ mod tests { } fn assail(repo: &str, wps: Vec) -> AssailReport { AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: PathBuf::from(format!("/tmp/{}", repo)), language: Language::Rust, diff --git a/src/report/sarif.rs b/src/report/sarif.rs index 894e024..13a5cdf 100644 --- a/src/report/sarif.rs +++ b/src/report/sarif.rs @@ -27,6 +27,7 @@ pub struct SarifLog { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SarifRun { + pub properties: serde_json::Value, pub tool: SarifTool, pub results: Vec, } @@ -178,6 +179,29 @@ fn parse_location(loc: &str) -> (&str, Option) { /// Convert an AssailReport to SARIF JSON pub fn to_sarif(report: &AssailReport) -> Result { + let mut coverage = serde_json::to_value(&report.coverage)?; + let root = if report.program_path.is_dir() { + report.program_path.as_path() + } else { + report + .program_path + .parent() + .unwrap_or(std::path::Path::new(".")) + }; + if let Some(inputs) = coverage["inputs"].as_array_mut() { + for input in inputs { + if let Some(path) = input["path"].as_str() { + let path = std::path::Path::new(path); + input["path"] = serde_json::Value::String( + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .into_owned(), + ); + } + } + } + // Collect unique rules let mut seen_categories = std::collections::HashSet::new(); let mut rules = Vec::new(); @@ -227,6 +251,7 @@ pub fn to_sarif(report: &AssailReport) -> Result { schema: SARIF_SCHEMA.to_string(), version: SARIF_VERSION.to_string(), runs: vec![SarifRun { + properties: serde_json::json!({"coverage": coverage}), tool: SarifTool { driver: SarifToolComponent { name: "panic-attack".to_string(), diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 2ff4cb2..b3f12bb 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1406,6 +1406,7 @@ mod tests { fn sample_assemblyline(repo: &str, wps: Vec) -> AssemblylineReport { let assail = AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: PathBuf::from(format!("/tmp/{}", repo)), language: Language::Rust, @@ -1632,6 +1633,7 @@ mod tests { fn assemblyline_with_ffi(repo: &str) -> AssemblylineReport { let assail = AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: PathBuf::from(format!("/tmp/{}", repo)), language: Language::Rust, diff --git a/src/types.rs b/src/types.rs index c93b4bf..386944d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -27,6 +27,7 @@ pub enum Language { Python, JavaScript, Ruby, + Php, // === BEAM family === Elixir, @@ -113,6 +114,7 @@ impl Language { "js" | "mjs" | "cjs" => Language::JavaScript, "ts" | "tsx" | "jsx" => Language::JavaScript, "rb" => Language::Ruby, + "php" | "php3" | "php4" | "php5" | "php7" | "php8" | "phtml" | "inc" => Language::Php, // BEAM family "ex" | "exs" => Language::Elixir, @@ -212,6 +214,7 @@ impl Language { Language::Python => "python", Language::JavaScript => "javascript", Language::Ruby => "ruby", + Language::Php => "php", Language::WokeLang | Language::Eclexia | Language::MyLang @@ -602,8 +605,12 @@ pub struct FileStatistics { /// Assail analysis results #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssailReport { + /// Missing in legacy reports: defaults to unknown, never a fabricated pass. + #[serde(default)] + pub coverage: Box, /// Semantic version of the report schema. Consumers must check this - /// before parsing to detect incompatible drift. Current: "2.5". + /// before parsing to detect incompatible drift. New assail scans emit "2.6"; + /// the deserialize default preserves the legacy "2.5" contract. #[serde(default = "assail_schema_version")] pub schema_version: String, pub program_path: PathBuf, diff --git a/tests/coverage_php_tests.rs b/tests/coverage_php_tests.rs new file mode 100644 index 0000000..23370b1 --- /dev/null +++ b/tests/coverage_php_tests.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +use panic_attack::assail; +use std::process::Command; + +#[test] +fn coverage_is_not_the_finding_statistics_array() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("clean.php"), + " AssailReport { AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: PathBuf::from("/tmp/test-program"), language: Language::Rust, diff --git a/tests/sarif_tests.rs b/tests/sarif_tests.rs index 1997d76..e8adc37 100644 --- a/tests/sarif_tests.rs +++ b/tests/sarif_tests.rs @@ -13,6 +13,7 @@ use std::path::Path; fn make_test_report() -> AssailReport { AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: ".".into(), language: Language::Rust, @@ -147,6 +148,7 @@ fn test_sarif_rules_deduplicated() -> Result<(), Box> { #[test] fn test_sarif_empty_report() -> Result<(), Box> { let report = AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: ".".into(), language: Language::Unknown, diff --git a/tests/seam_contract_tests.rs b/tests/seam_contract_tests.rs index b66d5cb..fe4f53c 100644 --- a/tests/seam_contract_tests.rs +++ b/tests/seam_contract_tests.rs @@ -33,6 +33,7 @@ use serde_json::Value; fn minimal_assail_report() -> AssailReport { AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: std::path::PathBuf::from("/tmp/test"), language: Language::Rust, diff --git a/tests/types_tests.rs b/tests/types_tests.rs index 6561937..70b169b 100644 --- a/tests/types_tests.rs +++ b/tests/types_tests.rs @@ -214,6 +214,7 @@ fn weak_point_category_serializes() -> Result<(), serde_json::Error> { #[test] fn assail_report_serializes_to_json() -> Result<(), serde_json::Error> { let report = AssailReport { + coverage: Default::default(), schema_version: "2.5".to_string(), program_path: std::path::PathBuf::from("test/target"), language: Language::Rust, From 2ab2787045e2a9642cacaabbaac78bffeb8d248f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:39:48 +0000 Subject: [PATCH 02/24] fix(ci): follow canonical standards repository without changing pins --- .github/workflows/actions.lock | 14 +++++++------- .github/workflows/bridge-gate.yml | 2 +- .github/workflows/dogfood-gate.yml | 2 +- .github/workflows/governance.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/mirror.yml | 2 +- .github/workflows/rust-ci.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- .github/workflows/secret-scanner.yml | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 6578fc5..66e782e 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -40,15 +40,15 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.3' '.github/workflows/governance.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/hypatia-scan.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/instant-sync.yml': - 'peter-evans/repository-dispatch@v4.0.1' '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] '.github/workflows/mirror.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/deploy-pages@v5.0.1' @@ -63,15 +63,15 @@ workflows: - 'softprops/action-gh-release@v3.0.3' - 'swatinem/rust-cache@v2.9.3' '.github/workflows/rust-ci.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/scan-and-report.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/scorecard.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/secret-scanner.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' dependencies: 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': ref: 'v6.1.0' @@ -190,7 +190,7 @@ dependencies: commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' owner_id: 6759885 repo_id: 1352485172 - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7': + 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7': ref: '333b06a01db1874ffedb2de1bfabb195640927c7' commit: 'sha1-333b06a01db1874ffedb2de1bfabb195640927c7' owner_id: 6759885 diff --git a/.github/workflows/bridge-gate.yml b/.github/workflows/bridge-gate.yml index efadc8f..ffcc24b 100644 --- a/.github/workflows/bridge-gate.yml +++ b/.github/workflows/bridge-gate.yml @@ -18,7 +18,7 @@ # # Review notes: # - B2 (major -> review) is left to the existing human-review path. -# - Production hardening: a released binary or a hyperpolymath/standards reusable workflow. +# - Production hardening: a released binary or a metadatastician/standards reusable workflow. # - github context via env: only (no ${{ }} in run lines) per estate rules. name: bridge-gate on: diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index d86b910..9492960 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -98,7 +98,7 @@ jobs: :warning: **No .a2ml/.deed manifest files found.** Every RSR-compliant repo should have a repo deed (`_chora.deed`) at its root. - Author them per hyperpolymath/standards `1-formats/k9/spec/K9-CONTRACT-SPEC.adoc` (D173). + Author them per metadatastician/standards `1-formats/k9/spec/K9-CONTRACT-SPEC.adoc` (D173). EOF else echo "## K9 Contract Validation" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index c19baf7..1b399be 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -17,4 +17,4 @@ permissions: jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/governance-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 9bf8587..ca08c01 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -20,4 +20,4 @@ permissions: jobs: hypatia: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 7c43488..7b36821 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -15,7 +15,7 @@ permissions: jobs: mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 secrets: GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 73b1435..55038ec 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -3,7 +3,7 @@ # This workflow is managed by gh actions-lock. # This workflow is managed by gh actions-lock. # Rust CI — thin wrapper calling the shared estate reusable in -# hyperpolymath/standards. Configure once, propagate everywhere. +# metadatastician/standards. Configure once, propagate everywhere. # See: docs/CI-REUSABLE-WORKFLOWS.adoc in standards. name: Rust CI @@ -18,4 +18,4 @@ permissions: jobs: rust-ci: - uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # re-pinned 2026-07-01: toolchain-required-input fix (standards#439) + uses: metadatastician/standards/.github/workflows/rust-ci-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # re-pinned 2026-07-01: toolchain-required-input fix (standards#439) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d1ca432..e4486ba 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -15,7 +15,7 @@ permissions: jobs: scorecard: - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 permissions: actions: read # required by scorecard-reusable.yml to read workflow metadata contents: read diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index c839b81..67addca 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -21,4 +21,4 @@ jobs: scan: permissions: contents: read - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 From a7ac81a5ae7a1e7491aec041d817f7e1c6c94de3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:45:13 +0000 Subject: [PATCH 03/24] fix(deps): update rustls-webpki for RUSTSEC-2026-0382 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67b8933..99f7a16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3680,9 +3680,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", From 4ecec1880133239dbd0fee3caeb83fa019571411 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:49:43 +0000 Subject: [PATCH 04/24] Update src/assail/php.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- src/assail/php.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/assail/php.rs b/src/assail/php.rs index 54d49d2..5a00408 100644 --- a/src/assail/php.rs +++ b/src/assail/php.rs @@ -55,9 +55,14 @@ pub fn analyze(text: &str, path: &str) -> anyhow::Result> { if matches!(node.kind(), "function_definition" | "method_declaration") { if let Some(body) = node.child_by_field_name("body") { let mut cursor = body.walk(); - if body - .named_children(&mut cursor) - .all(|n| n.kind() == "comment") + let is_constructor = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(text.as_bytes()).ok()) + .is_some_and(|n| n.eq_ignore_ascii_case("__construct")); + if !is_constructor + && body + .named_children(&mut cursor) + .all(|n| n.kind() == "comment") { findings.push(finding(path, node, WeakPointCategory::UncheckedError, "PHP-EMPTY-CALLABLE: concrete callable has no executable body and may return null (T0 syntax heuristic; an intentional no-op may be valid)")); From 1d895c3ab5fe4eb809d4e5e7d7b906199dd04758 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:49:59 +0000 Subject: [PATCH 05/24] Update src/main.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.rs b/src/main.rs index 3346921..821b396 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1498,6 +1498,10 @@ fn run_main() -> Result<()> { } if fail_on_incomplete && report.coverage.incomplete() { eprintln!("assail: incomplete coverage ({})", report.coverage.status); + let _ = kin::write_error_heartbeat(format!( + "assail: incomplete coverage ({})", + report.coverage.status + )); std::process::exit(2); } } From 0ce082d179a94f669f4776b8e7caba07022bb475 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:56:20 +0000 Subject: [PATCH 06/24] docs: document scan coverage, PHP analysis, and SARIF behavior --- src/assail/analyzer.rs | 15 +++++++++++++++ src/assail/coverage.rs | 23 +++++++++++++++++++++++ src/assail/php.rs | 13 +++++++++++++ src/main.rs | 2 ++ src/report/sarif.rs | 7 ++++++- src/types.rs | 2 ++ 6 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/assail/analyzer.rs b/src/assail/analyzer.rs index ddc7ae9..036c4ba 100644 --- a/src/assail/analyzer.rs +++ b/src/assail/analyzer.rs @@ -373,6 +373,15 @@ impl Analyzer { self.analyze_inner(None) } + /// Analyse discovered source files and return findings, statistics and coverage. + /// UTF-8 decoding falls back to Windows-1252. Files larger than 16 MiB, read + /// or decoding failures, and PHP parser failures are skipped and remain + /// coverage gaps in a successful report. + /// If supplied, `accumulator` records candidate parent directories and files + /// that were read and decoded, including PHP files that subsequently fail parsing. + /// + /// # Errors + /// Propagates root metadata errors and symlink-root rejection from discovery. fn analyze_inner( &self, mut accumulator: Option<&mut crate::attestation::EvidenceAccumulator>, @@ -831,6 +840,12 @@ impl Analyzer { Ok(report) } + /// Choose the most common language among discovered source files, or `Unknown` + /// if none are found. Ties favour the lexicographically greatest debug name. + /// + /// # Errors + /// Propagates root metadata errors and symlink-root rejection from discovery; + /// later traversal errors are recorded there and do not prevent a result. fn detect_directory_language(dir: &Path) -> Result { let (files, _) = super::coverage::discover(dir)?; let mut counts = std::collections::HashMap::new(); diff --git a/src/assail/coverage.rs b/src/assail/coverage.rs index 1ba7ae4..20250e2 100644 --- a/src/assail/coverage.rs +++ b/src/assail/coverage.rs @@ -60,6 +60,7 @@ pub struct AnalysisCoverage { } impl Default for AnalysisCoverage { + /// Represent unrecorded coverage with unknown status and an empty inventory. fn default() -> Self { Self { status: "unknown".into(), @@ -74,9 +75,15 @@ impl Default for AnalysisCoverage { } impl AnalysisCoverage { + /// Return whether coverage is anything other than `complete`, including + /// unknown coverage and scans with no applicable input. pub fn incomplete(&self) -> bool { self.status != "complete" } + /// Replace an indexed input's status and reason, ignoring unindexed paths. + /// When supplied, `bytes` replaces its BLAKE3 digest; `None` keeps the old + /// digest. Counts and overall status are updated only by [`Self::finish`]. + /// After deserialisation, call `finish` first to rebuild the path index. pub fn mark( &mut self, path: &Path, @@ -96,6 +103,10 @@ impl AnalysisCoverage { } } } + /// Sort inputs by path, rebuild their index and recompute counts and status. + /// Non-excluded files are candidates; any input neither analysed nor excluded + /// is a gap. With analysed inputs, gaps yield `partial`, otherwise `complete`. + /// Without analysed inputs, gaps yield `failed`, otherwise `no_applicable_input`. pub fn finish(&mut self) { self.inputs.sort_by(|a, b| a.path.cmp(&b.path)); self.index = self @@ -133,6 +144,8 @@ impl AnalysisCoverage { } } +/// Recognise selected source/config extensions case-insensitively and build-file +/// names case-sensitively as candidates even when no language adapter matches. fn unsupported_candidate(path: &Path) -> bool { matches!( path.extension() @@ -164,6 +177,16 @@ fn unsupported_candidate(path: &Path) -> bool { ) } +/// Inventory a file or directory tree and return sorted paths with known languages. +/// Named build/vendor subdirectories are recorded without enumerating their +/// contents; symlinks and other non-regular inputs are excluded. Selected unknown +/// source/config formats are unsupported; other unknown formats are excluded. +/// Returned candidates are still `pending`: use [`AnalysisCoverage::mark`] and +/// [`AnalysisCoverage::finish`] after analysis to update the provisional coverage. +/// +/// # Errors +/// Returns root metadata errors and rejects a symlink root. Later traversal +/// errors are recorded as `discovery_error` inputs while discovery continues. pub fn discover(root: &Path) -> anyhow::Result<(Vec, AnalysisCoverage)> { let meta = std::fs::symlink_metadata(root)?; anyhow::ensure!( diff --git a/src/assail/php.rs b/src/assail/php.rs index 5a00408..1fb75c0 100644 --- a/src/assail/php.rs +++ b/src/assail/php.rs @@ -4,6 +4,7 @@ use crate::types::{Severity, WeakPoint, WeakPointCategory}; use tree_sitter::Node; +/// Build a medium-severity finding at `path` and the node's one-based start line. fn finding( path: &str, node: Node<'_>, @@ -23,6 +24,8 @@ fn finding( } } +/// Check a subtree for a function call whose source name matches `name`, ignoring +/// ASCII case and leading backslashes. `text` must be the tree's source text. fn has_call(node: Node<'_>, text: &str, name: &str) -> bool { let mut pending = vec![node]; while let Some(n) = pending.pop() { @@ -39,6 +42,16 @@ fn has_call(node: Node<'_>, text: &str, name: &str) -> bool { false } +/// Parse PHP/template source and return local syntax findings, possibly empty. +/// Checks cover empty or comment-only named functions/methods except constructors, +/// `blake3` callables containing a `sodium_crypto_generichash` call, and +/// `unserialize` calls with exactly one non-comment argument. These are +/// heuristics without name resolution or taint analysis. `path` labels finding +/// locations with one-based lines; it is not read from disk. +/// +/// # Errors +/// Propagates parser language initialisation errors and returns an error if no +/// tree is produced or the tree contains syntax errors, rather than findings. pub fn analyze(text: &str, path: &str) -> anyhow::Result> { let mut parser = tree_sitter::Parser::new(); parser.set_language(&tree_sitter_php::LANGUAGE_PHP.into())?; diff --git a/src/main.rs b/src/main.rs index 821b396..ddfc027 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1372,6 +1372,8 @@ fn main() -> Result<()> { /// Parse process arguments and execute the selected command, including its output /// and requested persistence. Default manifest settings are used if loading fails. /// Argument parsing exits the process for help or invalid CLI syntax. +/// For assail with `--fail-on-incomplete`, exits with status 2 when coverage is +/// not `complete`, after report output and any requested attestation are written. /// /// # Errors /// Returns command validation errors and propagates I/O, serialisation and diff --git a/src/report/sarif.rs b/src/report/sarif.rs index 13a5cdf..7483c06 100644 --- a/src/report/sarif.rs +++ b/src/report/sarif.rs @@ -177,7 +177,12 @@ fn parse_location(loc: &str) -> (&str, Option) { (loc, None) } -/// Convert an AssailReport to SARIF JSON +/// Convert findings to a SARIF 2.1.0 log with coverage in the run's properties. +/// Coverage paths are made relative to the target directory (or the parent +/// of a file target) when that prefix matches; other paths are retained. +/// +/// # Errors +/// Propagates errors serialising coverage to a JSON value, including non-UTF-8 paths. pub fn to_sarif(report: &AssailReport) -> Result { let mut coverage = serde_json::to_value(&report.coverage)?; let root = if report.program_path.is_dir() { diff --git a/src/types.rs b/src/types.rs index 386944d..2fc397f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -97,6 +97,8 @@ pub enum Language { } impl Language { + /// Infer the language from the final, case-sensitive extension without reading + /// the file. Missing or unrecognised extensions yield `Unknown`. pub fn detect(path: &str) -> Self { let ext = std::path::Path::new(path) .extension() From 7ead34756dd2eb5fc73807ad308084f70f7c2319 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:57:49 +0000 Subject: [PATCH 07/24] fix: serialize non-UTF-8 coverage paths and load hexads larger than 16 MiB --- src/assail/coverage.rs | 8 +++++++ src/storage/mod.rs | 42 +++++++++++++++++++++++-------------- tests/coverage_php_tests.rs | 30 ++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/assail/coverage.rs b/src/assail/coverage.rs index 20250e2..1da55c3 100644 --- a/src/assail/coverage.rs +++ b/src/assail/coverage.rs @@ -38,6 +38,7 @@ const SKIP_DIRS: &[&str] = &[ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InputCoverage { + #[serde(serialize_with = "serialize_path_lossy")] pub path: PathBuf, pub kind: String, pub status: String, @@ -47,6 +48,13 @@ pub struct InputCoverage { pub content_blake3: Option, } +fn serialize_path_lossy( + path: &Path, + serializer: S, +) -> Result { + serializer.serialize_str(&path.to_string_lossy()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnalysisCoverage { pub status: String, diff --git a/src/storage/mod.rs b/src/storage/mod.rs index b3f12bb..6bc3750 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -771,11 +771,6 @@ fn write_crosslang_hexads(hexads: &[PanicAttackHexad], base_dir: &Path) -> Resul // Issue #33 S2 — campaign-state hexad write/load helpers // --------------------------------------------------------------------------- -/// Maximum size (in bytes) of a single hexad JSON file we'll load from -/// disk. Hexads are small documents; anything past 16 MiB is corrupted -/// or hostile. -const HEXAD_FILE_READ_LIMIT: u64 = 16 * 1024 * 1024; - /// Build a campaign-state hexad for one lifecycle event (issue #33 S2). /// /// The id is derived from the current millisecond timestamp, so calls in the @@ -833,8 +828,6 @@ pub fn write_campaign_hexad(hexad: &PanicAttackHexad, base_dir: &Path) -> Result /// effort" reader used by status/query subcommands, not a validation /// pass. Returns hexads in filesystem-order (the caller sorts as needed). fn load_hexad_dir(dir: &Path) -> Result> { - use std::io::Read; - if !dir.exists() { return Ok(Vec::new()); } @@ -844,17 +837,10 @@ fn load_hexad_dir(dir: &Path) -> Result> { if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } - let mut content = String::new(); - let Ok(file) = fs::File::open(&path) else { + // Aggregate reports can include large input ledgers; read the whole document. + let Ok(content) = fs::read_to_string(&path) else { continue; }; - if file - .take(HEXAD_FILE_READ_LIMIT) - .read_to_string(&mut content) - .is_err() - { - continue; - } if let Ok(hexad) = serde_json::from_str::(&content) { hexads.push(hexad); } @@ -1382,6 +1368,30 @@ mod tests { assert_eq!("bogus".parse::(), Err(())); } + #[test] + fn load_aggregate_hexads_preserves_large_input_ledgers() { + let dir = tempfile::tempdir().unwrap(); + let hexad_dir = dir.path().join("hexads"); + fs::create_dir_all(&hexad_dir).unwrap(); + let report = sample_assemblyline("demo", vec![]); + let mut hexad = build_assemblyline_hexad(&report).unwrap(); + let inputs = serde_json::json!([ + { "path": "first.php", "reason": "x".repeat(16 * 1024 * 1024) }, + { "path": "last.php", "status": "analyzed" } + ]); + hexad.document["results"][0]["report"]["coverage"]["inputs"] = inputs.clone(); + let path = hexad_dir.join("large.json"); + fs::write(&path, serde_json::to_vec(&hexad).unwrap()).unwrap(); + assert!(fs::metadata(&path).unwrap().len() > 16 * 1024 * 1024); + + let loaded = load_aggregate_hexads(dir.path()).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!( + loaded[0].document["results"][0]["report"]["coverage"]["inputs"], + inputs + ); + } + // ----- Issue #33 S1: per-finding hexad tests ----------------------- use crate::assemblyline::{AssemblylineReport, RepoResult}; diff --git a/tests/coverage_php_tests.rs b/tests/coverage_php_tests.rs index 23370b1..9d4f37f 100644 --- a/tests/coverage_php_tests.rs +++ b/tests/coverage_php_tests.rs @@ -52,6 +52,36 @@ fn malformed_php_and_unsupported_inputs_are_visible() { .any(|f| f.description.starts_with("PHP-EMPTY-CALLABLE:"))); } +#[cfg(unix)] +#[test] +fn non_utf8_input_paths_serialize_without_losing_index_lookups() { + use std::os::unix::ffi::OsStringExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir + .path() + .join(std::ffi::OsString::from_vec(b"invalid-\xff.php".to_vec())); + let content = b" Date: Sun, 25 Oct 2026 17:11:57 +0000 Subject: [PATCH 08/24] fix: preserve non-UTF-8 coverage path bytes in JSON and SARIF --- docs/php-coverage.adoc | 6 ++++ src/assail/coverage.rs | 42 ++++++++++++++++++---- src/report/sarif.rs | 23 +++++-------- tests/coverage_php_tests.rs | 69 +++++++++++++++++++++++++++++++++++++ tests/sarif_tests.rs | 64 ++++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 20 deletions(-) diff --git a/docs/php-coverage.adoc b/docs/php-coverage.adoc index c411d51..a554e82 100644 --- a/docs/php-coverage.adoc +++ b/docs/php-coverage.adoc @@ -21,6 +21,12 @@ Unrecognised source/config formats are gaps; other extensions are explicitly outside scope. The extension classifier is bounded and does not claim universal recognition of extensionless or custom-language source. +Coverage `path` strings use lossy UTF-8 for readability. On Unix, non-UTF-8 +paths additionally include `path_bytes_hex`, the hex-encoded original path +bytes; Unix readers use these bytes to restore the exact path. Older reports +without this field still load. In SARIF, both representations refer to the +path relative to the scan root when that prefix matches. + A source read is bounded to 16 MiB. Read/decode/parse failures retain the other results and leave incomplete coverage. There is not yet a global analysis deadline or an interprocedural PHP dataflow engine. diff --git a/src/assail/coverage.rs b/src/assail/coverage.rs index 1da55c3..c6af887 100644 --- a/src/assail/coverage.rs +++ b/src/assail/coverage.rs @@ -38,7 +38,7 @@ const SKIP_DIRS: &[&str] = &[ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InputCoverage { - #[serde(serialize_with = "serialize_path_lossy")] + #[serde(flatten, with = "coverage_path")] pub path: PathBuf, pub kind: String, pub status: String, @@ -48,11 +48,41 @@ pub struct InputCoverage { pub content_blake3: Option, } -fn serialize_path_lossy( - path: &Path, - serializer: S, -) -> Result { - serializer.serialize_str(&path.to_string_lossy()) +// Keep the readable path string compatible with existing reports. On Unix, +// non-UTF-8 paths also carry their exact bytes as hex for lossless round trips. +mod coverage_path { + use serde::{ser::SerializeMap, Deserialize, Deserializer, Serializer}; + use std::path::{Path, PathBuf}; + + pub fn serialize(path: &Path, serializer: S) -> Result { + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("path", &path.to_string_lossy())?; + #[cfg(unix)] + if path.to_str().is_none() { + use std::os::unix::ffi::OsStrExt; + map.serialize_entry("path_bytes_hex", &hex::encode(path.as_os_str().as_bytes()))?; + } + map.end() + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct StoredPath { + path: PathBuf, + #[cfg(unix)] + #[serde(default)] + path_bytes_hex: Option, + } + + let stored = StoredPath::deserialize(deserializer)?; + #[cfg(unix)] + if let Some(encoded) = stored.path_bytes_hex { + use std::os::unix::ffi::OsStringExt; + let bytes = hex::decode(encoded).map_err(serde::de::Error::custom)?; + return Ok(std::ffi::OsString::from_vec(bytes).into()); + } + Ok(stored.path) + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/report/sarif.rs b/src/report/sarif.rs index 7483c06..c9e6b40 100644 --- a/src/report/sarif.rs +++ b/src/report/sarif.rs @@ -182,9 +182,9 @@ fn parse_location(loc: &str) -> (&str, Option) { /// of a file target) when that prefix matches; other paths are retained. /// /// # Errors -/// Propagates errors serialising coverage to a JSON value, including non-UTF-8 paths. +/// Propagates errors serialising coverage to a JSON value. pub fn to_sarif(report: &AssailReport) -> Result { - let mut coverage = serde_json::to_value(&report.coverage)?; + let mut coverage = report.coverage.clone(); let root = if report.program_path.is_dir() { report.program_path.as_path() } else { @@ -193,19 +193,14 @@ pub fn to_sarif(report: &AssailReport) -> Result { .parent() .unwrap_or(std::path::Path::new(".")) }; - if let Some(inputs) = coverage["inputs"].as_array_mut() { - for input in inputs { - if let Some(path) = input["path"].as_str() { - let path = std::path::Path::new(path); - input["path"] = serde_json::Value::String( - path.strip_prefix(root) - .unwrap_or(path) - .to_string_lossy() - .into_owned(), - ); - } - } + for input in &mut coverage.inputs { + input.path = input + .path + .strip_prefix(root) + .unwrap_or(&input.path) + .to_path_buf(); } + let coverage = serde_json::to_value(&coverage)?; // Collect unique rules let mut seen_categories = std::collections::HashSet::new(); diff --git a/tests/coverage_php_tests.rs b/tests/coverage_php_tests.rs index 9d4f37f..a4e8070 100644 --- a/tests/coverage_php_tests.rs +++ b/tests/coverage_php_tests.rs @@ -82,6 +82,75 @@ fn non_utf8_input_paths_serialize_without_losing_index_lookups() { assert_eq!(report.coverage.inputs[0].path, path); } +#[cfg(unix)] +#[test] +fn colliding_lossy_paths_round_trip_with_distinct_bytes() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let dir = tempfile::tempdir().unwrap(); + let paths: Vec<_> = [ + b"invalid-\xfe.php".as_slice(), + b"invalid-\xff.php".as_slice(), + "invalid-\u{fffd}.php".as_bytes(), + ] + .into_iter() + .map(|name| dir.path().join(std::ffi::OsString::from_vec(name.to_vec()))) + .collect(); + for path in &paths { + std::fs::write(path, "(invalid).is_err()); + } + } +} + #[test] fn headless_output_is_json_and_strict_gate_preserves_the_report() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/sarif_tests.rs b/tests/sarif_tests.rs index e8adc37..6f3d2db 100644 --- a/tests/sarif_tests.rs +++ b/tests/sarif_tests.rs @@ -203,3 +203,67 @@ fn test_sarif_from_real_analysis() -> Result<(), Box> { } Ok(()) } + +#[cfg(unix)] +#[test] +fn coverage_paths_preserve_relative_bytes_in_sarif() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let dir = tempfile::tempdir().unwrap(); + let root = dir + .path() + .join(std::ffi::OsString::from_vec(b"root-\xff".to_vec())); + std::fs::create_dir(&root).unwrap(); + let names = [b"input-\xfe.php", b"input-\xff.php"]; + for name in names { + std::fs::write( + root.join(std::ffi::OsString::from_vec(name.to_vec())), + " Date: Sun, 25 Oct 2026 19:28:52 +0000 Subject: [PATCH 09/24] feat(aggregate): ingest proven-tests run reports, and grade this repo's own suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interop was written and never connected: src/aggregate/proven_tests.rs existed with #[allow(dead_code)] and no CLI surface, while proven-tests-and-benches/docs/INTEROP-PANIC-ATTACK.adoc described a mapping nothing executed. A design document is not a test. aggregate now takes --proven-tests [--proven-tests-root DIR] [--fail-on-defect]. It hashes the report, applies the tier->verdict mapping in code (only Actually-Proven is read as Closed; Provisionally-Proven is NOT Holes — absence of a proof is not presence of a hole), and records defects: an Actually-Proven entry with an empty ladder, a ladder step naming no file, a cited file that is absent under the root, an unrecognised tier, a summary that disagrees with the entries, or a report with no entries at all. --fail-on-defect turns those into a non-zero exit, which is the behaviour the interop doc records as missing on both sides. proven/ledger.json grades this repository's own suite on the framework's 17x14 lattice. Coverage is derived, never declared: scripts/proven/inventory.sh asks the test runner what exists, and scripts/proven/verify-ledger.py refuses to emit a report when a cell selects no executed test, when an executed test is accounted for by nothing, or when a stronger tier is claimed than the evidence supports. Every entry here is Provisionally-Proven or Unproven and says so; five cells are declared absent with the command that would test them (mutation and fuzz testing among them — PA025 MutationGap applies to this repo too). .github/workflows/proven-tests.yml runs the ledger gate on every PR and the framework's own Idris2 suite on a schedule, so the grader is exercised as well as trusted. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/proven-tests.yml | 200 +++++++++++++++++++ proven/ledger.json | 301 +++++++++++++++++++++++++++++ scripts/proven/inventory.sh | 50 +++++ scripts/proven/verify-ledger.py | 194 +++++++++++++++++++ src/aggregate/mod.rs | 64 +++++- src/aggregate/proven_tests.rs | 208 ++++++++++++++++++++ tests/aggregate_proof_fixture.rs | 3 + 7 files changed, 1015 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/proven-tests.yml create mode 100644 proven/ledger.json create mode 100644 scripts/proven/inventory.sh create mode 100644 scripts/proven/verify-ledger.py diff --git a/.github/workflows/proven-tests.yml b/.github/workflows/proven-tests.yml new file mode 100644 index 0000000..9339b24 --- /dev/null +++ b/.github/workflows/proven-tests.yml @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# proven-tests.yml — the estate's evidence-graded test framework, wired. +# +# THE TWO JOBS DO DIFFERENT THINGS AND NEITHER PRETENDS TO BE THE OTHER +# --------------------------------------------------------------------- +# `ledger` (every PR, blocking, minutes): derives a proven-tests run report from +# THIS repo's suite — from a test inventory the runner emitted in the same +# commit — and then feeds that report to `panic-attack aggregate +# --proven-tests --fail-on-defect`, which applies the tier->verdict mapping and +# refuses claims the report's own contents do not support (an Actually-Proven +# entry with no ladder, a ladder step citing a file that is not there, a +# summary that disagrees with the entries). A cell with no executed test is a +# gate failure, not a rounding error. +# +# `framework` (schedule and dispatch only, tens of minutes): builds the +# `proven-tests-and-benches` framework itself from a pinned commit with its +# own Idris2 toolchain and runs its suite, so the *grader* is exercised rather +# than trusted. This is deliberately off the PR path: bootstrapping Idris2 for +# every pull request buys nothing that the ledger job does not already prove, +# and it would make every contributor wait on a compiler that cannot change +# for them. +# +# WHY THE REPORT IS A FILE, NOT A PARAGRAPH: docs/INTEROP-PANIC-ATTACK.adoc in +# proven-tests-and-benches fixes the mapping in prose; a report plus a verifier +# is the same mapping with something at stake. + +name: Proven Tests + +on: + pull_request: + branches: ['**'] + push: + branches: [main, master] + schedule: + - cron: '41 4 * * 1' # Mondays, off the hour + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: proven-tests-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned: the framework commit this repo's ledger was written against. + PROVEN_TESTS_PIN: eca682bcd419554f6779e72969902e6faef4fd9d + IDRIS2_VERSION: '0.7.0' + +jobs: + ledger: + name: Ledger — coverage derived from executed tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: proven-ledger + + # The suite must PASS before a report may claim anything about it: a + # `result: passed` field written by a job that never ran the tests is + # exactly the dishonesty this framework exists to remove. + - name: Run the suite + run: cargo test --locked --no-fail-fast + + - name: Inventory the tests that exist + run: bash scripts/proven/inventory.sh /tmp/inventory.json + + - name: Derive the run report (cells must select real tests) + run: | + python3 scripts/proven/verify-ledger.py \ + --inventory /tmp/inventory.json \ + --ledger proven/ledger.json \ + --root . \ + --out /tmp/proven-report.json + + - name: Build panic-attack + run: cargo build --locked --release + + # The interop, actually executed: our own tool reads the framework's + # report, applies the mapping in code, and fails on an unsupported claim. + - name: Fold the report through aggregate --fail-on-defect + run: | + set -euo pipefail + ./target/release/panic-attack aggregate \ + --proven-tests /tmp/proven-report.json \ + --proven-tests-root . \ + --fail-on-defect \ + --output /tmp/proven-aggregate.json + python3 - <<'PY' + import json + agg = json.load(open('/tmp/proven-aggregate.json')) + records = agg['proven_tests'] + assert len(records) == 1, records + record = records[0] + assert record['defects'] == [], record['defects'] + summary = record['summary'] + assert summary['total'] > 0, summary + print(f"proven-tests: {summary['passed']}/{summary['total']} passed, " + f"{summary['covered_cells']}/{summary['cat_aspect_cells']} cells, " + f"{summary['actually_proven']} actually-proven") + PY + + - name: Publish to the job summary + if: always() + run: | + { + echo "## Proven-tests ledger" + echo "" + if [ -f /tmp/proven-report.json ]; then + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import json + r = json.load(open('/tmp/proven-report.json')) + s = r['summary'] + print(f"**{s['passed']}/{s['total']}** executed tests, " + f"**{s['covered_cells']}/{s['cat_aspect_cells']}** category×aspect cells") + print("") + print(f"| tier | tests |\n|---|---|") + print(f"| Actually-Proven | {s['actually_proven']} |") + print(f"| Provisionally-Proven | {s['provisionally_proven']} |") + print(f"| Unproven | {s['unproven']} |") + print("") + print("Cells declared absent, with the command that would test them, are in") + print("`proven/ledger.json` — a to-do list, not a silencer.") + PY + else + echo "_the report was not produced; see the failed step above_" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the run report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: proven-report + path: | + /tmp/proven-report.json + /tmp/proven-aggregate.json + retention-days: 30 + if-no-files-found: warn + + framework: + name: Framework — the grader is exercised (scheduled) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout this repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Checkout proven-tests-and-benches at the pinned commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/proven-tests-and-benches + ref: ${{ env.PROVEN_TESTS_PIN }} + path: .proven-tests + persist-credentials: false + + - name: Cache the Idris2 toolchain + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.idris2 + key: idris2-v${{ env.IDRIS2_VERSION }}-${{ runner.os }} + + - name: Install Idris2 (chez backend) + working-directory: .proven-tests + env: + IDRIS2_VERSION: ${{ env.IDRIS2_VERSION }} + run: bash scripts/install-idris2.sh + + - name: Build and run the framework's suite + working-directory: .proven-tests + run: | + set -euo pipefail + export PATH="$HOME/.idris2/bin:$PATH" + idris2 --version + bash scripts/ci-build.sh + + - name: Upload the framework's own run report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: proven-framework-report + path: .proven-tests/build/report/*.json + retention-days: 30 + if-no-files-found: warn diff --git a/proven/ledger.json b/proven/ledger.json new file mode 100644 index 0000000..8a22ec1 --- /dev/null +++ b/proven/ledger.json @@ -0,0 +1,301 @@ +{ + "schema_version": 1, + "suite": "panic-attack", + "framework": "proven-tests-and-benches", + "framework_pin": "eca682bcd419554f6779e72969902e6faef4fd9d", + "taxonomy": { + "categories": [ + "UnitTest", + "PointToPoint", + "EndToEnd", + "BuildTest", + "ExecutionRuntime", + "ReflexiveTest", + "LifecycleTest", + "SmokeTest", + "PropertyBasedTest", + "MutationTest", + "FuzzTest", + "ContractInvariantTest", + "RegressionTest", + "ChaosResilienceTest", + "CompatibilityTest", + "ProofRegressionTest", + "TypeSafeTest" + ], + "aspects": [ + "Dependability", + "Security", + "Usability", + "Interoperability", + "Safety", + "Performance", + "Functionality", + "Versability", + "Accessibility", + "Maintainability", + "Privacy", + "Observability", + "Reproducibility", + "Portability" + ] + }, + "doctrine": [ + "A cell is covered only when a test that actually ran selected it. Nothing", + "here declares coverage; scripts/proven/verify-ledger.py derives it from a", + "test inventory produced by `cargo test -- --list` in the same commit.", + "Tiers are the framework's three: Actually-Proven (a machine-checked proof", + "ladder exists), Provisionally-Proven (typed assertions, example/property", + "evidence, no proof object), Unproven (no type-safety warranty). This repo", + "has no proof-carrying Rust tests, so almost everything is", + "Provisionally-Proven and says so rather than borrowing a stronger word.", + "Absent cells are declared absent, with the command that would test them.", + "A declared absence is a to-do list, not a silencer." + ], + "entries": [ + { + "category": "UnitTest", + "aspect": "Functionality", + "tier": "Provisionally-Proven", + "bins": [ + "analyzer_tests", + "types_tests", + "pattern_tests" + ], + "evidence": "Per-language detection, WeakPoint construction and pattern tables." + }, + { + "category": "UnitTest", + "aspect": "Security", + "tier": "Provisionally-Proven", + "bins": [ + "analyzer_tests", + "pattern_tests" + ], + "evidence": "Injection, deserialisation and crypto-misuse patterns (PA003, PA005, PA022)." + }, + { + "category": "UnitTest", + "aspect": "Dependability", + "tier": "Provisionally-Proven", + "bins": [ + "regression_tests", + "readiness" + ], + "evidence": "Regression fixtures plus the CRG readiness grades D/C/B." + }, + { + "category": "PropertyBasedTest", + "aspect": "Functionality", + "tier": "Provisionally-Proven", + "bins": [ + "property_tests" + ], + "evidence": "proptest invariants over report construction and canonical JSON." + }, + { + "category": "PropertyBasedTest", + "aspect": "Dependability", + "tier": "Provisionally-Proven", + "bins": [ + "property_tests" + ], + "evidence": "Invariants that must hold for every generated input, not one fixture." + }, + { + "category": "UnitTest", + "aspect": "Interoperability", + "tier": "Provisionally-Proven", + "bins": [ + "seam_contract_tests" + ], + "evidence": "Seam contracts: the JSON/SARIF/PanLL surfaces other tools consume." + }, + { + "category": "ContractInvariantTest", + "aspect": "Interoperability", + "tier": "Provisionally-Proven", + "bins": [ + "seam_contract_tests", + "panll_tests" + ], + "evidence": "Schema versions, event-chain shape and CLI contract stability." + }, + { + "category": "UnitTest", + "aspect": "Observability", + "tier": "Provisionally-Proven", + "bins": [ + "panll_tests", + "report_tests" + ], + "evidence": "PanLL event export and report rendering, including SARIF runs." + }, + { + "category": "UnitTest", + "aspect": "Reproducibility", + "tier": "Provisionally-Proven", + "bins": [ + "report_tests" + ], + "evidence": "I-JSON/JCS artefacts: equal data yields equal bytes, one trailing LF." + }, + { + "category": "RegressionTest", + "aspect": "Dependability", + "tier": "Provisionally-Proven", + "bins": [ + "regression_tests", + "unbounded_corpus" + ], + "evidence": "Named defects stay fixed; corpus scans stay bounded." + }, + { + "category": "EndToEnd", + "aspect": "Functionality", + "tier": "Provisionally-Proven", + "bins": [ + "e2e_tests", + "integration" + ], + "evidence": "Scan -> detect -> report, exercised through the built binary." + }, + { + "category": "SmokeTest", + "aspect": "Functionality", + "tier": "Unproven", + "bins": [ + "integration" + ], + "evidence": "Process-level smoke: the CLI starts and produces parseable output." + }, + { + "category": "CompatibilityTest", + "aspect": "Portability", + "tier": "Provisionally-Proven", + "bins": [ + "coverage_php_tests", + "analyzer_tests" + ], + "evidence": "Multi-language inputs and the PHP adapter across templates, traits and namespaces." + }, + { + "category": "UnitTest", + "aspect": "Privacy", + "tier": "Provisionally-Proven", + "bins": [ + "coverage_php_tests" + ], + "evidence": "unsafe-deserialisation checks: unserialize with and without object options." + }, + { + "category": "ProofRegressionTest", + "aspect": "Dependability", + "tier": "Provisionally-Proven", + "bins": [ + "aggregate_proof_fixture" + ], + "evidence": "Folds a real Idris2 proof artifact and asserts the Closed verdict; the test is example-based even though the artifact is proof-carrying." + }, + { + "category": "ProofRegressionTest", + "aspect": "Reproducibility", + "tier": "Provisionally-Proven", + "bins": [ + "panic_attack" + ], + "pattern": "(proven_tests|aggregate)::tests::", + "evidence": "proven-tests run-report ingestion: hashing, tier mapping and defect detection (library unit tests)." + }, + { + "category": "LifecycleTest", + "aspect": "Dependability", + "tier": "Provisionally-Proven", + "bins": [ + "panic_attack" + ], + "pattern": "storage::tests::", + "evidence": "Hexad write/load and campaign lifecycle state transitions." + }, + { + "category": "ChaosResilienceTest", + "aspect": "Dependability", + "tier": "Provisionally-Proven", + "bins": [ + "panic_attack" + ], + "pattern": "(ambush|amuck|abduct)::", + "evidence": "Ambient stressors, mutation combinations and isolation/time-skew, at unit level." + }, + { + "category": "TypeSafeTest", + "aspect": "Safety", + "tier": "Provisionally-Proven", + "bins": [ + "panic_attack" + ], + "pattern": "kanren::", + "evidence": "miniKanren unification, taint and cross-language reasoning." + }, + { + "category": "UnitTest", + "aspect": "Usability", + "tier": "Provisionally-Proven", + "bins": [ + "panic_attack" + ], + "pattern": "(i18n|a2ml)::", + "evidence": "Ten-language message catalogue and A2ML manifest lookup." + }, + { + "category": "BuildTest", + "aspect": "Reproducibility", + "tier": "Provisionally-Proven", + "bins": [ + "readiness" + ], + "evidence": "The built artefact exists and runs: readiness grades D/C/B exercise the release binary end to end, so a build that does not build cannot pass." + }, + { + "category": "UnitTest", + "aspect": "Maintainability", + "tier": "Provisionally-Proven", + "bins": [ + "aspect_tests" + ], + "evidence": "Aspect/report structure tests that keep the public shape reviewable." + } + ], + "declared_absent": [ + { + "category": "MutationTest", + "aspect": "Dependability", + "reason": "No cargo-mutants campaign in CI. This is exactly the PA025 MutationGap finding the tool reports about other repositories, and it applies here until mutation testing runs nightly.", + "would_test": "cargo mutants --in-place --timeout 120 (nightly, schedule-only)" + }, + { + "category": "FuzzTest", + "aspect": "Security", + "reason": "proptest is property-based, not coverage-guided fuzzing; no fuzz target exists.", + "would_test": "cargo fuzz run parse_report (nightly, schedule-only)" + }, + { + "category": "Performance", + "aspect": "Performance", + "reason": "benches/scan_bench.rs exists as a Criterion bench but is not a graded test and is not gated on regressions.", + "would_test": "criterion bench + baseline diff (see proven-tests-and-benches scripts/check-bench-regression.sh)" + }, + { + "category": "UnitTest", + "aspect": "Accessibility", + "reason": "The CLI is the only default surface; the TUI/GUI are opt-in features with no accessibility assertions.", + "would_test": "TUI snapshot tests with terminal accessibility assertions" + }, + { + "category": "UnitTest", + "aspect": "Versability", + "reason": "The taxonomy aspect is not yet defined normatively in the estate canon, so any claim here would be unanchored.", + "would_test": "wait for the canon definition, then map" + } + ] +} diff --git a/scripts/proven/inventory.sh b/scripts/proven/inventory.sh new file mode 100644 index 0000000..e43295f --- /dev/null +++ b/scripts/proven/inventory.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# Emit a test inventory: which test binaries exist and which tests they declare. +# +# WHY NOT `cargo test -- --list` ONCE: that prints every test name with no +# attribution, so a ledger entry could not say "these cells rest on these +# binaries" and a renamed binary would silently re-point every claim. One +# `--list` per binary keeps the attribution exact. +# +# Usage: scripts/proven/inventory.sh [OUT] default: /tmp/panic-attack-tests.json +# +# Requires jq. Building the test binaries is the caller's job (`cargo test +# --no-run`); this script only lists. +set -euo pipefail + +OUT="${1:-/tmp/panic-attack-tests.json}" +command -v jq >/dev/null || { echo "inventory: jq not found" >&2; exit 2; } + +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +echo '{}' > "$tmp" + +add() { + local binary="$1" names + names="$(printf '%s\n' "$2" | sed -n 's/^\(.*\): test$/\1/p' | jq -R . | jq -s .)" + jq --arg b "$binary" --argjson n "$names" '.[$b] = $n' "$tmp" > "$tmp.new" + mv "$tmp.new" "$tmp" +} + +# Library unit tests (module::path::name). +add "panic_attack" "$(cargo test --locked --lib -- --list 2>/dev/null || true)" + +# Integration test binaries, one per tests/*.rs file. +for f in tests/*.rs; do + [ -e "$f" ] || continue + bin="$(basename "$f" .rs)" + case "$bin" in + # shell-driven fixtures, not Rust test binaries + verisimdb_e2e) continue ;; + esac + add "$bin" "$(cargo test --locked --test "$bin" -- --list 2>/dev/null || true)" +done + +jq -S . "$tmp" > "$OUT" +total="$(jq '[.[] | length] | add // 0' "$OUT")" +bins="$(jq 'length' "$OUT")" +echo "inventory: $total test(s) in $bins binar(ies) -> $OUT" +[ "$total" -gt 0 ] || { echo "inventory: no tests found — refusing to certify an empty suite" >&2; exit 1; } diff --git a/scripts/proven/verify-ledger.py b/scripts/proven/verify-ledger.py new file mode 100644 index 0000000..ffa8ace --- /dev/null +++ b/scripts/proven/verify-ledger.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +"""Derive a proven-tests run report from tests that ACTUALLY RAN. + +The framework's organising rule (proven-tests-and-benches) is that coverage is +derived from executed tests at typed coordinates, never declared. A JSON file +saying "we cover Security" is a claim; a JSON file that can only be produced +from a test inventory the test runner emitted is evidence. + +So this verifier: + + 1. refuses to emit anything unless every ledger cell selects at least one + test present in the inventory (a cell with no test is a stale claim); + 2. refuses to emit anything unless every test in the inventory is either + selected by a cell or named in `declared_absent`/`declared_unmapped` + (a test nobody accounted for is an invisible gap); + 3. refuses `Actually-Proven` unless the entry names a proof file that exists; + 4. writes a `schema_version: 1` run report, the format panic-attack's + `aggregate --proven-tests` reads, so the two tools agree on one artefact + rather than on a paragraph in a design doc. + +Inventory format (produced by scripts/proven/inventory.sh): + + {"lib": ["a::b::test", ...], "tests/foo.rs": ["test_name", ...], ...} + +Exit codes: 0 report written and self-consistent, 1 claim not supported, +2 usage or input error. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +TERSE = re.compile(r"^(?P[^\s:]+(?:::[^\s:]+)*): test$") + + +def load_inventory(path: Path) -> dict[str, list[str]]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SystemExit("inventory: expected an object of {binary: [test names]}") + clean: dict[str, list[str]] = {} + for binary, names in data.items(): + if not isinstance(names, list): + raise SystemExit(f"inventory: {binary} is not a list") + clean[binary] = sorted({str(n) for n in names}) + if not clean: + raise SystemExit("inventory: no test binaries — refusing to certify an empty run") + return clean + + +def select(entries: list[dict], inventory: dict[str, list[str]]) -> dict[str, dict]: + """Map test name -> the ledger cell that claims it.""" + claimed: dict[str, dict] = {} + for entry in entries: + bins = entry.get("bins") or [] + pattern = entry.get("pattern") + regex = re.compile(pattern) if pattern else None + matched = 0 + for binary, names in inventory.items(): + if binary not in bins: + continue + for name in names: + if regex and not regex.search(name): + continue + matched += 1 + claimed.setdefault(name, entry) + if matched == 0: + raise SystemExit( + f"ledger cell {entry['category']} x {entry['aspect']} selects no " + f"test in the inventory (bins={bins}, pattern={pattern!r}): the " + "claim is stale — fix the selection or declare the cell absent" + ) + return claimed + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--inventory", required=True) + ap.add_argument("--ledger", default="proven/ledger.json") + ap.add_argument("--root", default=".") + ap.add_argument("--out", default="proven-report.json") + ap.add_argument("--allow-absent", action="store_true", + help="report declared-absent cells as informational instead of failing") + args = ap.parse_args() + + inventory = load_inventory(Path(args.inventory)) + ledger = json.loads(Path(args.ledger).read_text(encoding="utf-8")) + entries = ledger.get("entries", []) + absent = ledger.get("declared_absent", []) + unmapped_decl = ledger.get("declared_unmapped", []) + + claimed = select(entries, inventory) + + all_tests = {name for names in inventory.values() for name in names} + unaccounted = sorted(all_tests - set(claimed)) + allowed_unmapped = [] + for decl in unmapped_decl: + regex = re.compile(decl["match"]) + allowed_unmapped.extend(t for t in unaccounted if regex.search(t)) + unaccounted = [t for t in unaccounted if t not in set(allowed_unmapped)] + + problems: list[str] = [] + if unaccounted: + problems.append( + f"{len(unaccounted)} executed test(s) are claimed by no ledger cell and " + f"declared nowhere, e.g. {', '.join(unaccounted[:5])}" + ) + + report_entries = [] + cells_covered: set[tuple[str, str]] = set() + tiers = {"Actually-Proven": 0, "Provisionally-Proven": 0, "Unproven": 0} + root = Path(args.root) + + for name in sorted(claimed): + entry = claimed[name] + tier = entry["tier"] + if tier not in tiers: + problems.append(f"{name}: unknown tier {tier!r}") + continue + proof_file = entry.get("proof_file") + if tier == "Actually-Proven": + if not proof_file: + problems.append( + f"{name}: claims Actually-Proven with no proof_file " + "(the framework's type makes this claim impossible to make honestly)" + ) + elif not (root / proof_file).is_file(): + problems.append(f"{name}: cited proof_file {proof_file} does not exist") + elif proof_file: + problems.append( + f"{name}: tier {tier} must not carry a proof_file — that would " + "borrow the warrant of a stronger tier" + ) + tiers[tier] += 1 + cells_covered.add((entry["category"], entry["aspect"])) + report_entries.append( + { + "test_id": name, + "category": entry["category"], + "aspect": entry["aspect"], + "provenance": tier, + "proof_ladder": [], + "result": "passed", + } + ) + + categories = ledger.get("taxonomy", {}).get("categories", []) + aspects = ledger.get("taxonomy", {}).get("aspects", []) + for a in absent: + if (a["category"], a["aspect"]) in cells_covered: + problems.append( + f"{a['category']} x {a['aspect']} is declared absent but is also covered" + ) + cat_aspect_cells = len(categories) * len(aspects) + + if problems: + for p in problems: + print(f"::error::{p}", file=sys.stderr) + return 1 + + report = { + "schema_version": 1, + "suite": ledger.get("suite", "unknown"), + "summary": { + "passed": len(report_entries), + "total": len(report_entries), + "actually_proven": tiers["Actually-Proven"], + "provisionally_proven": tiers["Provisionally-Proven"], + "unproven": tiers["Unproven"], + "covered_cells": len(cells_covered), + "cat_aspect_cells": cat_aspect_cells, + "lattice_covered": len(cells_covered), + }, + "entries": report_entries, + } + Path(args.out).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + + print( + f"proven: {len(report_entries)} executed test(s) across " + f"{len(cells_covered)} of {cat_aspect_cells} cat×aspect cells " + f"({tiers['Actually-Proven']} actually-proven, " + f"{tiers['Provisionally-Proven']} provisionally-proven, " + f"{tiers['Unproven']} unproven); {len(absent)} cell(s) declared absent" + ) + print(f"proven: wrote {args.out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aggregate/mod.rs b/src/aggregate/mod.rs index 25bab39..970937f 100644 --- a/src/aggregate/mod.rs +++ b/src/aggregate/mod.rs @@ -51,9 +51,11 @@ use std::io::Read; use std::path::{Path, PathBuf}; /// Ingest a `proven-tests` JSON run report as an aggregate input kind. -// This public library API is also compiled into the binary's private aggregate -// module, whose CLI does not yet consume proven-tests reports. -#[allow(dead_code)] +/// +/// Reached from the CLI as `panic-attack aggregate --proven-tests `; +/// see that repository's `docs/INTEROP-PANIC-ATTACK.adoc` for the mapping this +/// implements, and [`proven_tests::ProvenTestsRecord::defects`] for what it +/// refuses to take on trust. pub mod proven_tests; /// Upper bound on the text we scan for verdict / coverage classification. @@ -265,6 +267,10 @@ pub struct AggregateReport { pub trust_disclaimer: String, #[serde(default)] pub notes: Vec, + /// Proven-tests run reports folded in, with their defects. Omitted from + /// reports that ingested none, so existing consumers see no new field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub proven_tests: Vec, } /// One proof input with optional CLI-side overrides. @@ -275,10 +281,25 @@ pub struct ProofInput { pub covers: Vec, } +/// How `aggregate` is invoked. +/// +/// `proven_tests` reports are ingested and *not* treated as proofs: they are +/// evidence about a test suite's provenance, hashed and recorded, whose own +/// internal contradictions are reported as defects. Set `fail_on_defect` when +/// the caller wants a defect to fail the build rather than merely appear in +/// the report. #[derive(Debug, Clone)] pub struct AggregateConfig { pub proofs: Vec, pub base_report: Option, + /// `proven-tests` JSON run reports (schema_version 1) to fold in. + pub proven_tests: Vec, + /// Directory cited `proof_file` paths resolve against; the report's own + /// parent directory when `None`. + pub proven_tests_root: Option, + /// Fail the run when an ingested report makes a claim its contents do not + /// support. + pub fail_on_defect: bool, } const TRUST_DISCLAIMER: &str = "Aggregated verdicts rest on the trustworthiness of the named proof \ @@ -287,12 +308,41 @@ BLAKE3 hash. If this assessment is later challenged, the hash identifies the exa — a different file will not reproduce it."; pub fn run(config: AggregateConfig) -> Result { - if config.proofs.is_empty() { - return Err(anyhow!("provide at least one --proof")); + if config.proofs.is_empty() && config.proven_tests.is_empty() { + return Err(anyhow!( + "provide at least one --proof or --proven-tests report" + )); } let mut notes = Vec::new(); + // Proven-tests run reports: hashed, mapped through the tier->verdict + // table, and checked for claims their own contents do not support. + let mut proven_tests = Vec::new(); + let mut defect_lines = Vec::new(); + for path in &config.proven_tests { + let record = proven_tests::ingest(path, config.proven_tests_root.as_deref())?; + for defect in &record.defects { + defect_lines.push(format!("{}: {}", path.display(), defect)); + } + notes.push(format!( + "proven-tests report {} ({} bytes, blake3 {}…) recorded with {} defect(s)", + path.display(), + record.bytes, + &record.hash.hex[..record.hash.hex.len().min(12)], + record.defects.len() + )); + proven_tests.push(record); + } + if config.fail_on_defect && !defect_lines.is_empty() { + let mut msg = String::from("proven-tests report(s) make unsupported claims:"); + for line in &defect_lines { + msg.push_str("\n - "); + msg.push_str(line); + } + return Err(anyhow!(msg)); + } + // Load base findings (optional). let (findings, finding_summary, base_loaded) = match &config.base_report { Some(path) => match load_findings(path) { @@ -394,6 +444,7 @@ pub fn run(config: AggregateConfig) -> Result { finding_summary, trust_disclaimer: TRUST_DISCLAIMER.to_string(), notes, + proven_tests, }) } @@ -986,6 +1037,9 @@ mod tests { covers: Vec::new(), }], base_report: Some(base), + proven_tests: Vec::new(), + proven_tests_root: None, + fail_on_defect: false, }) .unwrap(); diff --git a/src/aggregate/proven_tests.rs b/src/aggregate/proven_tests.rs index 9799cad..01894ac 100644 --- a/src/aggregate/proven_tests.rs +++ b/src/aggregate/proven_tests.rs @@ -195,6 +195,138 @@ impl ProvenTestsReport { } } +/// A `proven-tests` run report as folded into an [`crate::aggregate::AggregateReport`]. +/// +/// This is the reciprocal of the framework's `scripts/emit-aggregate-inputs.sh` +/// (documented in `docs/INTEROP-PANIC-ATTACK.adoc` in that repository): instead +/// of exporting proof files to panic-attack, panic-attack reads the run report. +/// Both directions apply the same tier->verdict mapping, which lives in +/// [`ReportTier::to_verdict`]. +/// +/// `defects` is the honest part. A report can *say* anything; these are the +/// claims it makes that its own contents do not support — an Actually-Proven +/// entry with no ladder, a ladder step naming no file, a cited file that is not +/// present under the root, an unrecognised tier, a summary that disagrees with +/// the entries, or a report with no entries at all. An empty `defects` list is +/// therefore a positive claim, not a default. +#[derive(Debug, Clone, Serialize)] +pub struct ProvenTestsRecord { + /// Path of the report that was ingested. + pub path: std::path::PathBuf, + /// BLAKE3 of the exact bytes ingested: a different report will not match. + pub hash: super::HashRecord, + /// Size of the report in bytes. + pub bytes: u64, + /// The report's own `suite` field. + pub suite: String, + /// Tier and coverage counts as the report stated them. + pub summary: Summary, + /// Distinct `.idr` files cited by Actually-Proven ladders. + pub ladder_files: Vec, + /// One honest line per entry, applying the tier->verdict mapping. + pub readings: Vec, + /// Unsupported claims; empty only when the report is self-consistent. + pub defects: Vec, +} + +/// Read a run report, hash it, and check that its claims are backed by its own +/// contents. +/// +/// `root` is the directory cited `proof_file` paths are resolved against — for +/// the framework's own reports that is the `proven-tests-and-benches` checkout. +/// It defaults to the report's parent directory, so a report colocated with the +/// sources it cites needs no extra configuration. +/// +/// # Errors +/// Returns file read, BLAKE3 hashing and JSON parsing errors (including an +/// unsupported `schema_version`). Does *not* error on defects: it records them, +/// so a caller may report first and fail afterwards with +/// [`crate::aggregate::AggregateConfig::fail_on_defect`]. +pub fn ingest(path: &Path, root: Option<&Path>) -> Result { + let report = ProvenTestsReport::from_path(path)?; + let (hash, bytes) = super::hash_file(path)?; + let base = root.map(Path::to_path_buf).unwrap_or_else(|| { + path.parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf() + }); + + let mut defects = Vec::new(); + let mut ladder_files: Vec = Vec::new(); + let mut counted_actually_proven = 0usize; + + if report.entries.is_empty() { + defects.push("report contains no entries: an empty run proves nothing".to_string()); + } + + for entry in &report.entries { + match ReportTier::parse(&entry.provenance) { + None => defects.push(format!( + "{}: unrecognised provenance tier {:?} (expected Actually-Proven, Provisionally-Proven or Unproven)", + entry.test_id, entry.provenance + )), + Some(ReportTier::ActuallyProven) => { + counted_actually_proven += 1; + if entry.proof_ladder.is_empty() { + defects.push(format!( + "{}: claims Actually-Proven with an empty proof ladder", + entry.test_id + )); + } + for step in &entry.proof_ladder { + match &step.proof_file { + None => defects.push(format!( + "{}: ladder step {:?} cites no proof file", + entry.test_id, step.description + )), + Some(file) => { + let candidate = base.join(file); + if !candidate.is_file() { + defects.push(format!( + "{}: cited proof file {} was not found under {}", + entry.test_id, + file, + base.display() + )); + } + if !ladder_files.iter().any(|f| f == file) { + ladder_files.push(file.clone()); + } + } + } + } + } + Some(ReportTier::ProvisionallyProven | ReportTier::Unproven) => {} + } + } + + if report.summary.actually_proven != counted_actually_proven { + defects.push(format!( + "summary claims {} Actually-Proven entr{}, entries carry {}", + report.summary.actually_proven, + if report.summary.actually_proven == 1 { "y" } else { "ies" }, + counted_actually_proven + )); + } + if report.summary.covered_cells > report.summary.cat_aspect_cells { + defects.push(format!( + "summary claims {} covered cells of a {} cell lattice", + report.summary.covered_cells, report.summary.cat_aspect_cells + )); + } + + Ok(ProvenTestsRecord { + path: path.to_path_buf(), + hash, + bytes, + suite: report.suite.clone(), + summary: report.summary.clone(), + ladder_files, + readings: report.notes(), + defects, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -266,4 +398,80 @@ mod tests { .iter() .any(|n| n.contains("unrecognised provenance tier"))); } + + #[test] + fn ingest_flags_an_actually_proven_claim_with_no_ladder() { + let dir = tempfile::tempdir().unwrap(); + let report = dir.path().join("report.json"); + std::fs::write( + &report, + r#"{"schema_version":1,"suite":"proven-tests","summary":{"actually_proven":1}, + "entries":[{"test_id":"claims-proof","provenance":"Actually-Proven", + "proof_ladder":[],"result":"passed"}]}"#, + ) + .unwrap(); + let record = ingest(&report, None).expect("ingest"); + assert!(record.defects.iter().any(|d| d.contains("empty proof ladder"))); + assert!(record.ladder_files.is_empty()); + } + + #[test] + fn ingest_flags_a_cited_proof_file_that_is_absent() { + let dir = tempfile::tempdir().unwrap(); + let report = dir.path().join("report.json"); + std::fs::write( + &report, + r#"{"schema_version":1,"suite":"proven-tests", + "summary":{"actually_proven":1,"covered_cells":1,"cat_aspect_cells":238}, + "entries":[{"test_id":"cites-proof","provenance":"Actually-Proven", + "proof_ladder":[{"description":"comm","proof_file":"src/ProvenTests/Tropical.idr"}], + "result":"passed"}]}"#, + ) + .unwrap(); + let record = ingest(&report, None).expect("ingest"); + assert!(record + .defects + .iter() + .any(|d| d.contains("was not found under")), "{:?}", record.defects); + assert_eq!(record.ladder_files, vec!["src/ProvenTests/Tropical.idr".to_string()]); + } + + #[test] + fn ingest_accepts_a_self_consistent_report() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("src")).unwrap(); + std::fs::write(dir.path().join("src/Tropical.idr"), "-- proof\n").unwrap(); + let report = dir.path().join("report.json"); + std::fs::write( + &report, + r#"{"schema_version":1,"suite":"proven-tests", + "summary":{"passed":2,"total":2,"actually_proven":1,"provisionally_proven":1, + "covered_cells":3,"cat_aspect_cells":238,"lattice_covered":3}, + "entries":[{"test_id":"tropical-laws","provenance":"Actually-Proven", + "proof_ladder":[{"description":"comm","proof_file":"src/Tropical.idr", + "theorem":"oplusComm"}],"result":"passed"}, + {"test_id":"smoke","provenance":"Unproven","proof_ladder":[], + "result":"passed"}]}"#, + ) + .unwrap(); + let record = ingest(&report, Some(dir.path())).expect("ingest"); + assert!(record.defects.is_empty(), "{:?}", record.defects); + assert_eq!(record.suite, "proven-tests"); + assert_eq!(record.summary.passed, 2); + assert!(record.hash.hex.len() == 64); + } + + #[test] + fn ingest_flags_a_summary_that_disagrees_with_the_entries() { + let dir = tempfile::tempdir().unwrap(); + let report = dir.path().join("report.json"); + std::fs::write( + &report, + r#"{"schema_version":1,"summary":{"actually_proven":2}, + "entries":[{"test_id":"one","provenance":"Provisionally-Proven","proof_ladder":[]}]}"#, + ) + .unwrap(); + let record = ingest(&report, None).expect("ingest"); + assert!(record.defects.iter().any(|d| d.contains("summary claims 2"))); + } } diff --git a/tests/aggregate_proof_fixture.rs b/tests/aggregate_proof_fixture.rs index 1497a36..2b9f990 100644 --- a/tests/aggregate_proof_fixture.rs +++ b/tests/aggregate_proof_fixture.rs @@ -32,6 +32,9 @@ fn aggregate_folds_idris2_unsafecode_soundness_proof() { covers: Vec::new(), }], base_report: None, + proven_tests: Vec::new(), + proven_tests_root: None, + fail_on_defect: false, }) .expect("aggregate should fold the Idris2 fixture"); From 21b14d1374a8d26c4396fc51b6c4b894a10e001e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Sun, 25 Oct 2026 19:28:57 +0000 Subject: [PATCH 10/24] ci: rebuild the pipeline on estate canon, and make startup failures legible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI went dark on 2026-10-25: 12 of 12 pull_request workflows ended startup_failure with zero jobs and no log. Two policy mismatches caused it, both of which this commit fixes at the root rather than by disabling a check. 1. Reusable workflow owner. The canon allowlist (rhodium-standard-repositories/actions-allowlist/allowed-actions.json) permits hyperpolymath/* and does not permit metadatastician/*. The six thin wrappers had been re-pointed at metadatastician/standards, which is the same repository by rename — so the estate spelling is restored and the pin is unchanged. 2. Tag pins under sha_pinning_required. Every uses: now names a full commit with a version comment, resolved through scripts/ci/resolve-action-shas.sh and recorded in .github/workflows/action-pins.json; actions.lock is derived from the workflows by scripts/ci/pin-workflow-uses.py, and its dependencies: keys carry the TAG as ref (never their own SHA — standards#1271 makes that a runner-level lockfile failure). New gates, each aimed at a failure that was previously undiagnosable: ci-integrity.yml SHA pins, lockfile coverage, allowlist coverage and YAML parseability — the four ways a run dies before its first job. Uses the canon's own validators (.githooks/, vendored). verify.yml the floor that cannot be taken out by another repository: fmt, clippy -D warnings, the feature matrix, MSRV, docs, and the coverage ledger via --fail-on-incomplete. squabble-gate.yml cicd-squabbler, pinned, run against this repo: gate diagnosis plus cross-repo chain findings. Squabble wins gates, never bypasses them. recon.yml deep recon by two independent analysers — panic-attack's own assail and pons-asinorum — compared against a frozen debt baseline so regressions gate and standing debt does not create pressure to suppress. Also: a repo deed (panic-attack_chora.deed, canonical linter clean), the SPDX header the ANCHOR.a2ml manifest was missing, and the allowlist vendored next to the workflows so the gate is hermetic. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .githooks/validate-actions-lock.sh | 255 ++++++++++++++++++++ .githooks/validate-sha-pins.sh | 170 ++++++++++++++ .github/actions-allowlist.json | 96 ++++++++ .github/workflows/action-pins.json | 94 ++++++++ .github/workflows/actions.lock | 219 ++++++------------ .github/workflows/boj-build.yml | 2 +- .github/workflows/bridge-gate.yml | 4 +- .github/workflows/cargo-audit.yml | 4 +- .github/workflows/casket-pages.yml | 14 +- .github/workflows/ci-integrity.yml | 118 ++++++++++ .github/workflows/codeql.yml | 6 +- .github/workflows/coverage.yml | 4 +- .github/workflows/dependency-review.yml | 4 +- .github/workflows/dogfood-gate.yml | 12 +- .github/workflows/e2e.yml | 28 +-- .github/workflows/governance.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/instant-sync.yml | 2 +- .github/workflows/mirror.yml | 2 +- .github/workflows/pages.yml | 8 +- .github/workflows/push-email-notify.yml | 2 +- .github/workflows/recon.yml | 167 +++++++++++++ .github/workflows/release.yml | 18 +- .github/workflows/rust-ci.yml | 4 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/secret-scanner.yml | 2 +- .github/workflows/squabble-gate.yml | 197 ++++++++++++++++ .github/workflows/verify.yml | 210 +++++++++++++++++ .machine_readable/anchors/ANCHOR.a2ml | 2 + panic-attack_chora.deed | 32 +++ recon/baseline.json | 13 ++ scripts/ci/check-allowed-actions.sh | 68 ++++++ scripts/ci/pin-workflow-uses.py | 296 ++++++++++++++++++++++++ scripts/ci/resolve-action-shas.sh | 62 +++++ scripts/recon/compare.py | 166 +++++++++++++ src/main.rs | 51 +++- 36 files changed, 2127 insertions(+), 211 deletions(-) create mode 100755 .githooks/validate-actions-lock.sh create mode 100755 .githooks/validate-sha-pins.sh create mode 100644 .github/actions-allowlist.json create mode 100644 .github/workflows/action-pins.json create mode 100644 .github/workflows/ci-integrity.yml create mode 100644 .github/workflows/recon.yml create mode 100644 .github/workflows/squabble-gate.yml create mode 100644 .github/workflows/verify.yml create mode 100644 panic-attack_chora.deed create mode 100644 recon/baseline.json create mode 100755 scripts/ci/check-allowed-actions.sh create mode 100755 scripts/ci/pin-workflow-uses.py create mode 100755 scripts/ci/resolve-action-shas.sh create mode 100644 scripts/recon/compare.py diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh new file mode 100755 index 0000000..d93ec8e --- /dev/null +++ b/.githooks/validate-actions-lock.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Actions lockfile coverage: every SHA-pinned `uses:` ref in .github/workflows/ +# AND in .github/actions/*/action.yml must have a matching entry in +# .github/workflows/actions.lock. +# +# WHY THIS EXISTS +# +# GitHub validates a reusable workflow against the CALLEE repo's own +# actions.lock. A lockfile that has drifted from its workflows therefore +# does not break this repo -- it breaks every repo that calls into it, with +# `failure` (not `startup_failure`), 0 jobs, and no reason in either the +# REST or the GraphQL payload; only the run page says why. actionlint +# passes either way. Dependabot bumps a `uses:` SHA without regenerating +# the lock, and Dependabot never runs pre-commit -- so CI is the only place +# this is caught before it reaches the callers. +# +# WHY NOT `gh actions-lock --verify-local`, measured 2026-09-15 on v0.1.6: +# +# * It WRITES. It rewrote `uses: ./.github/actions/signed-push` to the +# invalid `uses: $/.github/actions/signed-push` -- a ref that kills the +# workflow at startup -- while running in a mode whose own help text +# calls it read-only and "ideal for pre-commit hooks". +# --no-migrate-local-actions suppresses that, but then the tool stops +# descending into local composite actions and misreports their +# dependencies as stale. Safety and accuracy are in conflict there. +# * Its coverage is REPO-scoped, not SHA-exact. Bumping ONE of a workflow's +# two refs to the same action leaves the old lock key still referenced by +# the other: nothing reported stale, nothing reported missing, check +# green, workflow broken. That mutant survives it and dies here. +# +# SCOPE -- stated, not implied: +# * Only 40-hex SHA-pinned refs are checked. Tag refs (@v2, @main) are a +# different question, gated by validate-sha-pins.sh. +# * Reusable WORKFLOW refs (owner/repo/.github/workflows/x.yml@sha) are +# skipped: the lockfile models actions and keys no entry for them. +# * Local refs (./...) are skipped; they carry no SHA. +# * Sub-path actions normalise to their repo -- codeql-action/init@X is +# keyed once as codeql-action@X, never once per sub-path. +# * Comparison is case-insensitive: workflows say Swatinem/rust-cache while +# the lockfile stores swatinem/rust-cache. +# * Local COMPOSITE actions (.github/actions/*/action.yml) are scanned too. +# Their deps are keyed in the lock under the workflow that uses them, and +# Dependabot bumps them like any other ref; scanning only the workflow +# directory leaves that drift invisible. +# * Membership is GLOBAL, not per-workflow-section. A ref present in some +# other workflow's lock section satisfies this check. A workflow with +# SHA-pinned refs but no lock section of its own is therefore NOT +# detected here -- see the PR notes. + +set -euo pipefail + +REPO_ROOT="${INPUT_PATH:-.}" +LOCKFILE="$REPO_ROOT/.github/workflows/actions.lock" +WORKFLOW_DIR="$REPO_ROOT/.github/workflows" +ACTIONS_DIR="$REPO_ROOT/.github/actions" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' + +# Refs deliberately absent from the lockfile. Each entry is an exact +# normalised owner/repo@sha plus the reason it is absent. This is an explicit +# list, never a pattern: anything not named here fails, so the list cannot +# quietly widen to cover an accident. An entry that stops being used is +# reported as stale, so it cannot rot either. +EXPECTED_ABSENT=( + # Empty: the denoland/setup-deno exception was removed 2026-09-22 after + # the last consumer went away (the validator itself reported it stale). + # Estate doctrine is still bun-only; if a deno ref ever returns, the + # cure is to remove the consumer, not to re-add an exception. +) + +if [ ! -f "$LOCKFILE" ]; then + echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE not found${NC}" >&2 + exit 1 +fi + +# An unreadable lockfile must abort, never silently pass. +if [ ! -r "$LOCKFILE" ]; then + echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE unreadable${NC}" >&2 + exit 1 +fi + +# Parse the lockfile's keys into an explicit set ONCE, rather than substring +# -matching against the whole file. A substring test over a blob fails open in +# ways that are hard to see: if the blob is empty or truncated, every ref is +# reported "missing" and the operator is told to regenerate a lockfile that +# was actually fine. An explicit set can be counted, and is counted below. +mapfile -t LOCK_KEYS < <( + grep -oE "'[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[0-9a-fA-F]{40}'" "$LOCKFILE" \ + | tr -d "'" | tr '[:upper:]' '[:lower:]' | sort -u +) + +# Positive control: the lockfile is known to key at least one action. If the +# parse yields nothing, the FILE FORMAT changed or the read failed -- say so, +# rather than reporting every ref in the repo as missing. +if [ "${#LOCK_KEYS[@]}" -eq 0 ]; then + echo -e "${RED}[validate-actions-lock] ERROR: parsed 0 keys from $LOCKFILE${NC}" >&2 + echo " The lockfile exists but no 'owner/repo@sha' keys were found." >&2 + echo " This is a parser/format failure, NOT a coverage failure." >&2 + exit 1 +fi + +lock_has() { + local needle="$1" k + for k in "${LOCK_KEYS[@]}"; do + [ "$k" = "$needle" ] && return 0 + done + return 1 +} + +# Leaf action entries in `dependencies:` whose `ref:` equals their own 40-hex +# SHA pin fail GitHub runner lockfile verification during `Set up job` +# ("Unable to resolve action ...: invalid lockfile pin", standards#1271). +mapfile -t SELF_SHA_KEYS < <( + awk ' + /^dependencies:/ { in_dep = 1; next } + /^[a-z_]+:/ { in_dep = 0; cur = ""; next } + !in_dep { next } + /^ \x27[^\x27]+\x27:$/ { + cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur) + next + } + cur != "" && /^ ref: \x27[^\x27]+\x27$/ { + r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r) + n = index(cur, "@") + pin = (n > 0) ? substr(cur, n + 1) : "" + if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { + print tolower(cur) + } + cur = "" + } + ' "$LOCKFILE" | sort -u +) + +# Check a normalized owner/repo@sha key against the parsed self-SHA entries. +# Return 0 if its dependency ref equals its own SHA pin, or 1 if absent. +lock_has_self_sha() { + local needle="$1" k + for k in ${SELF_SHA_KEYS[@]+"${SELF_SHA_KEYS[@]}"}; do + [ "$k" = "$needle" ] && return 0 + done + return 1 +} + +ERRORS=0 +CHECKED=0 +declare -a SEEN_ABSENT=() + +# Collect every SHA-pinned uses: ref across all workflow files. +# +# Read with yq, never grep (YAML-POLICY Y-1). A line grep for `uses:` only +# sees block style: on a KYAML file (`uses: "owner/repo@sha", # v1`) it +# captured the quote and comma into the ref and reported a pinned, locked +# action as missing. The parser returns the scalar VALUE whatever the style, +# and never matches a `uses:` that is text inside a `run:` body. +# Measured 2026-10-01: same 29-ref set as the old grep on the block tree. +if ! command -v yq >/dev/null 2>&1; then + echo -e "${RED}[validate-actions-lock] ERROR: yq not found -- it reads the workflows (YAML-POLICY Y-1)${NC}" >&2 + exit 1 +fi +declare -a UNPARSED=() +mapfile -t RAW < <( + for f in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \ + "$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml; do + [ -f "$f" ] || continue + yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$f" \ + || printf '\001UNPARSED\001%s\n' "$f" + done | grep -E $'@[0-9a-fA-F]{40}$|^\001UNPARSED\001' | sort -u +) +# A file yq cannot parse is a file whose refs went unchecked: fail closed. +for ref in "${RAW[@]}"; do + case "$ref" in $'\001UNPARSED\001'*) UNPARSED+=("${ref#$'\001UNPARSED\001'}") ;; esac +done +if [ "${#UNPARSED[@]}" -gt 0 ]; then + echo -e "${RED}[validate-actions-lock] ERROR: yq could not parse: ${UNPARSED[*]}${NC}" >&2 + exit 1 +fi + +# A zero-input pass is the classic fake green: if ref extraction ever breaks, +# this script would report success having checked nothing. If the lockfile +# already names workflows, finding no refs is a contradiction, not an empty +# repo -- fail instead of passing vacuously. The success line below also +# prints the number checked, so a silent collapse to near-zero is visible. +if [ "${#RAW[@]}" -eq 0 ]; then + if grep -qE "^ '\\.github/workflows/" "$LOCKFILE"; then + echo -e "${RED}[validate-actions-lock] ERROR: no SHA-pinned refs extracted, yet the lockfile names workflows -- extraction is broken${NC}" >&2 + exit 1 + fi + echo -e "${YELLOW}[validate-actions-lock] no SHA-pinned refs found -- nothing to check${NC}" + exit 0 +fi + +for ref in "${RAW[@]}"; do + case "$ref" in + ./*|.\\*) continue ;; # local action, carries no SHA + */.github/workflows/*) continue ;; # reusable workflow, not keyed + esac + case "$ref" in + *.yml@*|*.yaml@*) continue ;; # reusable workflow, any path + esac + + sha="${ref##*@}" + path="${ref%@*}" + owner="${path%%/*}" + rest="${path#*/}" + repo="${rest%%/*}" + [ -n "$owner" ] && [ -n "$repo" ] || continue + norm="$(printf '%s/%s@%s' "$owner" "$repo" "$sha" | tr '[:upper:]' '[:lower:]')" + + skip=0 + for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do + ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" + if [ "$norm" = "$ex_lc" ]; then + SEEN_ABSENT+=("$ex_lc") + skip=1 + break + fi + done + [ "$skip" -eq 1 ] && continue + + CHECKED=$((CHECKED + 1)) + if ! lock_has "$norm"; then + echo -e "${RED}[validate-actions-lock] ERROR: not in actions.lock: ${ref}${NC}" >&2 + echo " normalised to: $norm" >&2 + ERRORS=$((ERRORS + 1)) + elif lock_has_self_sha "$norm"; then + echo -e "${RED}[validate-actions-lock] ERROR: self-SHA ref in actions.lock dependencies: ${norm}${NC}" >&2 + echo " leaf action entries must carry a tag/branch ref, not their own commit SHA (standards#1271)" >&2 + ERRORS=$((ERRORS + 1)) + fi +done + +# A doctrine exception that is no longer used must be removed, or the list +# becomes a place where real coverage gaps can hide. +for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do + ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" + found=0 + for s in ${SEEN_ABSENT[@]+"${SEEN_ABSENT[@]}"}; do + [ "$s" = "$ex_lc" ] && found=1 && break + done + if [ "$found" -eq 0 ]; then + echo -e "${YELLOW}[validate-actions-lock] WARNING: stale exception, no workflow uses ${ex} -- remove it from EXPECTED_ABSENT${NC}" >&2 + fi +done + +if [ "$ERRORS" -gt 0 ]; then + echo -e "${RED}[validate-actions-lock] ${ERRORS} ref(s) missing from the lockfile${NC}" >&2 + echo " Regenerate with the LOCKFILE ONLY, and verify the *.yml diff is empty:" >&2 + echo " gh actions-lock --no-migrate-local-actions --no-narrow" >&2 + echo " git diff --stat -- '.github/workflows/*.yml' # MUST be empty" >&2 + exit 1 +fi + +echo -e "${GREEN}[validate-actions-lock] ✅ ${CHECKED} SHA-pinned ref(s) found among ${#LOCK_KEYS[@]} lockfile keys, ${#EXPECTED_ABSENT[@]} doctrine exception(s)${NC}" +exit 0 diff --git a/.githooks/validate-sha-pins.sh b/.githooks/validate-sha-pins.sh new file mode 100755 index 0000000..51980ee --- /dev/null +++ b/.githooks/validate-sha-pins.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# SHA-Pinning Validation -- canon rule 10: every third-party `uses:` ref names a +# full commit SHA, never a moving tag or a branch. +# +# TWO BUGS IN THE VERSION THIS REPLACES, both measured 2026-09-21 on main: +# +# 1. The fallback scan was +# find "$SCAN_PATH" -path '*/.git/*' -prune -o \ +# -path '*/.github/workflows/*.yml' -o -path '*/.github/workflows/*.yaml' -print +# `find` applies a bare action to the last test only, so -print bound to the +# *.yaml clause alone. This repo has 53 root .yml workflows and no .yaml ones, +# so find emitted 0 paths, the loop validated nothing, and the hook announced +# "All workflow actions are SHA-pinned" over 26 unpinned refs. Parenthesising +# the -o chain would have emitted 166. A scan that finds nothing must say so, +# hence the empty-tree refusal below -- that guard is the part that keeps this +# class of silent all-clear from coming back. +# +# 2. validate_file() whitened whole files: it complained only when a file had an +# unpinned ref AND no pinned ref anywhere, so one `actions/checkout@` +# excused every other action in the same workflow. Validation is per line now. +# +# SCOPE. Root .github/{workflows,actions} plus those directories in nested trees, +# minus rhodium-standard-repositories/**: that subtree is a vendored mirror of the +# RSR canon (gitlab.com/hyperpolymath/rhodium-standard-repositories), other +# repositories' workflows reproduced here as templates. Re-pinning it here fixes no +# live runner and desynchronises the mirror, so its unpinned refs are measured and +# tolerated in .machine_readable/Debtfile.a2ml (vendored-mirror-unpinned-actions) +# rather than skipped silently. The scope matches .githooks/validate-actions-lock.sh, +# the sibling hook this repo also runs in CI (.github/workflows/actions-lock-gate.yml). +set -euo pipefail +command -v yq >/dev/null 2>&1 || { echo "[validate-sha-pins] ERROR: yq not found -- required to read workflows (YAML-POLICY Y-1)" >&2; exit 1; } +SCAN_PATH="${INPUT_PATH:-.}" +STAGED_FILES="${INPUT_STAGED_FILES:-}" +VENDORED_MARK="rhodium-standard-repositories/" +ERRORS=0 +SCANNED=0 +SKIPPED=0 + +is_vendored() { case "$1" in *"${VENDORED_MARK}"*) return 0 ;; *) return 1 ;; esac; } + +# Prints one `:uses: ` record per ref in that is neither a +# full-SHA pin, a local path, nor a docker:// image (or one record naming a +# parse failure). Empty output means the file is clean. Local paths (`./`, +# `../`) are this repo's own composite actions and `docker://` refs are container +# images, not actions -- neither is modelled by actions.lock, which keys actions only. +UNPINNED_FILTER() { + # ONE pipeline, and the first selector matches any NON-BLANK ref. + # + # Two defects are cured here, and the second hid the first. + # + # 1. The selector used to demand `[A-Za-z0-9]`, so a ref that is not + # alphanumeric was dropped before any later arm saw it. `gh actions-lock` + # REWRITE MODE emits `uses: $/.github/actions/...` (measured; it also + # de-pinned 24 SHAs). `$` is not alphanumeric, so that corruption was + # never reported and a workflow that dies at STARTUP scanned CLEAN. + # + # 2. The cure attempted for (1) was a SECOND grep in the same brace group: + # `{ grep A ... ; grep B ; } < "$file"`. Both greps share one stdin, the + # first reads it to EOF, and the second is handed an exhausted stream. It + # matched in isolation and emitted nothing in place — dead code that made + # the gate look fixed. Its comment claimed "zero matches tree-wide today; + # this arm is purely prospective", which was false: signed-push-smoke.yml + # carried a live `$/` ref the whole time. A second reader of one stdin is + # never a second chance. + # + # 3. (YAML-POLICY Y-1) The selector was a line grep, which saw only block + # style: a quoted ref (`uses: "./x"`) slipped past the local-path arm and + # was false-failed, a one-line flow step (`- { uses: a/b@v1 }`) was never + # matched at all, and `uses:` text inside a `run:` body was read as a ref. + # Refs now come from the YAML parser, one `:uses: ` record each; + # a file that does not parse is reported as such, never scanned clean. + # + # An unknown-shaped ref must be REPORTED, never skipped. The exemptions below + # are the only way out, and each one is explicit. + local file=$1 refs + if ! refs=$(yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str") | ((line | tostring) + ":" + .)' "$file" 2>&1); then + printf '0:uses: \n' "$(printf '%s' "$refs" | head -1)" + return 0 + fi + # An unclosed quote can swallow a workflow into one scalar that still parses, + # leaving no jobs and no refs: that is a broken file, not a clean one. + case "$file" in + *.github/workflows/*) + if [ "$(yq -r '.jobs | tag' "$file" 2>/dev/null)" != '!!map' ]; then + printf '0:uses: \n' + return 0 + fi ;; + esac + [ -n "$refs" ] || return 0 + printf '%s\n' "$refs" \ + | sed -E 's/^([0-9]+):/\1:uses: /' \ + | { /usr/bin/grep -E '^[0-9]+:uses: [^[:space:]]' || true; } \ + | { /usr/bin/grep -vE '^[0-9]+:uses: [./]' || true; } \ + | { /usr/bin/grep -vE '^[0-9]+:uses: docker://' || true; } \ + | { /usr/bin/grep -vE '^[0-9]+:uses: [^[:space:]@]+@[0-9a-f]{40}$' || true; } +} + +# Reports every unpinned or invalid `uses:` ref in to stderr and adds +# one to ERRORS per ref. +validate_file() { + local file="$1" rec lineno body + while IFS= read -r rec; do + [ -n "$rec" ] || continue + lineno="${rec%%:*}" + body="${rec#*:}" + body="${body#"${body%%[![:space:]]*}"}" + echo "[validate-sha-pins] ERROR: $file:$lineno: ${body}" >&2 + case "$body" in + *'&2 + ;; + *'uses:'*'$/'*) + # Name the real fault. `$/...` is not an unpinned ref, it is not valid + # `uses:` syntax at all, so the workflow dies at STARTUP and no job of + # it ever runs. Reporting it as "unpinned" sends the reader hunting a + # SHA that was never the problem. + echo " invalid ref: a '\$/'-leading ref is not valid uses: syntax and kills the" >&2 + echo " workflow at startup. This is the gh actions-lock rewrite-mode corruption;" >&2 + echo " a local action is written 'uses: ./.github/actions/'." >&2 + ;; + *) + echo " unpinned ref: canon rule 10 wants owner/repo@<40-hex> plus a '# ' comment," >&2 + echo " and a matching key in .github/workflows/actions.lock" >&2 + ;; + esac + ERRORS=$((ERRORS + 1)) + done < <(UNPINNED_FILTER "$file") +} + +if [ -n "$STAGED_FILES" ]; then + while IFS=$'\n' read -r file; do + [ -n "$file" ] || continue + case "$file" in *.yml|*.yaml) ;; *) continue ;; esac + case "$file" in *".github/workflows/"*|*".github/actions/"*) ;; *) continue ;; esac + is_vendored "$file" && { SKIPPED=$((SKIPPED + 1)); continue; } + [ -f "$file" ] || continue + SCANNED=$((SCANNED + 1)) + validate_file "$file" + done <<< "$STAGED_FILES" +else + ALL=() + while IFS= read -r f; do + [ -n "$f" ] && ALL+=("$f") + done < <(find "$SCAN_PATH" \ + -path '*/.git/*' -prune -o \ + \( -path '*/.github/workflows/*.yml' -o -path '*/.github/workflows/*.yaml' \ + -o -path '*/.github/actions/*.yml' -o -path '*/.github/actions/*.yaml' \) \ + -print 2>/dev/null | LC_ALL=C sort) + # An empty scan is not a pass -- see bug 1 above. + if [ "${#ALL[@]}" -eq 0 ]; then + echo "[validate-sha-pins] ERROR: found 0 workflow files under '$SCAN_PATH'; refusing to certify a tree it never scanned" >&2 + exit 1 + fi + for f in "${ALL[@]}"; do + if is_vendored "$f"; then SKIPPED=$((SKIPPED + 1)); continue; fi + SCANNED=$((SCANNED + 1)) + validate_file "$f" + done +fi + +echo "[validate-sha-pins] ${SCANNED} workflow file(s) scanned, ${SKIPPED} vendored mirror file(s) excluded, ${ERRORS} unpinned ref(s)" +if [ "$ERRORS" -gt 0 ]; then + echo "[validate-sha-pins] FAIL: $ERRORS unpinned action ref(s)" >&2 + exit 1 +fi +echo "[validate-sha-pins] OK: every third-party uses: ref in scope is pinned to a full SHA" +exit 0 diff --git a/.github/actions-allowlist.json b/.github/actions-allowlist.json new file mode 100644 index 0000000..e2f08d0 --- /dev/null +++ b/.github/actions-allowlist.json @@ -0,0 +1,96 @@ +{ + "github_owned_allowed": true, + "verified_allowed": true, + "patterns_allowed": [ + "tailscale/github-action@*", + "8398a7/action-slack@*", + "actions-rust-lang/setup-rust-toolchain@*", + "ad-m/github-push-action@*", + "alire-project/setup-alire@*", + "anchore/sbom-action@*", + "anchore/scan-action@*", + "android-actions/setup-android@*", + "anthropics/claude-code-action@*", + "aquasecurity/trivy-action@*", + "astral-sh/setup-uv@*", + "awalsh128/cache-apt-pkgs-action@*", + "azure/webapps-deploy@*", + "benchmark-action/github-action-benchmark@*", + "Bogdanp/setup-racket@*", + "cachix/install-nix-action@*", + "cbrgm/cleanup-stale-branches-action@*", + "cometkim/rclone-actions@*", + "DavidAnson/markdownlint-cli2-action@*", + "dawidd6/action-download-artifact@*", + "dawidd6/action-send-mail@*", + "DeLaGuardo/setup-clojure@*", + "denoland/setup-deno@*", + "dependabot/fetch-metadata@*", + "dependency-check/Dependency-Check_Action@*", + "devcontainers/ci@*", + "dlang-community/setup-dlang@*", + "docker/build-push-action@*", + "docker/login-action@*", + "docker/metadata-action@*", + "docker/setup-buildx-action@*", + "docker/setup-qemu-action@*", + "dtolnay/rust-action@*", + "dtolnay/rust-toolchain@*", + "editorconfig-checker/action-editorconfig-checker@*", + "EnricoMi/publish-unit-test-result-action@*", + "erlef/setup-beam@*", + "extractions/setup-just@*", + "gitleaks/gitleaks-action@*", + "google/clusterfuzzlite@*", + "goto-bus-stop/setup-zig@*", + "hadolint/hadolint-action@*", + "hashicorp/setup-terraform@*", + "haskell-actions/hlint-run@*", + "haskell-actions/hlint-setup@*", + "haskell-actions/setup@*", + "hyperpolymath/*", + "ibiqlik/action-yamllint@*", + "ionos-deploy-now/deploy-to-ionos-action@*", + "ionos-deploy-now/retrieve-project-info-action@*", + "ionos-deploy-now/template-renderer-action@*", + "jetli/wasm-pack-action@*", + "julia-actions/cache@*", + "julia-actions/CompatHelper@*", + "julia-actions/julia-buildpkg@*", + "julia-actions/julia-processcoverage@*", + "julia-actions/julia-runtest@*", + "julia-actions/setup-julia@*", + "JuliaRegistries/TagBot@*", + "KSXGitHub/github-actions-deploy-aur@*", + "leanprover/lean-action@*", + "ludeeus/action-shellcheck@*", + "lycheeverse/lychee-action@*", + "mlugg/setup-zig@*", + "ocaml/setup-ocaml@*", + "orhun/git-cliff-action@*", + "ossf/scorecard-action@*", + "oven-sh/setup-bun@*", + "peaceiris/actions-gh-pages@*", + "peaceiris/actions-hugo@*", + "peter-evans/create-pull-request@*", + "peter-evans/repository-dispatch@*", + "pnpm/action-setup@*", + "pypa/gh-action-pypi-publish@*", + "r-lib/actions@*", + "reactivecircus/android-emulator-runner@*", + "returntocorp/semgrep-action@*", + "ruby/setup-ruby@*", + "rustsec/audit-check@*", + "shivammathur/setup-php@*", + "slsa-framework/slsa-github-generator@*", + "softprops/action-gh-release@*", + "SonarSource/sonarcloud-github-action@*", + "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/.github/workflows/action-pins.json b/.github/workflows/action-pins.json new file mode 100644 index 0000000..46d5d6e --- /dev/null +++ b/.github/workflows/action-pins.json @@ -0,0 +1,94 @@ +{ + "Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0": { + "sha": "d1527c8f67ef9af357290475d4af4b0fab8f12e0", + "tag": "d1527c8f67ef9af357290475d4af4b0fab8f12e0" + }, + "Swatinem/rust-cache@v2.9.3": { + "sha": "d1527c8f67ef9af357290475d4af4b0fab8f12e0", + "tag": "v2.9.3" + }, + "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9": { + "sha": "55cc8345863c7cc4c66a329aec7e433d2d1c52a9", + "tag": "55cc8345863c7cc4c66a329aec7e433d2d1c52a9" + }, + "actions/cache@v6.1.0": { + "sha": "55cc8345863c7cc4c66a329aec7e433d2d1c52a9", + "tag": "v6.1.0" + }, + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1": { + "sha": "3d3c42e5aac5ba805825da76410c181273ba90b1", + "tag": "3d3c42e5aac5ba805825da76410c181273ba90b1" + }, + "actions/checkout@v7.0.1": { + "sha": "3d3c42e5aac5ba805825da76410c181273ba90b1", + "tag": "v7.0.1" + }, + "actions/configure-pages@v6.0.0": { + "sha": "45bfe0192ca1faeb007ade9deae92b16b8254a0d", + "tag": "v6.0.0" + }, + "actions/dependency-review-action@v5.0.0": { + "sha": "a1d282b36b6f3519aa1f3fc636f609c47dddb294", + "tag": "v5.0.0" + }, + "actions/deploy-pages@v5.0.1": { + "sha": "368f82528645a54fb793d4d04e342629a3f51346", + "tag": "v5.0.1" + }, + "actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333": { + "sha": "9000827ccba6bdab643e8b6fd33ac0654aef8333", + "tag": "9000827ccba6bdab643e8b6fd33ac0654aef8333" + }, + "actions/download-artifact@v8.0.2": { + "sha": "9000827ccba6bdab643e8b6fd33ac0654aef8333", + "tag": "v8.0.2" + }, + "actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9": { + "sha": "cf430e030ddbb5b0abf93d22962f4752f3646cd9", + "tag": "cf430e030ddbb5b0abf93d22962f4752f3646cd9" + }, + "actions/upload-artifact@v7.0.2": { + "sha": "cf430e030ddbb5b0abf93d22962f4752f3646cd9", + "tag": "v7.0.2" + }, + "actions/upload-pages-artifact@v5.0.0": { + "sha": "fc324d3547104276b827a68afc52ff2a11cc49c9", + "tag": "v5.0.0" + }, + "dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8": { + "sha": "683b588c487a69d01f6c6ddf40f036ff682cc2c8", + "tag": "683b588c487a69d01f6c6ddf40f036ff682cc2c8" + }, + "dtolnay/rust-toolchain@v1": { + "sha": "683b588c487a69d01f6c6ddf40f036ff682cc2c8", + "tag": "v1" + }, + "github/codeql-action@v4.39.0": { + "sha": "6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee", + "tag": "v4.39.0" + }, + "haskell-actions/setup@v2.12.1": { + "sha": "0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d", + "tag": "v2.12.1" + }, + "hyperpolymath/smtp-notify-action@v0.5.0": { + "sha": "c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef", + "tag": "v0.5.0" + }, + "hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7": { + "sha": "333b06a01db1874ffedb2de1bfabb195640927c7", + "tag": "333b06a01db1874ffedb2de1bfabb195640927c7" + }, + "peter-evans/repository-dispatch@v4.0.1": { + "sha": "28959ce8df70de7be546dd1250a005dd32156697", + "tag": "v4.0.1" + }, + "slsa-framework/slsa-github-generator@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a": { + "sha": "f7dd8c54c2067bafc12ca7a55595d5ee9b75204a", + "tag": "f7dd8c54c2067bafc12ca7a55595d5ee9b75204a" + }, + "softprops/action-gh-release@v3.0.3": { + "sha": "efb35369e0ad2afab669f228072c1b0d510eae64", + "tag": "v3.0.3" + } +} diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 66e782e..234b1a1 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -4,106 +4,120 @@ version: 'v0.0.3' workflows: '.github/workflows/boj-build.yml': - - 'actions/checkout@v7.0.1' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/bridge-gate.yml': - - 'actions/checkout@v7.0.1' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/cargo-audit.yml': - - 'actions/checkout@v7.0.1' - - 'dtolnay/rust-toolchain@v1' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' '.github/workflows/casket-pages.yml': - - 'actions/cache@v6.1.0' - - 'actions/checkout@v7.0.1' - - 'actions/configure-pages@v6.0.0' - - 'actions/deploy-pages@v5.0.1' - - 'actions/upload-pages-artifact@v5.0.0' - - 'haskell-actions/setup@v2.12.1' + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' + - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' + - 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' + - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' '.github/workflows/chapel-ci.yml': - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + '.github/workflows/ci-integrity.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/codeql.yml': - - 'actions/checkout@v7.0.1' - - 'github/codeql-action@v4.39.0' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'github/codeql-action@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' '.github/workflows/coverage.yml': - - 'actions/checkout@v7.0.1' - - 'dtolnay/rust-toolchain@v1' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' '.github/workflows/dependency-review.yml': - - 'actions/checkout@v7.0.1' - - 'actions/dependency-review-action@v5.0.0' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294' '.github/workflows/dogfood-gate.yml': - - 'actions/checkout@v7.0.1' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/e2e.yml': - - 'actions/checkout@v7.0.1' - - 'actions/upload-artifact@v7.0.2' - - 'dtolnay/rust-toolchain@v1' - - 'swatinem/rust-cache@v2.9.3' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/governance.yml': - - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/hypatia-scan.yml': - - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/instant-sync.yml': - - 'peter-evans/repository-dispatch@v4.0.1' + - 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697' '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] '.github/workflows/mirror.yml': - - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/pages.yml': - - 'actions/checkout@v7.0.1' - - 'actions/deploy-pages@v5.0.1' - - 'actions/upload-pages-artifact@v5.0.0' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' + - 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' + '.github/workflows/proven-tests.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/push-email-notify.yml': - - 'hyperpolymath/smtp-notify-action@v0.5.0' + - 'hyperpolymath/smtp-notify-action@c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' + '.github/workflows/recon.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'github/codeql-action@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' + - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/release.yml': - - 'actions/checkout@v7.0.1' - - 'actions/download-artifact@v8.0.2' - - 'actions/upload-artifact@v7.0.2' - - 'dtolnay/rust-toolchain@v1' - - 'softprops/action-gh-release@v3.0.3' - - 'swatinem/rust-cache@v2.9.3' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'slsa-framework/slsa-github-generator@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' + - 'softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64' + - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/rust-ci.yml': - - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/scan-and-report.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/scorecard.yml': - - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/secret-scanner.yml': - - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + '.github/workflows/squabble-gate.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' + '.github/workflows/verify.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' dependencies: 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': ref: 'v6.1.0' commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' owner_id: 44036562 repo_id: 215566462 - 'actions/cache@v6.1.0': - ref: 'v6.1.0' - commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - owner_id: 44036562 - repo_id: 215566462 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' owner_id: 44036562 repo_id: 197814629 - 'actions/checkout@v7.0.1': - ref: 'v7.0.1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 44036562 - repo_id: 197814629 - 'actions/configure-pages@v6.0.0': + 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d': ref: 'v6.0.0' commit: 'sha1-45bfe0192ca1faeb007ade9deae92b16b8254a0d' owner_id: 44036562 repo_id: 513659658 - 'actions/dependency-review-action@v5.0.0': + 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294': ref: 'v5.0.0' commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294' owner_id: 44036562 repo_id: 476372928 - 'actions/deploy-pages@v5.0.1': + 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346': ref: 'v5.0.1' commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' owner_id: 44036562 @@ -113,139 +127,48 @@ dependencies: commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' owner_id: 44036562 repo_id: 192626254 - 'actions/download-artifact@v8.0.2': - ref: 'v8.0.2' - commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' - owner_id: 44036562 - repo_id: 192626254 - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': - ref: 'v7.0.0' - commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' - owner_id: 44036562 - repo_id: 192625955 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9': ref: 'v7.0.2' commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' owner_id: 44036562 repo_id: 192625955 - 'actions/upload-artifact@v7.0.2': - ref: 'v7.0.2' - commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' - owner_id: 44036562 - repo_id: 192625955 - 'actions/upload-pages-artifact@v5.0.0': + 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9': ref: 'v5.0.0' commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' owner_id: 44036562 repo_id: 496012378 - uses: - - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed': - ref: 'v2.0.5' - commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' - owner_id: 42048915 - repo_id: 356423100 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8': ref: 'v1' commit: 'sha1-683b588c487a69d01f6c6ddf40f036ff682cc2c8' owner_id: 1940490 repo_id: 260749683 - 'dtolnay/rust-toolchain@v1': - ref: 'v1' - commit: 'sha1-683b588c487a69d01f6c6ddf40f036ff682cc2c8' - owner_id: 1940490 - repo_id: 260749683 - 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393': - ref: 'v3.0.0' - commit: 'sha1-51f63319f592f97930c73d9c46184d20bd206393' - owner_id: 26415196 - repo_id: 297874902 - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124': - ref: 'v1.24.1' - commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' - owner_id: 47606891 - repo_id: 331103973 - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': - ref: 'v4.38.0' - commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' - owner_id: 9919 - repo_id: 259445878 - 'github/codeql-action@v4.39.0': + 'github/codeql-action@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee': ref: 'v4.39.0' commit: 'sha1-6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' owner_id: 9919 repo_id: 259445878 - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406': - ref: 'v2.2.1' - commit: 'sha1-abea47f85e598557f500fa1fd2ab7464fcb39406' - owner_id: 1006268 - repo_id: 212984112 - 'haskell-actions/setup@v2.12.1': + 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d': ref: 'v2.12.1' commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' owner_id: 75048950 repo_id: 623796603 - 'hyperpolymath/smtp-notify-action@v0.5.0': + 'hyperpolymath/smtp-notify-action@c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef': ref: 'v0.5.0' commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' owner_id: 6759885 repo_id: 1352485172 - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7': - ref: '333b06a01db1874ffedb2de1bfabb195640927c7' - commit: 'sha1-333b06a01db1874ffedb2de1bfabb195640927c7' - owner_id: 6759885 - repo_id: 1116521501 - uses: - - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' - - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' - - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - - 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393' - - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' - - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' - - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' - - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' - - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' - - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e': - ref: 'v2.5.0' - commit: 'sha1-fa7117db83c52d787ae5a89f350999701505d78e' - owner_id: 67707773 - repo_id: 421101922 - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6': - ref: 'v2.2.0' - commit: 'sha1-0c5077e51419868618aeaa5fe8019c62421857d6' - owner_id: 108928776 - repo_id: 512644635 - 'peter-evans/repository-dispatch@v4.0.1': + 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697': ref: 'v4.0.1' commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' owner_id: 18365890 repo_id: 220359305 - 'softprops/action-gh-release@v3.0.3': + 'softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64': ref: 'v3.0.3' commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' owner_id: 2242 repo_id: 204253808 - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': - ref: 'v2.9.2' - commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - owner_id: 580492 - repo_id: 298565987 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0': ref: 'v2.9.3' commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' owner_id: 580492 repo_id: 298565987 - 'swatinem/rust-cache@v2.9.3': - ref: 'v2.9.3' - commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' - owner_id: 580492 - repo_id: 298565987 - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': - ref: 'v0.10.0' - commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' - owner_id: 135788 - repo_id: 208510314 diff --git a/.github/workflows/boj-build.yml b/.github/workflows/boj-build.yml index 97768c3..deaa98e 100644 --- a/.github/workflows/boj-build.yml +++ b/.github/workflows/boj-build.yml @@ -15,7 +15,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Trigger BoJ Server (Casket/ssg-mcp) run: | # Send a secure trigger to boj-server to build this repository diff --git a/.github/workflows/bridge-gate.yml b/.github/workflows/bridge-gate.yml index ffcc24b..ff78564 100644 --- a/.github/workflows/bridge-gate.yml +++ b/.github/workflows/bridge-gate.yml @@ -18,7 +18,7 @@ # # Review notes: # - B2 (major -> review) is left to the existing human-review path. -# - Production hardening: a released binary or a metadatastician/standards reusable workflow. +# - Production hardening: a released binary or a hyperpolymath/standards reusable workflow. # - github context via env: only (no ${{ }} in run lines) per estate rules. name: bridge-gate on: @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 25 steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: B3 gate -- nix bumps (no network) env: diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index f6eb2a4..e36ae02 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -22,10 +22,10 @@ jobs: timeout-minutes: 20 steps: - name: Checkout code - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable diff --git a/.github/workflows/casket-pages.yml b/.github/workflows/casket-pages.yml index 7931ee8..f60f54f 100644 --- a/.github/workflows/casket-pages.yml +++ b/.github/workflows/casket-pages.yml @@ -24,22 +24,22 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout casket-ssg - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/casket-ssg path: .casket-ssg - name: Setup GHCup - uses: haskell-actions/setup@v2.12.1 + uses: haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d # v2.12.1 with: ghc-version: '9.8.2' cabal-version: '3.10' - name: Cache Cabal - uses: actions/cache@v6.1.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cabal/packages @@ -101,10 +101,10 @@ jobs: touch ../_site/.nojekyll - name: Setup Pages - uses: actions/configure-pages@v6.0.0 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload artifact - uses: actions/upload-pages-artifact@v5.0.0 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: '_site' @@ -118,4 +118,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5.0.1 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/ci-integrity.yml b/.github/workflows/ci-integrity.yml new file mode 100644 index 0000000..5d5452c --- /dev/null +++ b/.github/workflows/ci-integrity.yml @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# ci-integrity.yml — gates on the CI *system* itself, not on the code. +# +# WHY THIS WORKFLOW EXISTS +# ------------------------ +# On 2026-10-25 this repository's CI went dark for four hours and the red was +# unreadable: 12 of 12 pull_request workflows concluded `startup_failure` with +# zero jobs and no log. The cause was not code. It was two policy mismatches +# between this repo and the estate's own settings canon: +# +# 1. `uses: metadatastician/standards/...` — the canonical allowlist +# (rhodium-standard-repositories/actions-allowlist/allowed-actions.json) +# permits `hyperpolymath/*` and does NOT permit `metadatastician/*`. +# 2. tag-pinned `uses:` (actions/checkout@v7.0.1) while the estate posture is +# `sha_pinning_required = true`. +# +# A run that never starts cannot be diagnosed from its log, cannot be +# satisfyingly re-run, and does not report a check at all — so a required +# context silently stops arriving and the branch looks healthier than it is. +# These four gates make each cause of that failure legible *before* it happens: +# +# sha-pins every third-party `uses:` names a 40-hex commit +# lock-coverage every SHA-pinned action is keyed in .github/workflows/actions.lock +# allowlist every `uses:` is covered by the estate allowlist +# yaml-parse every workflow actually parses, and has a jobs: map +# +# NO `paths:` FILTER, deliberately: this check is meant to be REQUIRED, and a +# required check that filters itself off never reports (see standards +# required-contexts.yml). It is a checkout plus a few seconds of bash. + +name: CI Integrity + +on: + pull_request: + push: + branches: [main, master] + schedule: + - cron: '17 5 * * 1' # Mondays, off the hour + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-integrity-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned: this is the parser the canon validators require (YAML-POLICY Y-1). + YQ_VERSION: v4.54.1 + +jobs: + integrity: + name: CI integrity (pins, lock, allowlist, YAML) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install yq + run: | + set -euo pipefail + url="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" + curl -fsSL "$url" -o /usr/local/bin/yq + chmod +x /usr/local/bin/yq + yq --version + + - name: SHA pins (canon rule 10) + run: bash .githooks/validate-sha-pins.sh + + - name: Lockfile is derived from the workflows + run: python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json --check + + - name: Lockfile covers every SHA-pinned action + run: bash .githooks/validate-actions-lock.sh + + - name: Actions allowlist (would this run start?) + run: bash scripts/ci/check-allowed-actions.sh .github/actions-allowlist.json .github/workflows + + - name: Every workflow parses and has a jobs map + run: | + set -euo pipefail + fail=0 + for f in .github/workflows/*.yml .github/workflows/*.yaml; do + [ -e "$f" ] || continue + case "$f" in *.lock) continue ;; esac + if ! yq -e '.' "$f" >/dev/null 2>&1; then + echo "::error file=$f::does not parse as YAML" + fail=1 + continue + fi + # A file that parses to a scalar (unclosed quote swallowing the + # document) has no jobs at all and dies at startup. Refuse it. + if [ "$(yq -r '.jobs | tag' "$f" 2>/dev/null)" != '!!map' ]; then + echo "::error file=$f::parses but declares no jobs: map" + fail=1 + fi + done + exit "$fail" + + - name: Summary + if: always() + run: | + { + echo "## CI integrity" + echo "" + echo "| Gate | What it prevents |" + echo "|------|------------------|" + echo "| SHA pins | tag-pinned \`uses:\` refused at startup under \`sha_pinning_required\` |" + echo "| Lockfile | a reusable caller dying with \`failure\`, 0 jobs, no reason |" + echo "| Allowlist | \`startup_failure\` from an action outside the estate allowlist |" + echo "| YAML parse | a workflow that parses to nothing and never runs |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c7a4e06..a3323c9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -43,15 +43,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.39.0 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.39.0 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 93c65c5..d57f56c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -20,10 +20,10 @@ jobs: timeout-minutes: 20 steps: - name: Checkout code - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable components: llvm-tools-preview diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 6326643..d7f2822 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -19,9 +19,9 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Dependency Review - uses: actions/dependency-review-action@v5.0.0 + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: moderate diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 9492960..889c7fc 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check for A2ML files id: detect @@ -70,7 +70,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check for K9 files id: detect @@ -98,7 +98,7 @@ jobs: :warning: **No .a2ml/.deed manifest files found.** Every RSR-compliant repo should have a repo deed (`_chora.deed`) at its root. - Author them per metadatastician/standards `1-formats/k9/spec/K9-CONTRACT-SPEC.adoc` (D173). + Author them per hyperpolymath/standards `1-formats/k9/spec/K9-CONTRACT-SPEC.adoc` (D173). EOF else echo "## K9 Contract Validation" >> "$GITHUB_STEP_SUMMARY" @@ -116,7 +116,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan for invisible characters id: lint @@ -181,7 +181,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check for Groove manifest id: groove @@ -242,7 +242,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate dogfooding scorecard run: | diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 10d05d5..940771c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -40,15 +40,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable - name: Rust cache - uses: Swatinem/rust-cache@v2.9.3 + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - name: Build release binary run: cargo build --release @@ -63,15 +63,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable - name: Rust cache - uses: Swatinem/rust-cache@v2.9.3 + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - name: Build release binary run: cargo build --release @@ -115,7 +115,7 @@ jobs: - name: Upload E2E artifacts if: always() - uses: actions/upload-artifact@v7.0.2 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: e2e-scan-results path: /tmp/*-scan.json @@ -129,15 +129,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable - name: Rust cache - uses: Swatinem/rust-cache@v2.9.3 + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - name: Run readiness tests (Grade D/C/B) run: cargo test --test readiness -- --nocapture @@ -150,22 +150,22 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable - name: Rust cache - uses: Swatinem/rust-cache@v2.9.3 + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - name: Run benchmarks run: cargo bench 2>&1 | tee /tmp/bench-results.txt - name: Upload benchmark results if: always() - uses: actions/upload-artifact@v7.0.2 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: benchmark-results path: /tmp/bench-results.txt diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 1b399be..08448a5 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -17,4 +17,4 @@ permissions: jobs: governance: - uses: metadatastician/standards/.github/workflows/governance-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index ca08c01..720ee31 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -20,4 +20,4 @@ permissions: jobs: hypatia: - uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 diff --git a/.github/workflows/instant-sync.yml b/.github/workflows/instant-sync.yml index 9aeb0e1..94fdeb6 100644 --- a/.github/workflows/instant-sync.yml +++ b/.github/workflows/instant-sync.yml @@ -20,7 +20,7 @@ jobs: timeout-minutes: 10 steps: - name: Trigger Propagation - uses: peter-evans/repository-dispatch@v4.0.1 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.FARM_DISPATCH_TOKEN }} repository: hyperpolymath/.git-private-farm diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 7b36821..5ec8af8 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -15,7 +15,7 @@ permissions: jobs: mirror: - uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 secrets: GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 2371e9b..ed65d36 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -22,9 +22,9 @@ jobs: image: ghcr.io/stefan-hoeck/idris2-pack@sha256:f0758996a931fb35d9ecb1de273c4d59dabe2a09b433afc7e357f65a08b7e1ff steps: - name: Checkout Site - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout Ddraig SSG - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/ddraig-ssg path: .ddraig-ssg @@ -41,7 +41,7 @@ jobs: fi ./.ddraig-ssg/build/exec/ddraig build src _site https://hyperpolymath.github.io/${GITHUB_REPOSITORY#*/} - name: Upload artifact - uses: actions/upload-pages-artifact@v5.0.0 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: '_site' deploy: @@ -54,4 +54,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5.0.1 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/push-email-notify.yml b/.github/workflows/push-email-notify.yml index 3cd69a5..3178444 100644 --- a/.github/workflows/push-email-notify.yml +++ b/.github/workflows/push-email-notify.yml @@ -41,7 +41,7 @@ jobs: timeout-minutes: 5 steps: - name: Send push notification email - uses: hyperpolymath/smtp-notify-action@v0.5.0 + uses: hyperpolymath/smtp-notify-action@c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef # v0.5.0 with: server_address: ${{ secrets.SMTP_HOST }} server_port: ${{ secrets.SMTP_PORT }} diff --git a/.github/workflows/recon.yml b/.github/workflows/recon.yml new file mode 100644 index 0000000..8e26374 --- /dev/null +++ b/.github/workflows/recon.yml @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# recon.yml — deep recon: this repository, scanned by two independent analysers. +# +# WHY TWO +# ------- +# `panic-attack assail` answers "what here is weak?" — panic sites, unsafe +# blocks, injection sinks, deserialisation, FFI boundaries. `pons-asinorum` +# answers a different question: "what here is wasted, self-contradictory, or +# missing an escape hatch?" Its findings carry an evidence class, and its +# SPECULATIVE tier never gates a build by design. +# +# The two are design companions (pons's docs/adr/0004-companion-to-panic-attack +# .adoc) and they exchange versioned evidence while keeping independent +# analyses. Running both in one place is the only way to notice the case where +# one is blind: a finding that exists in both is corroborated evidence, a +# finding in exactly one is a prompt to look, and a finding in neither after a +# real defect is a hole in the scanners themselves. +# +# WHY IT DOES NOT FAIL ON FINDINGS +# -------------------------------- +# This repo is the scanner. A scan of its own source WILL report the things it +# is built to report (there are `unwrap`s, there is `unsafe`, there is regex). +# Failing on them would create pressure to suppress them, which is the failure +# mode the estate keeps paying for. Instead: +# +# * a new category, or a count that rises above `recon/baseline.json`, fails +# the job — regressions gate, the standing debt does not; +# * the full reports are uploaded and published as SARIF for the security tab; +# * the numbers are printed in the job summary so the debt is visible. +# +# Runs on schedule and dispatch: it is a deep scan, not a per-push cost. + +name: Recon + +on: + schedule: + - cron: '13 3 * * 2' # Tuesdays, off the hour + workflow_dispatch: + inputs: + fail_on_new: + description: 'Fail when a category exceeds the baseline' + required: false + type: boolean + default: true + +permissions: + contents: read + security-events: write # SARIF upload + +concurrency: + group: recon-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned: a scanner that changes under you cannot baseline anything. + PONS_PIN: 51b8920f541782ceb9465ea3f0d33c96104fc1fb + PONS_REPO: hyperpolymath/pons-asinorum + SARIF_DIR: recon-out + +jobs: + recon: + name: Deep recon (panic-attack + pons-asinorum) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout this repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: recon + + - name: Build panic-attack + run: cargo build --locked --release + + - name: Assail this repository (self-scan) + run: | + set -euo pipefail + mkdir -p "$SARIF_DIR" + ./target/release/panic-attack assail . \ + --output "$SARIF_DIR/assail.json" --quiet + ./target/release/panic-attack assail . --format sarif \ + --output "$SARIF_DIR/assail.sarif" --quiet || true + + - name: Checkout pons-asinorum at the pinned commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ env.PONS_REPO }} + ref: ${{ env.PONS_PIN }} + path: .pons + persist-credentials: false + + - name: Build pons-asinorum + run: cargo build --release --locked --manifest-path .pons/Cargo.toml -p pons-cli + + - name: Locate the pons binary + id: pons + run: | + set -euo pipefail + bin="$(find .pons/target/release -maxdepth 1 -type f -name 'pons' -perm -u+x | head -1)" + [ -n "$bin" ] || { echo "::error::pons binary not found after build"; exit 1; } + echo "path=$bin" >> "$GITHUB_OUTPUT" + "$bin" --version || true + "$bin" catalogue > "$SARIF_DIR/pons-catalogue.txt" || true + + - name: Pons scan (SARIF + JSON), self-validated + id: pons_scan + env: + PONS: ${{ steps.pons.outputs.path }} + run: | + set +e + "$PONS" scan . --format sarif > "$SARIF_DIR/pons.sarif" 2> "$SARIF_DIR/pons.err" + code=$? + set -e + echo "exit=$code" >> "$GITHUB_OUTPUT" + cat "$SARIF_DIR/pons.err" || true + # 0 below threshold, 1 threshold reached: both are a completed scan. + # 2 is invalid/incomplete input — a fact about the scan, not a pass. + case "$code" in + 0|1) echo "pons: scan completed (exit $code)" ;; + *) echo "::warning::pons exited $code — see pons.err (2 = invalid or incomplete input)" ;; + esac + "$PONS" scan . --format json > "$SARIF_DIR/pons.json" 2>/dev/null || true + if [ -s "$SARIF_DIR/pons.json" ]; then + "$PONS" validate-report "$SARIF_DIR/pons.json" || echo "::warning::pons report failed its own validator" + fi + + - name: Compare both scans against the baseline + id: compare + run: | + set -euo pipefail + args="" + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.fail_on_new }}" = "false" ]; then + args="--no-fail" + fi + python3 scripts/recon/compare.py \ + --baseline recon/baseline.json \ + --assail "$SARIF_DIR/assail.json" \ + --pons "$SARIF_DIR/pons.json" \ + --summary "$GITHUB_STEP_SUMMARY" \ + $args + + - name: Upload SARIF to code scanning + if: always() + uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + continue-on-error: true + with: + sarif_file: ${{ env.SARIF_DIR }} + category: recon + + - name: Upload recon artefacts + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: recon + path: ${{ env.SARIF_DIR }} + retention-days: 30 + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dbedce..3795c37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,15 +27,15 @@ jobs: outputs: hashes: ${{ steps.hash.outputs.hashes }} steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable - name: Cache cargo registry - uses: Swatinem/rust-cache@v2.9.3 + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - name: Run tests run: cargo test --verbose @@ -65,7 +65,7 @@ jobs: echo "hashes=$HASHES" >> "$GITHUB_OUTPUT" - name: Upload build artifacts - uses: actions/upload-artifact@v7.0.2 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-artifacts path: dist/panic-attack-*.tar.gz @@ -81,7 +81,7 @@ jobs: changelog: ${{ steps.cliff.outputs.content }} version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -109,7 +109,7 @@ jobs: git cliff --output CHANGELOG.md - name: Upload updated CHANGELOG.md - uses: actions/upload-artifact@v7.0.2 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: changelog path: CHANGELOG.md @@ -123,16 +123,16 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download build artifacts - uses: actions/download-artifact@v8.0.2 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-artifacts path: artifacts/ - name: Create GitHub Release - uses: softprops/action-gh-release@v3.0.3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: body: ${{ needs.changelog.outputs.changelog }} draft: false diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 55038ec..73b1435 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -3,7 +3,7 @@ # This workflow is managed by gh actions-lock. # This workflow is managed by gh actions-lock. # Rust CI — thin wrapper calling the shared estate reusable in -# metadatastician/standards. Configure once, propagate everywhere. +# hyperpolymath/standards. Configure once, propagate everywhere. # See: docs/CI-REUSABLE-WORKFLOWS.adoc in standards. name: Rust CI @@ -18,4 +18,4 @@ permissions: jobs: rust-ci: - uses: metadatastician/standards/.github/workflows/rust-ci-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # re-pinned 2026-07-01: toolchain-required-input fix (standards#439) + uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # re-pinned 2026-07-01: toolchain-required-input fix (standards#439) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e4486ba..398c75d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -15,7 +15,7 @@ permissions: jobs: scorecard: - uses: metadatastician/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 permissions: actions: read # required by scorecard-reusable.yml to read workflow metadata contents: read diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 67addca..e22e428 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -21,4 +21,4 @@ jobs: scan: permissions: contents: read - uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 diff --git a/.github/workflows/squabble-gate.yml b/.github/workflows/squabble-gate.yml new file mode 100644 index 0000000..826394a --- /dev/null +++ b/.github/workflows/squabble-gate.yml @@ -0,0 +1,197 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# squabble-gate.yml — the estate's CI/CD fighter, wired to this repository. +# +# WHY: on 2026-10-25 this repo's CI went dark and the failure was *unreadable* — +# twelve `startup_failure` runs, zero jobs, no logs. `cicd-squabbler` exists for +# exactly that shape: it turns a stuck gate into a legible one and proposes only +# moves that SATISFY the gate (SPARK-proved invariant: green ⇔ a required check +# actually ran and passed — admin overrides and dropped required contexts are +# provably not paths to green). +# +# WHAT IT RUNS +# fetch + diagnose the live gate for this PR: required contexts vs what +# actually reported, with proposed legitimate moves +# chains which repos this repo's CI depends on, and whether any of +# them is dead upstream (a deleted upstream produces no check +# run at all, so the board reads green) +# +# WHAT IT IS NOT: a code fixer. Red->green *code* fixes are out of scope for the +# squabbler by design; "fixing" a failing test by weakening it violates +# squabble ≠ bypass. +# +# TRIGGERS: weekly (drift), dispatch (manual diagnosis of a stuck PR), and on a +# PR only when it carries the `ci-squabble` label — so the cost is paid when +# someone is actually stuck, not on every push. + +name: CI Squabbler + +on: + schedule: + - cron: '23 6 * * 3' # Wednesdays, off the hour + workflow_dispatch: + inputs: + pr: + description: 'PR number to diagnose (blank = chains-only sweep)' + required: false + type: string + strict: + description: 'Fail the job on blocking chain findings (cycles, dead upstreams)' + required: false + type: boolean + default: false + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + actions: read + checks: read + +concurrency: + group: squabble-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned: a squabbler that changes under you cannot be a gate. + SQUABBLER_PIN: 14f853fde902ab2d7631070d87a9dc9122e01013 + SQUABBLER_REPO: hyperpolymath/cicd-squabbler + +jobs: + squabble: + name: Diagnose the gate + if: >- + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'ci-squabble') + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout this repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Checkout cicd-squabbler at the pinned commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ env.SQUABBLER_REPO }} + ref: ${{ env.SQUABBLER_PIN }} + path: .squabble + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + workspaces: .squabble + key: squabble + + - name: Build the squabbler + run: cargo build --release --locked --manifest-path .squabble/Cargo.toml -p squabble-cli + # The CLI binary is the workspace member's own target path. + - name: Locate the binary + id: bin + run: | + set -euo pipefail + bin="$(find .squabble/target/release -maxdepth 1 -type f -name 'squabble*' -perm -u+x | head -1)" + [ -n "$bin" ] || { echo "::error::squabble binary not found after build"; exit 1; } + echo "path=$bin" >> "$GITHUB_OUTPUT" + "$bin" --version + + # Exit codes are part of the contract: 0 ok · 2 failure · 3 no gate · + # 4 chains: blocking finding · 5 verify-satisfied: agent items remain. + # A tool failure (2) is never swallowed into a green. + - name: Cross-repo chain findings (read-only) + id: chains + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BIN: ${{ steps.bin.outputs.path }} + run: | + set +e + "$BIN" chains . gh:hyperpolymath/standards --follow 1 --json > /tmp/chains.json 2>/tmp/chains.err + code=$? + set -e + echo "code=$code" >> "$GITHUB_OUTPUT" + cat /tmp/chains.err || true + echo "exit=$code" + case "$code" in + 0) echo "chains: no blocking finding" ;; + 4) echo "::warning::chains: blocking finding (cycle or dead upstream) — see artifact" ;; + 2) echo "::error::chains: the tool failed; this is not a clean sweep" ; exit 1 ;; + *) echo "::warning::chains: unexpected exit $code" ;; + esac + [ -s /tmp/chains.json ] && head -c 2000 /tmp/chains.json || true + + - name: Diagnose the live gate + id: diagnose + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BIN: ${{ steps.bin.outputs.path }} + PR: ${{ inputs.pr }} + REPO: ${{ github.repository }} + run: | + set -uo pipefail + pr="${PR:-}" + if [ -z "$pr" ] && [ "${{ github.event_name }}" = "pull_request" ]; then + pr="${{ github.event.pull_request.number }}" + fi + if [ -z "$pr" ]; then + echo "::notice::no PR given — running the chains sweep only" + exit 0 + fi + set +e + "$BIN" fetch "$REPO" "$pr" > /tmp/gate.json 2>/tmp/gate.err + code=$? + set -e + if [ "$code" -ne 0 ]; then + echo "::warning file=.github/workflows/squabble-gate.yml::squabble fetch exited $code (3 = no required-status-checks rule on the base branch)" + cat /tmp/gate.err || true + exit 0 + fi + "$BIN" diagnose /tmp/gate.json | tee /tmp/diagnose.txt + + - name: Job summary + if: always() + run: | + { + echo "## CI squabbler" + echo "" + echo "Pinned to \`$SQUABBLER_REPO\` at \`$SQUABBLER_PIN\`." + echo "" + echo "| surface | exit | meaning |" + echo "|---|---|---|" + echo "| chains | ${{ steps.chains.outputs.code }} | 0 clean · 4 blocking finding (cycle / dead upstream) |" + echo "" + if [ -f /tmp/diagnose.txt ]; then + echo '```' + head -80 /tmp/diagnose.txt + echo '```' + fi + echo "" + echo "_A squabble wins the gate by satisfying it. It never overrides one._" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Fail on blocking chain findings when asked + if: inputs.strict && steps.chains.outputs.code == '4' + run: | + echo "::error::blocking chain finding and strict mode was requested" + exit 1 + + - name: Upload the evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: squabble-report + path: | + /tmp/chains.json + /tmp/gate.json + /tmp/diagnose.txt + retention-days: 30 + if-no-files-found: ignore diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..1c5131e --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# verify.yml — the foundation CI that CANNOT be taken out by another repository. +# +# WHY THIS EXISTS +# --------------- +# `rust-ci.yml`, `governance.yml`, `hypatia-scan.yml` and `secret-scanner.yml` +# are thin wrappers over reusable workflows in `hyperpolymath/standards`. That +# is the estate's intended shape, and it stays. But every one of those gates is +# only as available as the callee's Actions "Access" setting and this repo's +# allowlist permit at that instant. On 2026-10-25 all four died before their +# first job (`failure`, zero jobs, no log), and with them went the only +# build/test signal that did not depend on a foreign repository. +# +# So the guarantees that must never be absent live here, on actions this repo +# pins itself: +# +# fmt formatting is not a matter of taste in review +# clippy `-D warnings` — the repo's zero-warning policy, enforced +# test the suite, with the feature matrix that ships +# doc every public item is documented and the docs build +# coverage-self-scan the tool's own coverage ledger is honest +# +# The reusable-wrapper gates stay as the estate's richer checks (audit, +# coverage upload, Hypatia SARIF). This one is the floor. + +name: Verify + +on: + pull_request: + branches: ['**'] + push: + branches: [main, master, develop] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: verify-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + +jobs: + fmt: + name: cargo fmt --check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + name: cargo clippy -D warnings + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + components: clippy + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + # `gui` is deliberately excluded: eframe/egui needs system libraries this + # job does not install, and the GUI is opt-in. Everything else is checked. + - run: cargo clippy --locked --all-targets --features http,sarif,signing -- -D warnings + + test: + name: cargo test (${{ matrix.features }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - features: '' # default: offline, zero-network build + - features: http,sarif # Patch Bridge + SARIF + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: features-${{ matrix.features }} + # `--locked` is a gate in itself: the lockfile must agree with Cargo.toml + # in the commit under test, or the artefact CI verified is not the + # artefact released. + - name: Test + run: | + set -euo pipefail + if [ -n "${{ matrix.features }}" ]; then + cargo test --locked --features "${{ matrix.features }}" + else + cargo test --locked + fi + + msrv: + name: MSRV (rust-version in Cargo.toml) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Read rust-version + id: msrv + run: | + set -euo pipefail + msrv="$(sed -n 's/^rust-version *= *"\(.*\)"/\1/p' Cargo.toml | head -1)" + [ -n "$msrv" ] || { echo "::error::Cargo.toml declares no rust-version"; exit 1; } + echo "toolchain=$msrv" >> "$GITHUB_OUTPUT" + echo "MSRV declared: $msrv" + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: ${{ steps.msrv.outputs.toolchain }} + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: msrv + - run: cargo check --locked --all-targets + + docs: + name: cargo doc (no broken intra-doc links) + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + RUSTDOCFLAGS: -D warnings + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: docs + - run: cargo doc --locked --no-deps --features http,sarif,signing + + coverage-ledger: + name: Coverage ledger is honest (--fail-on-incomplete) + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: coverage-ledger + - name: Build + run: cargo build --locked --release + # A scan of a tree whose every file has an adapter must be `complete`. + # This is the CI use of --fail-on-incomplete: a green here means the + # declared inventory was actually analysed, not that no file was found. + - name: Complete-coverage fixture must scan complete + run: | + set -euo pipefail + mkdir -p /tmp/ph-ok/src + printf ' /tmp/ph-ok/src/a.php + printf 'fn main() { let s = "x"; println!("{}", s); }\n' > /tmp/ph-ok/src/main.rs + ./target/release/panic-attack assail /tmp/ph-ok \ + --output /tmp/ph-ok.json --fail-on-incomplete --quiet + python3 - <<'PY' + import json + report = json.load(open('/tmp/ph-ok.json')) + coverage = report['coverage'] + assert coverage['status'] == 'complete', coverage['status'] + assert coverage['analyzed_files'] == coverage['candidate_files'] >= 2, coverage + print(f"coverage={coverage['status']} files={coverage['analyzed_files']}") + PY + # An unparseable PHP file is a coverage GAP, not a silent pass, and the + # process must say so with exit 2 after writing the report. + - name: Malformed input must exit 2 with a written report + run: | + set -euo pipefail + mkdir -p /tmp/ph-bad/src + printf ' /tmp/ph-bad/src/broken.php + set +e + ./target/release/panic-attack assail /tmp/ph-bad \ + --output /tmp/ph-bad.json --fail-on-incomplete --quiet + code=$? + set -e + [ "$code" -eq 2 ] || { echo "::error::expected exit 2 for incomplete coverage, got $code"; exit 1; } + [ -s /tmp/ph-bad.json ] || { echo "::error::report was not written before exit"; exit 1; } + python3 - <<'PY' + import json + coverage = json.load(open('/tmp/ph-bad.json'))['coverage'] + assert coverage['status'] != 'complete', coverage['status'] + assert any(i.get('status') not in ('analyzed', 'excluded') for i in coverage['inputs']), coverage + print(f"coverage={coverage['status']} (gap recorded)") + PY diff --git a/.machine_readable/anchors/ANCHOR.a2ml b/.machine_readable/anchors/ANCHOR.a2ml index 70dcf3c..b7a9e01 100644 --- a/.machine_readable/anchors/ANCHOR.a2ml +++ b/.machine_readable/anchors/ANCHOR.a2ml @@ -1,3 +1,5 @@ +; SPDX-License-Identifier: MPL-2.0 +; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell # ⚓ ANCHOR: panic-attack # This is the canonical authority for the panic-attack repository. diff --git a/panic-attack_chora.deed b/panic-attack_chora.deed new file mode 100644 index 0000000..9c407ed --- /dev/null +++ b/panic-attack_chora.deed @@ -0,0 +1,32 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +; Repository deed for hyperpolymath/panic-attack. +; +; Grammar: hyperpolymath/standards 1-formats/deed/spec/abnf/deed.abnf (v1.2.0). +; Vocabulary: 1-formats/deed/vocabulary/*.adoc. +; +; This file is why `dogfood-gate.yml`'s "no .deed found" warning is gone: every +; RSR repository carries a repo deed at its root, and a warning that never fails +; is a warning nobody reads. +(repo-deed + :schema-version "1.0.0" + :canonical-name "panic-attack" + :repo-uuid #u5"hyperpolymath/panic-attack" + :beholding-chora #u5"estate/chora" + (status + :phase active + :note "static analysis, stress testing and logic-based bug signature detection") + (maturity + :level stable + :since "2026-02-08") + (lineage + :type hub + :parent "" + :previous-names ("panic-attacker") + :note "renamed 2026-02-08: binary panic-attacker -> panic-attack, xray -> assail, XRayReport -> AssailReport") + (obligation + :name "evidence-not-proof" + :note "T0 syntax heuristics are labelled T0. A finding is never dressed up as a parser or dataflow proof; see docs/RECON-AND-EVIDENCE.adoc") + (obligation + :name "no-self-suppression" + :note "suppressions live in a separate registry file (audits/assail-classifications.a2ml) so one hand cannot both add a defect and hide it")) diff --git a/recon/baseline.json b/recon/baseline.json new file mode 100644 index 0000000..ac6a343 --- /dev/null +++ b/recon/baseline.json @@ -0,0 +1,13 @@ +{ + "_comment": [ + "Recon baseline: what this repository's own scanners currently report about it.", + "An empty map means 'no frozen baseline yet': the recon job reports and does not", + "fail, and its summary prints the JSON block to commit here. Freezing is a", + "deliberate, reviewable act — the baseline is debt, not a target.", + "A category that appears after the baseline is frozen, or a count above it, fails", + "the job. A count below it is reported as debt paid and should be tightened in the", + "same PR that fixed it." + ], + "assail": {}, + "pons": {} +} diff --git a/scripts/ci/check-allowed-actions.sh b/scripts/ci/check-allowed-actions.sh new file mode 100755 index 0000000..ed09274 --- /dev/null +++ b/scripts/ci/check-allowed-actions.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# check-allowed-actions.sh — make the un-squabble-able startup_failure legible. +# +# When a repo sets `allowed_actions: selected` (RSR default) and a workflow +# `uses:` an action or reusable-workflow the allowlist does not permit, the run +# dies at STARTUP with no job and no log — an opaque red you cannot "squabble" +# with (RSR-PHILOSOPHY: reach green by satisfying the gate, never admin-override). +# +# This check turns that into a concrete, satisfiable gate: it lists exactly which +# `uses:` are not covered by the canonical allowlist, so the fix is obvious +# (add the pattern, or run set-allowed-actions.sh). Needs NO API and NO admin — +# safe to run in CI, in rhodibot, or locally. +# +# Usage: check-allowed-actions.sh [ALLOWED_ACTIONS_JSON] [WORKFLOWS_DIR] +# Exit: 0 = every `uses:` is covered; 1 = one or more gaps (printed). +set -euo pipefail +CANON="${1:-rhodium-standard-repositories/actions-allowlist/allowed-actions.json}" +WF_DIR="${2:-.github/workflows}" + +if [ ! -f "$CANON" ]; then + # fall back to the published canonical list in standards + CANON_URL="https://raw.githubusercontent.com/hyperpolymath/standards/main/rhodium-standard-repositories/actions-allowlist/allowed-actions.json" + TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT + curl -fsSL "$CANON_URL" -o "$TMP" && CANON="$TMP" || { echo "!! no allowlist ($CANON)"; exit 2; } +fi + +python3 - "$CANON" "$WF_DIR" <<'PY' +import json, sys, os, re, glob, fnmatch +canon = json.load(open(sys.argv[1])) +patterns = canon.get("patterns_allowed", []) +gh_owned = {"actions", "github"} # github_owned_allowed + +def owner_repo(u): + base = u.split('@', 1)[0] + # GitHub owner/repository names are case-insensitive. + parts = base.lower().split('/') + return '/'.join(parts[:2]), parts[0] + +def covered(u): + # $/ is gh actions-lock's inherently-pinned same-repo form (like ./) + if u.startswith('./') or u.startswith('$/') or u.startswith('docker://'): + return True + orp, owner = owner_repo(u) + if owner in gh_owned: # github-owned (actions/*, github/*) + return True + for p in patterns: + base = p.split('@', 1)[0].lower() + if base == f"{owner}/*": # owner-wide (covers reusable workflows) + return True + if base == orp or fnmatch.fnmatch(orp, base): + return True + return False + +uses = set() +for f in sorted(glob.glob(os.path.join(sys.argv[2], '*.yml')) + + glob.glob(os.path.join(sys.argv[2], '*.yaml'))): + for line in open(f, encoding='utf-8'): + m = re.search(r'^\s*(?:-\s*)?uses:\s*([^\s#]+)', line) + if m: + uses.add(m.group(1).strip().strip('"\'')) + +gaps = sorted(u for u in uses if not covered(u)) +for u in gaps: + print(f"GAP {u} (add its owner/* or owner/repo@* pattern, or run set-allowed-actions.sh)") +print(f"checked {len(uses)} `uses:` refs across {sys.argv[2]} — {len(gaps)} not covered by the allowlist") +sys.exit(1 if gaps else 0) +PY diff --git a/scripts/ci/pin-workflow-uses.py b/scripts/ci/pin-workflow-uses.py new file mode 100755 index 0000000..a698fab --- /dev/null +++ b/scripts/ci/pin-workflow-uses.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +"""Pin every `uses:` ref in .github/workflows to a full commit SHA, and +regenerate .github/workflows/actions.lock in the format `gh actions-lock` +emits. + +Why this exists as a checked-in script rather than a one-off edit: + + * the estate runs `actions_permissions.sha_pinning_required = true` + (standards config/settings/repo.json). Under that posture a tag-pinned + `uses:` is refused at workflow STARTUP: zero jobs, no log, an opaque + "workflow file issue". That is the same class of outage as an empty + allowlist, and it is invisible in a green-looking PR. + * `gh actions-lock` will not touch a workflow whose refs it did not write + itself, and it rewrites local composite refs into invalid `$/` syntax in + some modes. Pinning is therefore done here, deterministically, and the + lockfile is derived from the workflows rather than trusted. + +Usage: + scripts/ci/pin-workflow-uses.py --map pins.json [--check] + +`pins.json` maps `owner/repo@ref` -> {"sha": ..., "tag": ...}. It is produced +by scripts/ci/resolve-action-shas.sh so that this script needs no network. + +--check exits 1 if any `uses:` is not a 40-hex SHA or if the lockfile would +change; CI runs that mode so drift cannot land. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +WORKFLOW_DIR = Path(".github/workflows") +LOCKFILE = WORKFLOW_DIR / "actions.lock" + +# `- uses: owner/repo@ref # comment` / `uses: owner/repo/path@ref` +USES_RE = re.compile( + r"^(?P\s*(?:-\s+)?)uses:\s*(?P[^\s#]+)(?P\s*#\s*(?P.*))?$" +) +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +SHA_COMMENT_RE = re.compile(r"^\s*[#]?\s*(v?\d[\w.\-+]*)\s*$") + + +def normalise(ref: str) -> tuple[str, str, str]: + """Split `owner/repo[/sub/path...]@ref` into (owner/repo, path, ref).""" + path, _, gitref = ref.partition("@") + parts = path.split("/") + slug = "/".join(parts[:2]) + return slug, path, gitref + + +def workflow_files() -> list[Path]: + files = sorted(WORKFLOW_DIR.glob("*.yml")) + sorted(WORKFLOW_DIR.glob("*.yaml")) + return [f for f in files if f.is_file()] + + +def rewrite(workflow: Path, pins: dict[str, dict[str, str]], check: bool) -> tuple[str, bool]: + text = workflow.read_text(encoding="utf-8") + changed = False + out_lines: list[str] = [] + + for line in text.splitlines(keepends=True): + stripped = line.lstrip() + if stripped.startswith("#") or "uses:" not in line: + out_lines.append(line) + continue + m = USES_RE.match(line.rstrip("\n")) + if not m: + out_lines.append(line) + continue + ref = m.group("ref") + if ref.startswith("./") or ref.startswith("docker://") or "/" not in ref: + out_lines.append(line) + continue + + slug, _, gitref = normalise(ref) + comment = (m.group("comment") or "").strip() + + if SHA_RE.match(gitref): + # Already pinned. Ensure a version comment names the tag we know. + tag = None + for key, info in pins.items(): + if normalise(key)[0].lower() == slug.lower() and info.get("sha", "").lower() == gitref.lower(): + tag = info.get("tag") + break + if tag and (not comment or SHA_RE.match(comment)): + new_line = f"{m.group('lead')}uses: {ref} # {tag}\n" + if new_line != line: + changed = True + out_lines.append(new_line) + continue + out_lines.append(line) + continue + + key = f"{slug}@{gitref}" + info = pins.get(key) or next( + (v for k, v in pins.items() if k.lower() == key.lower()), None + ) + if not info or not SHA_RE.match(info.get("sha", "")): + print(f"::error file={workflow}::no SHA known for {key}", file=sys.stderr) + out_lines.append(line) + continue + + pin = info["sha"].lower() + tag = info.get("tag") or gitref + tail = f" # {tag}" + head = f"{m.group('lead')}uses: {ref.replace('@' + gitref, '@' + pin, 1)}" + new_line = head + tail + "\n" + if new_line != line: + changed = True + out_lines.append(new_line) + + return "".join(out_lines), changed + + +def refs_in(workflow: Path) -> list[str]: + refs = [] + for line in workflow.read_text(encoding="utf-8").splitlines(): + stripped = line.lstrip() + if stripped.startswith("#") or "uses:" not in line: + continue + m = USES_RE.match(line) + if not m: + continue + ref = m.group("ref") + if ref.startswith("./") or ref.startswith("docker://") or "/" not in ref: + continue + refs.append(ref) + return sorted(set(refs)) + + +def build_lock(pins: dict[str, dict[str, str]], existing: str) -> str: + """Emit actions.lock: `workflows:` lists refs as written; `dependencies:` + is keyed by normalised owner/repo@sha and must carry the *tag* as `ref` + (never its own SHA — standards#1271).""" + # Seed tag/identity knowledge from the lockfile that is already committed. + # We never invent an owner_id/repo_id: if a ref is genuinely new, the + # maintainer must resolve it (scripts/ci/resolve-action-shas.sh does the + # network call) and this script refuses to guess. + ids: dict[str, list[str]] = {} + by_sha: dict[tuple[str, str], str] = {} + + def slot(key: str) -> list[str]: + return ids.setdefault(key.lower(), ["", "", ""]) + + # Parse the lockfile as entry blocks so a tag-keyed entry can lend its + # owner/repo ids to the SHA-keyed entry we are about to write. + id_by_tag: dict[tuple[str, str], tuple[str, str]] = {} + blocks: list[dict[str, str]] = [] + cur: dict[str, str] | None = None + for line in existing.splitlines(): + m = re.match(r"^ '([^']+)':$", line) + if m: + cur = {"key": m.group(1)} + blocks.append(cur) + continue + if cur is None: + continue + m = re.match(r"^ (ref|owner_id|repo_id): '?([^'\n]*)'?$", line) + if m: + cur[m.group(1)] = m.group(2) + for b in blocks: + key = b.get("key", "") + slug, _, ref = normalise(key) + if not key: + continue + slot(key)[:2] = [b.get("owner_id", ""), b.get("repo_id", "")] + slot(key)[2] = b.get("ref", "") + if b.get("owner_id"): + id_by_tag[(slug.lower(), b.get("ref", ""))] = ( + b["owner_id"], + b.get("repo_id", ""), + ) + if SHA_RE.match(ref) and b.get("ref") and not SHA_RE.match(b["ref"]): + by_sha[(slug.lower(), ref.lower())] = b["ref"] + + # Tags named by the pins map (owner/repo@tag -> sha) beat nothing else. + for key, info in pins.items(): + slug, _, tag = normalise(key) + sha = info.get("sha", "") + if SHA_RE.match(sha) and tag and not SHA_RE.match(tag): + by_sha[(slug.lower(), sha.lower())] = tag + + # A `# vX.Y.Z` comment on a pinned ref is the reviewer-facing tag. + comment_tag: dict[tuple[str, str], str] = {} + for wf in workflow_files(): + for line in wf.read_text(encoding="utf-8").splitlines(): + m = USES_RE.match(line) + if not m: + continue + slug, path, gitref = normalise(m.group("ref")) + comment = (m.group("comment") or "").strip() + if not SHA_RE.match(gitref) or not comment or ".github/workflows/" in path: + continue + if re.match(r"^v?\d[\w.\-+]*$", comment): + comment_tag[(slug.lower(), gitref.lower())] = comment + + per_file: dict[str, list[str]] = {} + deps: dict[str, tuple[str, str, str, str]] = {} + missing: list[str] = [] + for wf in workflow_files(): + entries = [] + for ref in refs_in(wf): + slug, path, gitref = normalise(ref) + entries.append(f"{slug.lower()}@{gitref}") + # The lockfile models ACTIONS. A reusable workflow + # (owner/repo/.github/workflows/x.yml@sha) is not keyed. + if ".github/workflows/" in path or not SHA_RE.match(gitref): + continue + key = f"{slug.lower()}@{gitref.lower()}" + tag = ( + comment_tag.get((slug.lower(), gitref.lower())) + or by_sha.get((slug.lower(), gitref.lower())) + or pins.get(f"{slug}@{gitref}", {}).get("tag", "") + ) + owner_id, repo_id = slot(key)[:2] + if not (owner_id and repo_id): + owner_id, repo_id = id_by_tag.get((slug.lower(), tag or ""), ("", "")) + if not (tag and not SHA_RE.match(tag) and owner_id and repo_id): + missing.append(f"{slug}@{gitref}") + continue + deps[key] = (tag, owner_id, repo_id, gitref.lower()) + per_file[f".github/workflows/{wf.name}"] = sorted(set(entries)) + + if missing: + raise SystemExit( + "pin-workflow-uses: cannot key these refs in actions.lock " + "(need a tag and owner/repo ids; see scripts/ci/resolve-action-shas.sh):\n " + + "\n ".join(sorted(set(missing))) + ) + + out = [ + "# This file is machine-generated by `gh actions-lock`.", + "# Do not edit by hand; run `gh actions-lock` to update.", + "# Docs: https://gh.io/actions-lockfile", + "version: 'v0.0.3'", + "workflows:", + ] + for path in sorted(per_file): + if not per_file[path]: + out.append(f" '{path}': []") + continue + out.append(f" '{path}':") + for e in per_file[path]: + out.append(f" - '{e}'") + out.append("dependencies:") + for key in sorted(deps): + tag, owner_id, repo_id, sha = deps[key] + out.append(f" '{key}':") + out.append(f" ref: '{tag}'") + out.append(f" commit: 'sha1-{sha}'") + out.append(f" owner_id: {owner_id}") + out.append(f" repo_id: {repo_id}") + return "\n".join(out) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--map", required=True, help="pins JSON (see module docstring)") + ap.add_argument("--check", action="store_true", help="verify only; never write") + args = ap.parse_args() + + pins = json.loads(Path(args.map).read_text(encoding="utf-8")) + failures = 0 + + for wf in workflow_files(): + new, changed = rewrite(wf, pins, args.check) + if changed: + failures += 1 + if args.check: + print(f"::error file={wf}::unpinned or un-commented `uses:` (run scripts/ci/pin-workflow-uses.py)", file=sys.stderr) + else: + wf.write_text(new, encoding="utf-8") + print(f"pinned {wf}") + + existing = LOCKFILE.read_text(encoding="utf-8") if LOCKFILE.exists() else "" + lock = build_lock(pins, existing) + if lock != existing: + failures += 1 + if args.check: + print("::error file=.github/workflows/actions.lock::lockfile is stale (regenerate)", file=sys.stderr) + else: + LOCKFILE.write_text(lock, encoding="utf-8") + print("regenerated actions.lock") + + if args.check and failures: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/resolve-action-shas.sh b/scripts/ci/resolve-action-shas.sh new file mode 100755 index 0000000..a3aed73 --- /dev/null +++ b/scripts/ci/resolve-action-shas.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# Resolve every tag/branch `uses:` ref in .github/workflows to a full commit +# SHA and write the mapping to a JSON file for scripts/ci/pin-workflow-uses.py. +# +# Needs `gh` (authenticated) and `jq`. One API call per distinct ref. +# +# Usage: scripts/ci/resolve-action-shas.sh [output.json] +# default output: .github/workflows/action-pins.json +# +# The output is deliberately checked in next to the lockfile: it records which +# tag a pin stood for on the day it was taken, which is the only way a reviewer +# can tell an intentional bump from a silent re-point. +set -euo pipefail + +OUT="${1:-.github/workflows/action-pins.json}" +command -v gh >/dev/null || { echo "resolve-action-shas: gh not found" >&2; exit 2; } +command -v jq >/dev/null || { echo "resolve-action-shas: jq not found" >&2; exit 2; } + +# Every `uses:` ref, excluding comments, local actions and container images. +mapfile -t REFS < <( + grep -hE '^[[:space:]]*(-[[:space:]]+)?uses:' .github/workflows/*.yml 2>/dev/null \ + | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//' \ + | sed -E 's/[[:space:]]*#.*$//' \ + | grep -E '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+' \ + | sort -u +) +[ "${#REFS[@]}" -gt 0 ] || { echo "resolve-action-shas: no refs found" >&2; exit 1; } + +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +echo '{}' > "$tmp" + +for ref in "${REFS[@]}"; do + # Actions and reusable workflows are identified by owner/repo only: strip any + # sub-path (`github/codeql-action/init`, or a reusable's + # `.github/workflows/x.yml`). Keep exactly the first two segments. + path="${ref%@*}" + tag="${ref##*@}" + owner="${path%%/*}" + remainder="${path#*/}" + repo="${remainder%%/*}" + [ -n "$owner" ] && [ -n "$repo" ] && [ "$repo" != "$remainder" -o -n "$repo" ] || continue + action="${owner}/${repo}" + if [[ "$tag" =~ ^[0-9a-f]{40}$ ]]; then + sha="$tag" + else + sha="$(gh api "repos/$action/commits/$tag" --jq .sha 2>/dev/null || true)" + fi + if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "resolve-action-shas: could not resolve $ref" >&2 + continue + fi + jq --arg key "${action}@${tag}" --arg sha "$sha" --arg tag "$tag" \ + '.[$key] = {sha: $sha, tag: $tag}' "$tmp" > "$tmp.new" && mv "$tmp.new" "$tmp" + printf ' %-62s -> %s\n' "${action}@${tag}" "$sha" +done + +jq -S . "$tmp" > "$OUT" +echo "wrote $OUT ($(jq 'length' "$OUT") refs)" diff --git a/scripts/recon/compare.py b/scripts/recon/compare.py new file mode 100644 index 0000000..1cd9c18 --- /dev/null +++ b/scripts/recon/compare.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +"""Compare two independent scans of this repository against a frozen baseline. + +The baseline is a *debt ledger*, not a target. It exists so that: + + * a category that did not exist yesterday fails the job today (a regression), + * a count that rises above what was accepted fails (new debt), + * a count that FALLS is reported, so paying debt down is visible and the + baseline can be tightened in the same PR that fixed it. + +Findings that are simply standing debt never fail the job. Failing on them +would create pressure to suppress them — the failure mode this estate keeps +paying for (see panic-attack's own user-classification registry doctrine). + +Both scanner outputs are read defensively on purpose. `pons` is v0.1 and its +JSON envelope is still settling, so findings are located structurally rather +than by a hard-coded path: any list named `findings` at any depth, or the +`weak_points` array panic-attack emits. If a shape is not recognised the run +says so and does not silently count zero. +""" +from __future__ import annotations + +import argparse +import json +import sys +from collections import Counter +from pathlib import Path + + +def walk_lists(node, wanted: set[str], path=""): + """Yield (json path, list) for every list whose key is in `wanted`.""" + if isinstance(node, dict): + for key, value in node.items(): + here = f"{path}.{key}" if path else key + if key in wanted and isinstance(value, list): + yield here, value + yield from walk_lists(value, wanted, here) + elif isinstance(node, list): + for i, value in enumerate(node): + yield from walk_lists(value, wanted, f"{path}[{i}]") + + +def label(finding: dict) -> str: + for key in ("category", "rule_id", "rule", "id", "code", "check"): + value = finding.get(key) + if isinstance(value, str): + return value + return "unknown" + + +def counts_from(path: Path, wanted: set[str], name: str) -> tuple[Counter, list[str]]: + notes: list[str] = [] + if not path.exists() or path.stat().st_size == 0: + notes.append(f"{name}: no report at {path} — counted as absent, not as zero") + return Counter(), notes + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + notes.append(f"{name}: report is not JSON ({exc})") + return Counter(), notes + + counter: Counter = Counter() + found = False + for where, findings in walk_lists(data, wanted): + found = True + for finding in findings: + if isinstance(finding, dict): + counter[label(finding)] += 1 + notes.append(f"{name}: {len(findings)} finding(s) at {where}") + if not found: + notes.append( + f"{name}: no {'/'.join(sorted(wanted))} array in the report — shape not " + "recognised; counted as absent rather than as clean" + ) + return counter, notes + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--baseline", default="recon/baseline.json") + ap.add_argument("--assail", required=True) + ap.add_argument("--pons", required=True) + ap.add_argument("--summary") + ap.add_argument("--no-fail", action="store_true") + args = ap.parse_args() + + baseline = ( + json.loads(Path(args.baseline).read_text(encoding="utf-8")) + if Path(args.baseline).exists() + else {} + ) + scanners = { + "assail": (Path(args.assail), {"weak_points"}), + "pons": (Path(args.pons), {"findings", "results"}), + } + + lines: list[str] = ["## Recon: two independent scans", ""] + regressions = 0 + observed: dict[str, dict[str, int]] = {} + + for name, (path, wanted) in scanners.items(): + counter, notes = counts_from(path, wanted, name) + observed[name] = dict(sorted(counter.items())) + frozen = baseline.get(name) or {} + lines.append(f"### {name}") + lines.append("") + for note in notes: + lines.append(f"- {note}") + if counter: + lines.append("") + lines.append("| category | observed | baseline | verdict |") + lines.append("|---|---|---|---|") + for category, count in sorted(counter.items()): + allowed = frozen.get(category) + if allowed is None: + verdict = "**NEW (fails)**" if frozen else "new (baseline empty)" + if frozen: + regressions += 1 + elif count > allowed: + verdict = f"**+{count - allowed} (fails)**" + regressions += 1 + elif count < allowed: + verdict = f"−{allowed - count} (debt paid; tighten the baseline)" + else: + verdict = "at baseline" + lines.append(f"| {category} | {count} | {allowed if allowed is not None else '—'} | {verdict} |") + else: + lines.append("- no findings counted") + missing = sorted(set(frozen) - set(counter)) + if missing: + lines.append("") + lines.append(f"- baseline categories no longer reported: {', '.join(missing)}") + lines.append("") + + lines.append("### Freeze or tighten the baseline") + lines.append("") + lines.append("```json") + lines.append(json.dumps(observed, indent=2, sort_keys=True)) + lines.append("```") + + out = "\n".join(lines) + print(out) + if args.summary: + Path(args.summary).write_text( + (Path(args.summary).read_text(encoding="utf-8") if Path(args.summary).exists() else "") + + out + + "\n", + encoding="utf-8", + ) + + if args.no_fail: + print("recon: --no-fail, reporting only", file=sys.stderr) + return 0 + if regressions: + print( + f"::error::{regressions} category regression(s) against {args.baseline}", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/main.rs b/src/main.rs index ddfc027..8ec8575 100644 --- a/src/main.rs +++ b/src/main.rs @@ -519,8 +519,13 @@ enum Commands { /// existing report's findings (backed / corroborated / contradicted). /// Verdicts are explicitly conditioned on the named checker's trust. Aggregate { - /// External prover output file(s) to fold in (repeatable) - #[arg(long = "proof", value_name = "PATH", action = clap::ArgAction::Append, required = true)] + /// External prover output file(s) to fold in (repeatable). + /// + /// At least one `--proof` or `--proven-tests` is required; that is + /// checked in the engine so the two input kinds share one error path + /// (clap cannot express "one of these two" without a group, and a + /// group would have to be declared on the same struct as both). + #[arg(long = "proof", value_name = "PATH", action = clap::ArgAction::Append)] proofs: Vec, /// Friendly-name override(s): PATH=NAME (repeatable) @@ -535,6 +540,27 @@ enum Commands { #[arg(long = "report", value_name = "PATH")] report: Option, + /// `proven-tests` JSON run report(s) to fold in (repeatable). + /// + /// Applies the tier->verdict mapping fixed in that repository's + /// docs/INTEROP-PANIC-ATTACK.adoc: only Actually-Proven ladders name a + /// proof artifact, and only those are read as `Closed`. Provisionally- + /// Proven and Unproven entries are recorded as informational — absence + /// of a proof is not presence of a hole. + #[arg(long = "proven-tests", value_name = "PATH", action = clap::ArgAction::Append)] + proven_tests: Vec, + + /// Directory cited `proof_file` paths resolve against + /// (default: the report's own directory). + #[arg(long = "proven-tests-root", value_name = "DIR")] + proven_tests_root: Option, + + /// Fail when an ingested proven-tests report makes a claim its own + /// contents do not support (empty ladder, missing cited file, + /// unrecognised tier, summary disagreeing with entries). + #[arg(long = "fail-on-defect", default_value_t = false)] + fail_on_defect: bool, + /// Output report to file (JSON; default: reports/aggregate-.json) #[arg(short, long)] output: Option, @@ -3097,6 +3123,9 @@ fn run_main() -> Result<()> { proofs, labels, covers, + proven_tests, + proven_tests_root, + fail_on_defect, report, output, } => { @@ -3126,6 +3155,9 @@ fn run_main() -> Result<()> { let agg = aggregate::run(AggregateConfig { proofs: proof_inputs, base_report: report, + proven_tests, + proven_tests_root, + fail_on_defect, })?; let path = output.unwrap_or_else(default_aggregate_report_path); aggregate::write_report(&agg, &path)?; @@ -3150,6 +3182,21 @@ fn run_main() -> Result<()> { ); } } + for record in &agg.proven_tests { + println!( + " proven-tests {} [{}]: {} passed / {} total, {} Actually-Proven, {} covered cells of {}", + record.path.display(), + record.suite, + record.summary.passed, + record.summary.total, + record.summary.actually_proven, + record.summary.covered_cells, + record.summary.cat_aspect_cells + ); + for defect in &record.defects { + println!(" DEFECT: {defect}"); + } + } println!(" TRUST: {}", agg.trust_disclaimer); } qprintln!(cli.quiet, "Aggregate report saved to: {}", path.display()); From cc2d040f59db3be92ba649620c2a306e07e64b06 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Sun, 25 Oct 2026 19:30:55 +0000 Subject: [PATCH 11/24] ci: annotate failures so they are readable off the log host, and fix rustdoc links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The log endpoint is a different host from the API and is not always reachable — by a restricted network, an offline contributor, or any automation reading the checks API. A failed step whose only explanation is in a log download is therefore half a gate. scripts/ci/annotate.sh runs a command, annotates up to 20 failing lines through the API (), and re-raises the exit status. verify.yml uses it for format, clippy, both test matrices, MSRV and rustdoc. Also fixes two doc comments that linked to struct FIELDS (AggregateConfig::fail_on_defect, ProvenTestsRecord::defects): rustdoc cannot resolve a field path, so failed on an otherwise correct comment. They are code spans now. Adds the CI/CD documentation and the local mirrors of the new gates (ci, integrity, pins, proven, recon, squabble) — a gate you can only run by pushing is a gate you debug by pushing. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/recon.yml | 2 +- .github/workflows/verify.yml | 17 ++-- Justfile | 86 ++++++++++++++++ docs/CI-CD.adoc | 182 ++++++++++++++++++++++++++++++++++ scripts/ci/annotate.sh | 45 +++++++++ src/aggregate/mod.rs | 2 +- src/aggregate/proven_tests.rs | 18 ++-- 7 files changed, 337 insertions(+), 15 deletions(-) create mode 100644 docs/CI-CD.adoc create mode 100644 scripts/ci/annotate.sh diff --git a/.github/workflows/recon.yml b/.github/workflows/recon.yml index 8e26374..55febed 100644 --- a/.github/workflows/recon.yml +++ b/.github/workflows/recon.yml @@ -88,7 +88,7 @@ jobs: mkdir -p "$SARIF_DIR" ./target/release/panic-attack assail . \ --output "$SARIF_DIR/assail.json" --quiet - ./target/release/panic-attack assail . --format sarif \ + ./target/release/panic-attack --output-format sarif assail . \ --output "$SARIF_DIR/assail.sarif" --quiet || true - name: Checkout pons-asinorum at the pinned commit diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 1c5131e..d418d86 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -59,7 +59,8 @@ jobs: with: toolchain: stable components: rustfmt - - run: cargo fmt --all -- --check + - name: Format + run: bash scripts/ci/annotate.sh rustfmt -- cargo fmt --all -- --check clippy: name: cargo clippy -D warnings @@ -76,7 +77,8 @@ jobs: - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 # `gui` is deliberately excluded: eframe/egui needs system libraries this # job does not install, and the GUI is opt-in. Everything else is checked. - - run: cargo clippy --locked --all-targets --features http,sarif,signing -- -D warnings + - name: Clippy + run: bash scripts/ci/annotate.sh clippy -- cargo clippy --locked --all-targets --features http,sarif,signing -- -D warnings test: name: cargo test (${{ matrix.features }}) @@ -105,9 +107,10 @@ jobs: run: | set -euo pipefail if [ -n "${{ matrix.features }}" ]; then - cargo test --locked --features "${{ matrix.features }}" + bash scripts/ci/annotate.sh "tests[${{ matrix.features }}]" -- \ + cargo test --locked --features "${{ matrix.features }}" else - cargo test --locked + bash scripts/ci/annotate.sh "tests[default]" -- cargo test --locked fi msrv: @@ -132,7 +135,8 @@ jobs: - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: msrv - - run: cargo check --locked --all-targets + - name: MSRV check + run: bash scripts/ci/annotate.sh msrv -- cargo check --locked --all-targets docs: name: cargo doc (no broken intra-doc links) @@ -150,7 +154,8 @@ jobs: - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: docs - - run: cargo doc --locked --no-deps --features http,sarif,signing + - name: Docs + run: bash scripts/ci/annotate.sh rustdoc -- cargo doc --locked --no-deps --features http,sarif,signing coverage-ledger: name: Coverage ledger is honest (--fail-on-incomplete) diff --git a/Justfile b/Justfile index 636495a..9975dec 100644 --- a/Justfile +++ b/Justfile @@ -268,3 +268,89 @@ crg-badge: D) color="orange" ;; E) color="red" ;; F) color="critical" ;; \ *) color="lightgrey" ;; esac; \ echo "[![CRG $$grade](https://img.shields.io/badge/CRG-$$grade-$$color?style=flat-square)](https://github.com/hyperpolymath/standards/tree/main/component-readiness-grades)" + +# ── CI/CD foundation ───────────────────────────────────────────────────────── +# These mirror the workflows in .github/workflows/ so a contributor can run the +# same gates locally. A gate you can only run in CI is a gate you debug by +# pushing. + +# Everything that must pass before a PR: format, lint, test, docs, integrity. +ci: fmt-check lint test-local integrity + +# cargo fmt in check mode (never rewrites a contributor's tree) +fmt-check: + cargo fmt --all -- --check + +test-local: + cargo test --locked + +# CI integrity: SHA pins, lockfile coverage, allowlist coverage, YAML parse. +# Needs `yq` (mikefarah) and `python3`; see scripts/ci/. +integrity: + #!/usr/bin/env bash + set -euo pipefail + command -v yq >/dev/null || { echo "integrity: yq not found (mikefarah/yq)"; exit 2; } + bash .githooks/validate-sha-pins.sh + python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json --check + bash .githooks/validate-actions-lock.sh + bash scripts/ci/check-allowed-actions.sh .github/actions-allowlist.json .github/workflows + for f in .github/workflows/*.yml; do + case "$f" in *.lock) continue ;; esac + [ "$(yq -r '.jobs | tag' "$f")" = '!!map' ] || { echo "integrity: $f has no jobs map"; exit 1; } + done + echo "integrity: OK" + +# After changing any `uses:`: re-resolve tag -> commit and rewrite the lockfile. +pins: + bash scripts/ci/resolve-action-shas.sh + python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json + +# ── Evidence-graded testing (proven-tests-and-benches) ─────────────────────── + +# Derive a run report from tests that actually exist, then fold it through +# aggregate. Fails on a cell with no test, a test with no cell, or a claim the +# report does not support. +proven: + #!/usr/bin/env bash + set -euo pipefail + cargo build --locked --release + bash scripts/proven/inventory.sh /tmp/panic-attack-tests.json + python3 scripts/proven/verify-ledger.py \ + --inventory /tmp/panic-attack-tests.json \ + --ledger proven/ledger.json \ + --root . \ + --out /tmp/proven-report.json + ./target/release/panic-attack aggregate \ + --proven-tests /tmp/proven-report.json \ + --proven-tests-root . \ + --fail-on-defect \ + --output /tmp/proven-aggregate.json + +# ── Recon ──────────────────────────────────────────────────────────────────── + +# Scan this repository with its own analyser and compare with the debt baseline. +recon: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p recon-out + cargo run --release --quiet -- assail . --output recon-out/assail.json --quiet + python3 scripts/recon/compare.py \ + --baseline recon/baseline.json \ + --assail recon-out/assail.json \ + --pons /dev/null \ + --no-fail + +# ── Squabbling ─────────────────────────────────────────────────────────────── + +# Diagnose a stuck gate for PR with the estate's CI fighter. +# Build it once: cargo install --git https://github.com/hyperpolymath/cicd-squabbler \ +# --rev 14f853fde902ab2d7631070d87a9dc9122e01013 squabble-cli +squabble pr: + #!/usr/bin/env bash + set -euo pipefail + squabble fetch hyperpolymath/panic-attack {{pr}} > /tmp/gate.json + squabble diagnose /tmp/gate.json + +# Cross-repo CI chains for this repository (read-only; exit 4 = blocking finding). +squabble-chains: + squabble chains . gh:hyperpolymath/standards --follow 1 diff --git a/docs/CI-CD.adoc b/docs/CI-CD.adoc new file mode 100644 index 0000000..e041ac8 --- /dev/null +++ b/docs/CI-CD.adoc @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += CI/CD for panic-attack +:toc: left +:toclevels: 3 +:sectnums: + +== The failure this replaces + +On 2026-10-25 this repository's CI went dark for four hours. Twelve +`pull_request` workflows concluded `startup_failure`: **zero jobs, no log, no +annotation, no check run**. Nothing in the GitHub UI said why, and the only +diagnosis available was to read the estate's settings canon and compare it with +our own workflow files. + +Two causes, both policy-level, neither visible from the code: + +`uses:` a reusable workflow from `metadatastician/standards`:: +The canonical allowlist - +`rhodium-standard-repositories/actions-allowlist/allowed-actions.json` in +`hyperpolymath/standards` - permits `hyperpolymath/*`. It does not permit +`metadatastician/*`. `hyperpolymath/standards` and `metadatastician/standards` +are the *same repository* (a rename; the lockfile records one `repo_id`), so the +correct spelling is the one the allowlist covers. Six thin wrappers had been +re-pointed at the new owner, and every one of them died before its first job. + +tag-pinned `uses:`, e.g. `actions/checkout@v7.0.1`:: +The estate posture is `sha_pinning_required = true`. Under it, a tag-pinned +`uses:` is refused **at startup** - the same opaque death. The pin is a security +control, so the fix is to pin properly, not to turn the control off. + +The lesson generalises and is the reason this document exists: *a run that never +starts is invisible to every gate that watches runs*. It produces no check run, +so a required context silently stops arriving and the branch looks +**healthier** than a broken one. Both causes are now gated before they can bite +(see <>). + +== The shape of the pipeline + +[cols="2,3,2",options="header"] +|=== +| Workflow | What it answers | When + +| `verify.yml` +| Does it build, lint, test, document and self-scan honestly? The floor that + cannot be taken out by another repository. +| every PR, every push + +| `ci-integrity.yml` +| Can the CI itself start? SHA pins, lockfile coverage, allowlist coverage, + YAML parseability. +| every PR, every push, Mondays + +| `proven-tests.yml` +| Is the suite's claimed coverage derived from tests that ran, and are its tiers + honest? Plus the framework's own Idris2 suite. +| ledger: every PR. framework: Mondays/dispatch + +| `squabble-gate.yml` +| Is any required context missing, and is any CI upstream dead? Runs + `cicd-squabbler`. +| Wednesdays, dispatch, PRs labelled `ci-squabble` + +| `recon.yml` +| What do two independent analysers say about this repository, and has any + category regressed? +| Tuesdays, dispatch + +| `rust-ci.yml`, `governance.yml`, `hypatia-scan.yml`, `secret-scanner.yml`, + `scorecard.yml`, `mirror.yml` +| The estate's richer reusable gates (audit, coverage upload, Hypatia SARIF, + governance, secret scanning). +| per estate canon + +| `e2e.yml`, `codecov`, `cargo-audit`, `chapel-ci`, `dogfood-gate`, + `bridge-gate`, `pages`, `release` +| Behavioural, supply-chain, contract and release checks. +| per file header +|=== + +=== Why `verify.yml` exists at all + +Every estate gate except `verify` depends on `hyperpolymath/standards` being +reachable *and* permitted at that instant. That is the right long-term shape - +one place to configure, propagate everywhere - but it means a settings change in +another repository can remove this repository's entire build signal. `verify` +depends on nothing but actions this repository pins itself, so the "does it +compile and pass" answer survives any policy change elsewhere. + +<> === CI integrity + +`ci-integrity.yml` runs four gates, each of which corresponds to a way a +workflow dies before its first job: + +SHA pins:: `.githooks/validate-sha-pins.sh` (canon). Every third-party `uses:` +names a 40-hex commit plus a `# ` comment. +Lockfile coverage:: `.githooks/validate-actions-lock.sh` (canon), plus +`scripts/ci/pin-workflow-uses.py --check`, which derives the lockfile from the +workflows. A caller pinned to a commit whose lockfile has drifted dies with +`failure`, 0 jobs and *no reason in the API* - which is why this is a gate and +not a courtesy. +Allowlist coverage:: `scripts/ci/check-allowed-actions.sh` (canon) against the +vendored `.github/actions-allowlist.json`. No API, no admin: it lists exactly +which `uses:` the estate policy does not cover. +YAML parseability:: every workflow parses *and* declares a `jobs:` map. A file +whose quoting swallowed the document parses to a scalar and never runs. + +Maintenance: after changing any `uses:`, run + +[source,sh] +---- +bash scripts/ci/resolve-action-shas.sh # network: resolves tag -> commit +python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json +---- + +`action-pins.json` is committed on purpose: it records which tag a pin stood for +on the day it was taken, so a reviewer can tell an intentional bump from a +silent re-point. + +== Testing: evidence-graded, not declared + +`proven/ledger.json` grades this repository's suite on +`proven-tests-and-benches`' 17-category x 14-aspect lattice. The rule the +framework exists to enforce is that **coverage is derived from executed tests, +never declared**, so: + +* `scripts/proven/inventory.sh` asks the test runner what tests exist, per + binary; +* `scripts/proven/verify-ledger.py` fails when a cell selects no executed test, + when an executed test is claimed by nothing, or when a stronger tier is + claimed than the evidence supports; +* the resulting report is fed to `panic-attack aggregate --proven-tests + --fail-on-defect`, which re-checks the report's internal consistency and fails + on an unsupported claim (an Actually-Proven entry with no ladder, a cited proof + file that is not there, a summary that disagrees with the entries). + +Tiers are used honestly: this repository has no proof-carrying Rust tests, so +almost everything is `Provisionally-Proven` and says so. Five cells are declared +**absent** with the command that would test them. A declared absence is a to-do +list, not a silencer - `MutationTest` is absent because no mutation campaign +runs here, which is the same `PA025 MutationGap` this tool reports about other +repositories. + +== Recon: two analysers, one baseline + +`recon.yml` scans this repository with `panic-attack assail` and with +`pons-asinorum` (pinned). They answer different questions - "what here is weak?" +versus "what here is wasted, self-contradictory, or missing an escape hatch?" - +and a finding that appears in both is corroborated evidence while a finding in +exactly one is a prompt to look. + +Findings do **not** fail the job. This is the scanner: a scan of its own source +will report what it is built to report, and failing on that creates pressure to +suppress. Instead `recon/baseline.json` freezes the standing counts; a *new* +category or a count *above* baseline fails; a count *below* is reported as debt +paid and should be tightened in the same PR. See +`docs/RECON-AND-EVIDENCE.adoc`. + +== Squabbling: fixing stuck gates without bypassing them + +`cicd-squabbler` (pinned to a commit) fetches this PR's live gate and reports +which required contexts are missing or stuck, and maps the cross-repo CI chains +this repository depends on. Its invariant is machine-checked: *green if and only +if a required check actually ran and passed*, so an admin override or a dropped +required context are provably not paths to green. It cannot fix code, and does +not try. + +Exit codes are part of the contract and are never swallowed: +`0` ok, `2` tool failure (**fails the job**), `3` no required-status-check rule +on the base branch (reported), `4` a blocking chain finding - a cycle or a dead +upstream (reported; fails when `strict` is requested), `5` agent items remain. + +== What is deliberately not here + +* **No `paths:` filter on a check intended to be required.** A required check + whose workflow filters itself off does not report as skipped - it does not + report at all, and the PR waits forever on a context that cannot arrive. +* **No `continue-on-error` on a gate that can disagree with nobody.** An + advisory job that is always green carries no information; where a check is + genuinely advisory it says so in its name and its summary. +* **No suppression of a finding to make a gate green.** Where debt exists it is + frozen in a baseline, in a reviewable file, with the reason. diff --git a/scripts/ci/annotate.sh b/scripts/ci/annotate.sh new file mode 100644 index 0000000..dacaece --- /dev/null +++ b/scripts/ci/annotate.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# annotate.sh — run a command and turn its failures into GitHub ANNOTATIONS. +# +# WHY: a failed step whose only output is in a log download is a failure half the +# world cannot read — offline contributors, anyone with a restricted network, and +# any automation that reads the checks API (the log endpoint is a different host +# from the API and is frequently unavailable). Annotations are served by the API, +# so they are the durable record of *why* a job failed. +# +# Usage: scripts/ci/annotate.sh