diff --git a/.machine_readable/bot_directives/gate_triage.a2ml b/.machine_readable/bot_directives/gate_triage.a2ml index ff0c52b..be10afa 100644 --- a/.machine_readable/bot_directives/gate_triage.a2ml +++ b/.machine_readable/bot_directives/gate_triage.a2ml @@ -9,8 +9,8 @@ # Origin: owner ruling 2026-09-03, given on the vacuous-Hypatia-gate options menu. [metadata] -version = "0.2.0" -last-updated = "2026-09-04" +version = "0.3.0" +last-updated = "2026-09-09" spec = "https://github.com/hyperpolymath/standards/blob/main/agentic-a2ml/docs/ADR-002-methodology-layer.adoc" status = "active" # wired into `squabble fight`; see crates/squabble-core/src/polarity.rs @@ -93,7 +93,7 @@ emits-evidence = true # a verdict without evidence is not a verd # Purpose: do not waste regular users' time running maintainer-only checks. [gate-triage.applicability] -manifest-file = "0.1-AI-MANIFEST.a2ml" +manifest-file = "0-AI-MANIFEST.a2ml" # corrected 2026-09-09: the 0.1- name never existed in this repo operator-type-key = "@gitforge_OperatorType" channel-key = "@channel" operator-types = ["user", "developer", "platform_maintainer"] @@ -170,41 +170,44 @@ note = "This is a contradiction with the environment declaration, NOT a judgemen [gate-triage.detection] source = "actions jobs API step conclusions" - -# GROUND TRUTH, not a paraphrase. Census of 33 local `static-analysis-gate.yml` -# copies, 2026-09-04: both names below appear 33/33 with ZERO variants. The -# matcher in `squabble-core::polarity::step_concluded` is an EXACT compare, so -# the abbreviated form this file carried until now ("Create stub findings") -# matched no real job on earth — every green check would have classified -# `Genuine`, forever, and the vacuity classifier would have been its own fake -# green. Do not shorten these for readability. -signature-skipped-steps = ["Run Hypatia scan"] -signature-success-steps = ["Create stub findings (when Hypatia unavailable)"] +# GROUND TRUTH, not a paraphrase. The step names live in the +# `[[gate-triage.detection.signatures]]` tables at the END of this section +# (an array-of-tables terminates its parent, so nothing may follow them). +# +# The matcher in `squabble-core::polarity::step_concluded` is an EXACT compare, +# so the abbreviated form this file carried before 0.2.0 ("Create stub +# findings") matched no real job on earth — every green check would have +# classified `Genuine`, forever, and the vacuity classifier would have been its +# own fake green. Do not shorten these for readability, and do not "tidy" the +# parentheticals: they are part of the step name. +# +# The flat `signature-skipped-steps` / `signature-success-steps` keys are GONE +# as of 0.3.0. Leaving them beside the tables would have parsed hypatia twice. check-conclusion-when-vacuous = "success" polarity = "green" # NOT red — this is the gap in `fight` evidence-required = ["run-count", "stub-rate", "upstream-exists", "target-tech-present"] - -# Coverage, stated honestly. The same workflow carries two more stub paths of -# identical shape, found by the same census: -# "Run panic-attack assail" / "Create stub findings (when panic-attack unavailable)" 33/33 -# "Run Patch Bridge triage" / "Create stub report (when unavailable)" 27/33 -# Neither is covered here. The matcher quantifies with `all`, so one signature -# describes one scanner; and each scanner is a separate JOB, hence a separate -# check, so an unmatched scanner is NOT escalated on the signature. That is an -# UNDERCOUNT, never a false alarm — with one stated exception: a job that +# Coverage, stated honestly. All three of the workflow's stub paths are now +# described, one table each, at the end of this section. The matcher quantifies +# with `all` WITHIN a signature, so one signature still describes exactly one +# scanner; the host quantifies with `any` ACROSS them. Each scanner is a +# separate JOB hence a separate check, so the check name identifies which one +# went vacuous and the cause does not need to name it. +# +# The disjunction cannot leak. `step_concluded` is false for an absent step, so +# the hypatia signature cannot match the panic-attack job — it never recorded +# hypatia's steps. Asserted in `polarity.rs`, not assumed. +# +# One exception to "signature or nothing" stands unchanged: a job that # concluded success having recorded steps, every one of them SKIPPED, is -# reported `all-steps-skipped` whether or not any signature matched. That is -# not a false alarm either; a job that ran nothing enforced nothing, and the -# finding stands on its own terms without naming a scanner. +# reported `all-steps-skipped` whether or not any signature matched. A job that +# ran nothing enforced nothing, and that finding stands without naming a +# scanner. # # A green whose job records NO steps at all is the opposite case: absence of # evidence, not evidence of absence. It is reported to the operator on stderr # and classified `Genuine`, never escalated. -# -# A second signature needs a repeatable table in this file plus a parser -# change, so it is filed rather than bodged in. -signature-covers = ["hypatia"] -signature-not-yet-covered = ["panic-attack", "patch-bridge"] +signature-covers = ["hypatia", "panic-attack", "patch-bridge"] +signature-not-yet-covered = [] # Measurability, stated honestly (Doctrine #10, no overclaim). # `run-count` and `stub-rate` are NOT a measured history. `squabble fight` @@ -219,8 +222,56 @@ evidence-measurable-by-host = ["run-count", "stub-rate"] evidence-unmeasured-by-host = ["upstream-exists", "target-tech-present"] evidence-single-run-only = ["run-count", "stub-rate"] -# Applicability (axis 0) is CORE-ONLY. `classify()` takes an `Applicability` and -# a `RepoDeclaration`, but `squabble fight` passes the defaults, so the owner's -# operator-type / channel axis cannot fire in production yet. Declared here so -# the gap is visible instead of being implied to work. -applicability-wired-into-host = false +# Applicability (axis 0) is now READ BY THE HOST: `squabble fight` loads the +# gate predicate from `[gate-triage.applicability]` below and the repo's own +# declaration from the manifest, instead of passing `Default::default()`. +# +# What that does NOT mean. The plumbing exists; the DATA does not. Measured +# 2026-09-09: this repo's manifest carries no `@gitforge_OperatorType` and no +# `@channel` key — it carries no `@` keys at all — and no gate in the estate +# declares `runs-on-channels` or `runs-for-operator-types`. An undeclared +# predicate short-circuits in `applicability_verdict`, so axis 0 still cannot +# fire on any repo today, and wiring it changed no verdict. It fires the day +# someone writes a declaration, which was previously impossible. +applicability-read-by-host = true +applicability-can-fire-today = false # no repo declares the keys; measured 2026-09-09 + +# ============================================================================ +# THE SIGNATURES — one table per scanner. MUST BE LAST IN THIS SECTION. +# ============================================================================ +# An array-of-tables terminates its parent table, so any key written after +# these would silently belong to the last signature instead of to +# `[gate-triage.detection]`. Append new scanners here; add nothing after them. +# +# GROUND TRUTH: every string below was read out of this repo's own +# `.github/workflows/static-analysis-gate.yml` on 2026-09-09 (job blocks +# `panic-attack-assail`, `hypatia-scan`, `patch-bridge-triage`), and the test +# `the_repos_own_directive_names_real_workflow_steps` re-reads that same file +# on every run. It is not a transcription of a ruling — that is precisely the +# mistake PR #60 nearly shipped. +# +# `census` records how many of the 33 local `static-analysis-gate.yml` copies +# carried the pair on 2026-09-04. It is documentation of reach, not a matcher +# input: patch-bridge's 27/33 means six copies predate that job, so those six +# are an UNDERCOUNT, never a mismatch. + +[[gate-triage.detection.signatures]] +scanner = "hypatia" +skipped-steps = ["Run Hypatia scan"] +success-steps = ["Create stub findings (when Hypatia unavailable)"] +job = "hypatia-scan" +census = "33/33" + +[[gate-triage.detection.signatures]] +scanner = "panic-attack" +skipped-steps = ["Run panic-attack assail"] +success-steps = ["Create stub findings (when panic-attack unavailable)"] +job = "panic-attack-assail" +census = "33/33" + +[[gate-triage.detection.signatures]] +scanner = "patch-bridge" +skipped-steps = ["Run Patch Bridge triage"] +success-steps = ["Create stub report (when unavailable)"] +job = "patch-bridge-triage" +census = "27/33" diff --git a/crates/squabble-cli/src/fetch.rs b/crates/squabble-cli/src/fetch.rs index 3efb9ed..184fa6b 100644 --- a/crates/squabble-cli/src/fetch.rs +++ b/crates/squabble-cli/src/fetch.rs @@ -468,7 +468,7 @@ mod polarity_plumbing_tests { .parent() .and_then(|p| p.parent()) .expect("workspace root"); - let sig = squabble_fight::gate_triage::load_signature(root); + let sig = squabble_fight::gate_triage::load_signatures(root); let verdict = squabble_core::polarity::classify( &steps, &sig, @@ -506,7 +506,7 @@ mod polarity_plumbing_tests { .parent() .and_then(|p| p.parent()) .expect("workspace root"); - let sig = squabble_fight::gate_triage::load_signature(root); + let sig = squabble_fight::gate_triage::load_signatures(root); let verdict = squabble_core::polarity::classify( &steps, &sig, diff --git a/crates/squabble-cli/src/fight.rs b/crates/squabble-cli/src/fight.rs index 7fb4545..046186d 100644 --- a/crates/squabble-cli/src/fight.rs +++ b/crates/squabble-cli/src/fight.rs @@ -13,7 +13,7 @@ use crate::fetch; use squabble_core::gate::Gate; use squabble_core::moves::Move; use squabble_core::outcome::Escalation; -use squabble_core::polarity::{Applicability, Evidence, RepoDeclaration}; +use squabble_core::polarity::Evidence; use squabble_core::outcome::Outcome; use squabble_fight::context::RepoContext; use std::path::PathBuf; @@ -117,16 +117,25 @@ pub fn run(rest: &[String]) -> ExitCode { /// `fight` only ever classifies reds, so a check that could not run reports /// green and is never inspected. This is the missing polarity. fn classify_greens(args: &FightArgs, greens: &[fetch::GreenCheck]) -> Vec { - let signature = squabble_fight::gate_triage::load_signature(&args.repo_root); + let signature = squabble_fight::gate_triage::load_signatures(&args.repo_root); if !signature.is_usable() { // Fail-safe: no directive means "detect no vacuity", never "detect it // everywhere". It also costs zero API calls on repos without one. return Vec::new(); } - // No gate declares an applicability predicate today, so Axis 0 falls - // through to the signature. Stated explicitly rather than assumed. - let applicability = Applicability::default(); - let declared = RepoDeclaration::default(); + // Axis 0, read from the directive rather than defaulted. Both loads are + // fail-safe: an absent predicate short-circuits in `applicability_verdict` + // ("applies here"), and an absent manifest key stays `None` rather than + // becoming a mismatch. + // + // Honest about reach: measured 2026-09-09, no repo declares + // `runs-on-channels` / `runs-for-operator-types` and no manifest carries + // `@gitforge_OperatorType` / `@channel`, so this reads empty everywhere + // today and changes no verdict. The point is that a declaration written + // tomorrow now takes effect, which passing `Default::default()` made + // impossible. + let applicability = squabble_fight::gate_triage::load_applicability(&args.repo_root); + let declared = squabble_fight::gate_triage::load_declaration(&args.repo_root); let mut moves = Vec::new(); for g in greens { diff --git a/crates/squabble-core/src/polarity.rs b/crates/squabble-core/src/polarity.rs index a09dd1c..0eef3f0 100644 --- a/crates/squabble-core/src/polarity.rs +++ b/crates/squabble-core/src/polarity.rs @@ -137,6 +137,88 @@ impl VacuitySignature { } } +/// One scanner's signature, plus the scanner it names. +/// +/// The name is carried for the *directive*'s sake — so a table entry is +/// self-describing and a census line can be attached to it — not for the +/// verdict. Each scanner is a separate JOB hence a separate check, so the +/// check name already identifies which scanner went vacuous; putting the +/// name in the cause would duplicate that and force `VacuityCause` to stop +/// being `Copy`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScannerSignature { + /// The scanner this entry describes, e.g. `"hypatia"`. Documentation and + /// diagnostics only — never matched against. + pub scanner: String, + /// The step-name conjunction for that scanner. + pub signature: VacuitySignature, +} + +/// Every scanner signature the host knows about. +/// +/// # Why a set, and why the two quantifiers differ +/// +/// [`VacuitySignature::matches`] is a CONJUNCTION: all of *this* scanner's +/// steps must line up, because a partial match is a legitimately optional +/// step rather than vacuity. That is exactly why one signature can only ever +/// describe one scanner — and the `static-analysis-gate.yml` workflow carries +/// three stub paths, in three separate jobs. +/// +/// So the set quantifies with `any`: a job is vacuous if it matches ANY +/// scanner's signature, having matched that scanner's steps in full. +/// +/// The disjunction cannot leak across scanners. [`step_concluded`] returns +/// `false` for a step that is absent from the list, so signature A cannot +/// match a job that never recorded A's steps — the panic-attack job cannot be +/// judged by the hypatia signature. That is asserted in the tests, not +/// assumed. +/// +/// An empty set is *unusable*, preserving the fail-safe: no directive means +/// "detect no vacuity", never "detect vacuity everywhere". +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SignatureSet { + pub signatures: Vec, +} + +impl SignatureSet { + /// Build a set from `(scanner, signature)` pairs. + pub fn new(entries: Vec) -> Self { + SignatureSet { signatures: entries } + } + + /// A one-scanner set — the shape the host had before multi-scanner + /// support, kept so a single signature is still expressible directly. + pub fn single(scanner: &str, signature: VacuitySignature) -> Self { + SignatureSet { + signatures: vec![ScannerSignature { + scanner: scanner.to_string(), + signature, + }], + } + } + + /// Usable when **at least one** member is usable. + /// + /// Not `all`: a directive that describes three scanners and fumbles one + /// should still detect the other two. An unusable member matches nothing + /// (that guard lives in [`VacuitySignature::matches`]), so carrying it is + /// inert rather than dangerous. + pub fn is_usable(&self) -> bool { + self.signatures.iter().any(|s| s.signature.is_usable()) + } + + /// Does any member match `steps`? + pub fn matches(&self, steps: &[StepOutcome]) -> bool { + self.matching(steps).is_some() + } + + /// Which member matched, if any — the same question as [`Self::matches`], + /// but retaining the scanner name for diagnostics. + pub fn matching(&self, steps: &[StepOutcome]) -> Option<&ScannerSignature> { + self.signatures.iter().find(|s| s.signature.matches(steps)) + } +} + /// What a gate declares about *where it applies* — the directive's /// `@gitforge_OperatorType` / `@channel` axis. /// @@ -384,7 +466,7 @@ fn applicability_verdict( /// module has no opinion about them. pub fn classify( steps: &[StepOutcome], - signature: &VacuitySignature, + signature: &SignatureSet, applicability: &Applicability, declared: &RepoDeclaration, evidence: Evidence, @@ -437,6 +519,13 @@ mod tests { VacuitySignature::new(&["Run Hypatia scan"], &["Create stub findings"]) } + /// The same signature as a one-scanner set — the shape `classify` now + /// takes. Kept separate from `sig()` so the tests that exercise the + /// CONJUNCTION still speak to a single `VacuitySignature` directly. + fn sigset() -> SignatureSet { + SignatureSet::single("hypatia", sig()) + } + fn ev(tech: bool, upstream: bool, rate: f64) -> Evidence { Evidence { run_count: 4, @@ -449,7 +538,7 @@ mod tests { fn classify_steps(steps: &[StepOutcome]) -> PolarityVerdict { classify( steps, - &sig(), + &sigset(), &Applicability::default(), &RepoDeclaration::default(), ev(true, true, 1.0), @@ -505,7 +594,7 @@ mod tests { // green into a finding. `is_usable` is what stops that. let v = classify( &[StepOutcome::new("Build", StepConclusion::Success)], - &VacuitySignature::default(), + &SignatureSet::default(), &Applicability::default(), &RepoDeclaration::default(), ev(true, true, 0.0), @@ -593,7 +682,7 @@ mod tests { fn declared_and_unmatched_is_not_applicable() { let v = classify( &[], // uninspectable; Axis 0 answers before steps are consulted - &sig(), + &sigset(), &Applicability { runs_for_operator_types: vec!["platform_maintainer".into()], runs_on_channels: vec![], @@ -614,7 +703,7 @@ mod tests { fn an_unmatched_channel_is_also_not_applicable() { let v = classify( &[], - &sig(), + &sigset(), &Applicability { runs_for_operator_types: vec![], runs_on_channels: vec!["nightly".into(), "alpha".into()], @@ -641,7 +730,7 @@ mod tests { StepOutcome::new("Run Hypatia scan", StepConclusion::Skipped), StepOutcome::new("Create stub findings", StepConclusion::Success), ], - &sig(), + &sigset(), &Applicability::default(), &RepoDeclaration { operator_type: Some("user".into()), @@ -662,7 +751,7 @@ mod tests { StepOutcome::new("Run Hypatia scan", StepConclusion::Skipped), StepOutcome::new("Create stub findings", StepConclusion::Success), ], - &sig(), + &sigset(), &Applicability { runs_for_operator_types: vec!["developer".into()], runs_on_channels: vec!["alpha".into()], @@ -682,7 +771,7 @@ mod tests { // that can only escalate, never green. let v = classify( &[StepOutcome::new("Build", StepConclusion::Success)], - &sig(), + &sigset(), &Applicability { runs_on_channels: vec!["nightly".into()], runs_for_operator_types: vec![], @@ -825,4 +914,42 @@ mod tests { "polarity classification must never move the proved gate state" ); } + + // ---- the set quantifies with ANY, the member with ALL ------------------- + + #[test] + fn an_unusable_member_cannot_drag_the_set_down() { + // A directive that describes three scanners and fumbles one must still + // detect the other two. The fumbled member matches nothing anyway. + let set = SignatureSet::new(vec![ + ScannerSignature { + scanner: "broken".to_string(), + signature: VacuitySignature::new(&["Run something"], &[]), + }, + ScannerSignature { + scanner: "hypatia".to_string(), + signature: sig(), + }, + ]); + assert!(set.is_usable()); + assert_eq!( + set.matching(&[ + StepOutcome { name: "Run Hypatia scan".into(), conclusion: StepConclusion::Skipped }, + StepOutcome { name: "Create stub findings".into(), conclusion: StepConclusion::Success }, + ]) + .map(|s| s.scanner.as_str()), + Some("hypatia") + ); + } + + #[test] + fn an_empty_set_matches_nothing() { + // Fail-safe, at the type that `classify` now takes. + let set = SignatureSet::default(); + assert!(!set.is_usable()); + assert!(!set.matches(&[StepOutcome { + name: "Run Hypatia scan".into(), + conclusion: StepConclusion::Skipped + }])); + } } diff --git a/crates/squabble-fight/src/gate_triage.rs b/crates/squabble-fight/src/gate_triage.rs index 491f6cb..6675f5b 100644 --- a/crates/squabble-fight/src/gate_triage.rs +++ b/crates/squabble-fight/src/gate_triage.rs @@ -5,15 +5,22 @@ //! //! [`squabble_core::polarity`] is deliberately scanner-agnostic: it holds no //! step names. They live in `.machine_readable/bot_directives/gate_triage.a2ml` -//! under `signature-skipped-steps` / `signature-success-steps`, and this module -//! is what turns that file into a [`VacuitySignature`]. +//! in `[[gate-triage.detection.signatures]]` tables, one per scanner, and this +//! module is what turns that file into a [`SignatureSet`]. +//! +//! The same file supplies axis 0: the gate's applicability predicate from +//! `[gate-triage.applicability]`, and the repo's own declaration from the +//! manifest that section names. //! //! Fail-safe like [`crate::context`]: an absent or unrecognised directive -//! yields an *unusable* signature, and an unusable signature matches nothing. +//! yields an *unusable* signature set, and an unusable set matches nothing. //! A missing directive therefore means "detect no vacuity", never "detect -//! vacuity everywhere". +//! vacuity everywhere". An absent applicability predicate likewise means +//! "applies here", never "applies nowhere". -use squabble_core::polarity::VacuitySignature; +use squabble_core::polarity::{ + Applicability, RepoDeclaration, ScannerSignature, SignatureSet, VacuitySignature, +}; use std::path::Path; use crate::context::extract_array; @@ -21,94 +28,434 @@ use crate::context::extract_array; /// Where the directive lives, relative to a repo checkout. pub const DIRECTIVE_PATH: &str = ".machine_readable/bot_directives/gate_triage.a2ml"; -/// Load the vacuity signature from a repo checkout. Never fails. -pub fn load_signature(repo_root: &Path) -> VacuitySignature { - let raw = std::fs::read_to_string(repo_root.join(DIRECTIVE_PATH)).unwrap_or_default(); - parse_signature(&raw) +/// The array-of-tables header that introduces one scanner's signature. +pub const SIGNATURE_TABLE: &str = "[[gate-triage.detection.signatures]]"; + +/// Manifest consulted for the repo's own declaration when the directive names +/// none. Ground-truthed against this repo on 2026-09-09; the directive used to +/// name a `0.1-` file that has never existed here. +pub const DEFAULT_MANIFEST: &str = "0-AI-MANIFEST.a2ml"; + +/// Load every scanner signature from a repo checkout. Never fails. +pub fn load_signatures(repo_root: &Path) -> SignatureSet { + parse_signatures(&read(repo_root, DIRECTIVE_PATH)) +} + +/// Pure half — the unit of test coverage. [`load_signatures`] only supplies +/// the file's text. +/// +/// One [`SIGNATURE_TABLE`] header opens one scanner. A chunk ends at the next +/// header of any kind, so a following `[section]` cannot donate its arrays to +/// the last table. +/// +/// A signature that is not *usable* (either half empty) is dropped rather than +/// carried: it would match nothing anyway, and dropping it keeps +/// `is_usable()` on the set honest about how many scanners are really covered. +pub fn parse_signatures(raw: &str) -> SignatureSet { + let mut out = Vec::new(); + for chunk in raw.split(SIGNATURE_TABLE).skip(1) { + let body = until_next_header(chunk); + let signature = VacuitySignature { + skipped_steps: extract_array(body, "skipped-steps"), + success_steps: extract_array(body, "success-steps"), + }; + if !signature.is_usable() { + continue; + } + out.push(ScannerSignature { + scanner: extract_scalar(body, "scanner").unwrap_or_else(|| "unnamed".to_string()), + signature, + }); + } + + if out.is_empty() { + // Legacy fallback: directives at version 0.2.0 carried a single flat + // pair of keys and no tables. Repos across the estate still ship that + // copy, and `fight` reads the directive of the repo it is fighting — + // so without this, upgrading the host would SILENTLY stop detecting + // hypatia vacuity on every unmigrated repo. It cannot double-parse: + // this repo's own directive no longer carries the flat keys, and the + // branch is only reached when no table was found at all. + let legacy = VacuitySignature { + skipped_steps: extract_array(raw, "signature-skipped-steps"), + success_steps: extract_array(raw, "signature-success-steps"), + }; + if legacy.is_usable() { + return SignatureSet::single("legacy-flat-keys", legacy); + } + } + + SignatureSet::new(out) +} + +/// Load the gate's applicability predicate (axis 0). Never fails. +/// +/// Absent keys yield [`Applicability::default`], which `applicability_verdict` +/// short-circuits on — so "no predicate" means "applies here". Inapplicable is +/// a claim, and an absent claim must not be manufactured. +pub fn load_applicability(repo_root: &Path) -> Applicability { + parse_applicability(&read(repo_root, DIRECTIVE_PATH)) +} + +/// Pure half of [`load_applicability`]. +pub fn parse_applicability(raw: &str) -> Applicability { + let section = section_of(raw, "[gate-triage.applicability]"); + Applicability { + runs_for_operator_types: extract_array(section, "runs-for-operator-types"), + runs_on_channels: extract_array(section, "runs-on-channels"), + } +} + +/// Load the repo's own declaration from the manifest the directive names. +/// +/// Never fails, and never guesses: an absent manifest, an absent key or a +/// manifest with no `@` keys all yield `None` fields, and a `None` field is +/// skipped by `applicability_verdict` rather than treated as a mismatch. +pub fn load_declaration(repo_root: &Path) -> RepoDeclaration { + let directive = read(repo_root, DIRECTIVE_PATH); + let section = section_of(&directive, "[gate-triage.applicability]"); + let manifest_name = + extract_scalar(section, "manifest-file").unwrap_or_else(|| DEFAULT_MANIFEST.to_string()); + let op_key = extract_scalar(section, "operator-type-key") + .unwrap_or_else(|| "@gitforge_OperatorType".to_string()); + let ch_key = extract_scalar(section, "channel-key").unwrap_or_else(|| "@channel".to_string()); + + let manifest = read(repo_root, &manifest_name); + RepoDeclaration { + operator_type: extract_scalar(&manifest, &op_key), + channel: extract_scalar(&manifest, &ch_key), + } +} + +fn read(repo_root: &Path, rel: &str) -> String { + std::fs::read_to_string(repo_root.join(rel)).unwrap_or_default() +} + +/// Truncate `chunk` at the next line that opens a table or section, so one +/// table's keys cannot be read out of the next one. +fn until_next_header(chunk: &str) -> &str { + let mut end = chunk.len(); + let mut at = 0usize; + for line in chunk.split_inclusive('\n') { + if line.trim_start().starts_with('[') { + end = at; + break; + } + at += line.len(); + } + &chunk[..end] +} + +/// The text of one `[section]`, from its header to the next header. +/// +/// Returns the whole input when the header is absent, which keeps a +/// header-less test fixture usable; every caller's keys are distinctive +/// enough that a whole-file search is not a false-positive risk. +fn section_of<'a>(raw: &'a str, header: &str) -> &'a str { + match raw.find(header) { + Some(i) => { + let rest = &raw[i + header.len()..]; + let body_end = until_next_header(rest).len(); + &rest[..body_end] + } + None => raw, + } } -/// Pure half — the unit of test coverage. [`load_signature`] only supplies the -/// file's text. -pub fn parse_signature(raw: &str) -> VacuitySignature { - VacuitySignature { - skipped_steps: extract_array(raw, "signature-skipped-steps"), - success_steps: extract_array(raw, "signature-success-steps"), +/// Read a `key = "value"` scalar. Commented lines are skipped: the directive +/// carries `# runs-on-channels = ["alpha"]` as a worked example, and a +/// parser that read its own documentation would be its own fake green. +fn extract_scalar(text: &str, key: &str) -> Option { + for line in text.lines() { + let t = line.trim(); + if t.starts_with('#') { + continue; + } + let Some(rest) = t.strip_prefix(key) else { + continue; + }; + let rest = rest.trim_start(); + let Some(rest) = rest.strip_prefix('=').or_else(|| rest.strip_prefix(':')) else { + continue; + }; + let rest = rest.trim_start(); + if let Some(open) = rest.strip_prefix('"') { + if let Some(close) = open.find('"') { + return Some(open[..close].to_string()); + } + } } + None } #[cfg(test)] mod tests { use super::*; + use squabble_core::polarity::{StepConclusion, StepOutcome}; + /// Two scanners, written the way the real directive writes them. const DIRECTIVE: &str = r#" [gate-triage.detection] source = "actions jobs API step conclusions" -signature-skipped-steps = ["Run Hypatia scan"] -signature-success-steps = ["Create stub findings"] check-conclusion-when-vacuous = "success" -evidence-required = ["run-count", "stub-rate", "upstream-exists", "target-tech-present"] + +[[gate-triage.detection.signatures]] +scanner = "hypatia" +skipped-steps = ["Run Hypatia scan"] +success-steps = ["Create stub findings (when Hypatia unavailable)"] +census = "33/33" + +[[gate-triage.detection.signatures]] +scanner = "panic-attack" +skipped-steps = ["Run panic-attack assail"] +success-steps = ["Create stub findings (when panic-attack unavailable)"] +census = "33/33" "#; + fn workspace_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("workspace root") + } + + fn step(name: &str, c: StepConclusion) -> StepOutcome { + StepOutcome { + name: name.to_string(), + conclusion: c, + } + } + + #[test] + fn every_table_becomes_its_own_scanner() { + let set = parse_signatures(DIRECTIVE); + assert_eq!(set.signatures.len(), 2, "one table, one scanner"); + assert_eq!(set.signatures[0].scanner, "hypatia"); + assert_eq!(set.signatures[1].scanner, "panic-attack"); + assert!(set.is_usable()); + } + + #[test] + fn the_two_step_keys_do_not_bleed_into_each_other() { + // `skipped-steps` and `success-steps` are read from the same chunk; a + // sloppy substring match would merge them. + let set = parse_signatures(DIRECTIVE); + let h = &set.signatures[0].signature; + assert_eq!(h.skipped_steps, vec!["Run Hypatia scan".to_string()]); + assert_eq!( + h.success_steps, + vec!["Create stub findings (when Hypatia unavailable)".to_string()] + ); + } + + #[test] + fn one_tables_steps_never_leak_into_the_next() { + // The whole point of chunking. If the parser searched the whole file + // per key, every scanner would get hypatia's steps. + let set = parse_signatures(DIRECTIVE); + let pa = &set.signatures[1].signature; + assert_eq!(pa.skipped_steps, vec!["Run panic-attack assail".to_string()]); + assert!( + !pa.skipped_steps.contains(&"Run Hypatia scan".to_string()), + "panic-attack must not inherit hypatia's steps" + ); + } + #[test] - fn both_signature_halves_are_read_from_the_directive() { - let sig = parse_signature(DIRECTIVE); - assert_eq!(sig.skipped_steps, vec!["Run Hypatia scan".to_string()]); - assert_eq!(sig.success_steps, vec!["Create stub findings".to_string()]); - assert!(sig.is_usable()); + fn a_scanner_signature_cannot_match_another_scanners_job() { + // The disjunction's safety property, ASSERTED rather than assumed: + // `step_concluded` is false for an absent step, so a job that never + // recorded hypatia's steps cannot be judged by hypatia's signature. + let set = parse_signatures(DIRECTIVE); + let panic_attack_job = [ + step("Run panic-attack assail", StepConclusion::Skipped), + step( + "Create stub findings (when panic-attack unavailable)", + StepConclusion::Success, + ), + ]; + let matched = set + .matching(&panic_attack_job) + .expect("panic-attack's own signature must match"); + assert_eq!( + matched.scanner, "panic-attack", + "matched the wrong scanner — the disjunction leaked" + ); } #[test] - fn the_two_keys_do_not_bleed_into_each_other() { - // `signature-skipped-steps` and `signature-success-steps` share a long - // prefix; a sloppy prefix match would merge them. - let sig = parse_signature(DIRECTIVE); - assert!(!sig.skipped_steps.contains(&"Create stub findings".to_string())); - assert!(!sig.success_steps.contains(&"Run Hypatia scan".to_string())); + fn a_half_written_table_is_dropped_not_carried() { + let set = parse_signatures( + r#" +[[gate-triage.detection.signatures]] +scanner = "half" +skipped-steps = ["Run something"] +"#, + ); + assert!( + set.signatures.is_empty(), + "a signature with one empty half matches nothing and must not be counted as coverage" + ); + assert!(!set.is_usable()); } #[test] fn an_absent_directive_detects_nothing() { // Fail-safe: no directive must mean "detect no vacuity", never // "detect vacuity everywhere". - let sig = parse_signature(""); - assert!(!sig.is_usable()); + assert!(!parse_signatures("").is_usable()); + assert!(!load_signatures(Path::new("/nonexistent-repo-root")).is_usable()); } #[test] - fn a_missing_file_yields_an_unusable_signature() { - let sig = load_signature(Path::new("/nonexistent-repo-root")); - assert!(!sig.is_usable()); + fn a_legacy_flat_directive_still_parses() { + // Repos across the estate still ship the 0.2.0 directive. Without the + // fallback, upgrading the host would silently stop detecting hypatia + // vacuity on every one of them. + let set = parse_signatures( + r#" +[gate-triage.detection] +signature-skipped-steps = ["Run Hypatia scan"] +signature-success-steps = ["Create stub findings (when Hypatia unavailable)"] +"#, + ); + assert!(set.is_usable(), "a 0.2.0 directive must still be readable"); + assert_eq!(set.signatures.len(), 1); + } + + #[test] + fn the_tables_win_over_legacy_keys_so_hypatia_is_never_parsed_twice() { + let mut both = DIRECTIVE.to_string(); + both.push_str("\nsignature-skipped-steps = [\"Run Hypatia scan\"]\n"); + both.push_str("signature-success-steps = [\"Create stub findings\"]\n"); + let set = parse_signatures(&both); + assert_eq!(set.signatures.len(), 2, "the legacy pair must not add a third"); + } + + // ---- ground truth ------------------------------------------------------ + + /// Every `name:` value in this repo's own gate workflow. Extracted from the + /// shipped artefact, so it cannot drift the way a copied-in fixture can — + /// which is exactly the failure PR #60 nearly shipped, when the directive + /// carried a step name that had come from a ruling rather than a file. + fn real_workflow_step_names() -> Vec { + let raw = std::fs::read_to_string( + workspace_root().join(".github/workflows/static-analysis-gate.yml"), + ) + .expect("this repo ships .github/workflows/static-analysis-gate.yml"); + raw.lines() + .filter_map(|l| { + let t = l.trim(); + let t = t.strip_prefix("- ").unwrap_or(t); + t.strip_prefix("name:").map(|v| v.trim().to_string()) + }) + .collect() } #[test] fn the_repos_own_directive_names_real_workflow_steps() { - // Ground-truth against the real file. `is_usable()` alone was NOT - // enough: it asks "is the list non-empty", while the consumer needs - // "do these names match a real job". The directive shipped - // "Create stub findings" — an abbreviation that matches no step on - // earth, since `step_concluded` compares exactly. Every check would - // have read `Genuine` forever. - // - // The names below are a census of 33 local `static-analysis-gate.yml` - // copies (2026-09-04): 33/33, zero variants. - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(|p| p.parent()) - .expect("workspace root"); - let sig = load_signature(root); + let names = real_workflow_step_names(); assert!( - sig.is_usable(), - "this repo's own {DIRECTIVE_PATH} must parse into a usable signature" + names.len() > 10, + "extractor found only {} names — it has stopped reading the workflow", + names.len() ); - assert_eq!( - sig.skipped_steps, - vec!["Run Hypatia scan".to_string()], - "must be the workflow's literal step name" + + let set = load_signatures(workspace_root()); + assert!( + set.is_usable(), + "this repo's own {DIRECTIVE_PATH} must parse into a usable signature set" ); assert_eq!( - sig.success_steps, - vec!["Create stub findings (when Hypatia unavailable)".to_string()], - "the parenthetical is part of the real step name — do not abbreviate" + set.signatures.len(), + 3, + "the workflow carries three stub paths; all three must be described" + ); + + for entry in &set.signatures { + for step_name in entry + .signature + .skipped_steps + .iter() + .chain(entry.signature.success_steps.iter()) + { + assert!( + names.contains(step_name), + "directive names step {step_name:?} for scanner {:?}, but no such step \ + exists in static-analysis-gate.yml. `step_concluded` compares EXACTLY, \ + so this signature would match no job on earth. Do not abbreviate.", + entry.scanner + ); + } + } + } + + #[test] + fn all_three_scanners_are_named() { + let set = load_signatures(workspace_root()); + let mut scanners: Vec<&str> = set.signatures.iter().map(|s| s.scanner.as_str()).collect(); + scanners.sort_unstable(); + assert_eq!(scanners, vec!["hypatia", "panic-attack", "patch-bridge"]); + } + + // ---- axis 0 ------------------------------------------------------------ + + #[test] + fn an_undeclared_predicate_means_applies_here() { + // Never manufacture an inapplicability claim from silence. + let a = parse_applicability(""); + assert!(a.is_undeclared()); + } + + #[test] + fn the_directives_commented_example_is_not_read_as_a_predicate() { + // `[gate-triage.applicability]` carries a worked example in comments: + // # runs-on-channels = ["alpha"] + // A parser that read its own documentation would declare this repo + // inapplicable on every channel but alpha. + let a = load_applicability(workspace_root()); + assert!( + a.is_undeclared(), + "read a predicate out of the directive's comments: {a:?}" + ); + } + + #[test] + fn a_real_predicate_is_read_when_one_is_written() { + let a = parse_applicability( + r#" +[gate-triage.applicability] +runs-for-operator-types = ["developer", "platform_maintainer"] +runs-on-channels = ["alpha"] +"#, + ); + assert_eq!(a.runs_on_channels, vec!["alpha".to_string()]); + assert_eq!(a.runs_for_operator_types.len(), 2); + assert!(!a.is_undeclared()); + } + + #[test] + fn this_repos_manifest_declares_neither_key() { + // Measured 2026-09-09: `0-AI-MANIFEST.a2ml` carries no `@` keys at all. + // Axis 0 is therefore READ but cannot FIRE, and this test is what will + // fail the day someone writes a declaration — at which point the + // directive's `applicability-can-fire-today = false` must be updated + // rather than left to rot. + let d = load_declaration(workspace_root()); + assert!(d.operator_type.is_none(), "got {:?}", d.operator_type); + assert!(d.channel.is_none(), "got {:?}", d.channel); + } + + #[test] + fn the_directive_names_a_manifest_that_exists() { + // The directive named `0.1-AI-MANIFEST.a2ml` until 2026-09-09; no such + // file has ever existed here, so the declaration could never be read. + let directive = read(workspace_root(), DIRECTIVE_PATH); + let section = section_of(&directive, "[gate-triage.applicability]"); + let named = extract_scalar(section, "manifest-file").expect("directive names a manifest"); + assert!( + workspace_root().join(&named).is_file(), + "directive names manifest {named:?}, which does not exist in this repo" ); } }