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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 162 additions & 7 deletions build_support/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,20 @@ impl Level {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Applicability {
Universal,
Conditional(String),
/// Conditional applicability. Either a prose `condition` (legacy `{ if:
/// "<prose>" }` shape, machine-opaque) or a machine-readable `antecedent`
/// (new `{ kind: conditional, antecedent: { check_id: ... } }` shape), or
/// both. The new shape is preferred; the legacy shape remains accepted for
/// rows where the verifier catalog has not yet grown a prerequisite check.
Conditional {
condition: Option<String>,
antecedent: Option<Antecedent>,
},
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Antecedent {
pub check_id: String,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -91,6 +104,10 @@ pub enum ParseError {
EmptyRequirements {
file: String,
},
ConditionalEmpty {
file: String,
requirement_id: String,
},
}

impl fmt::Display for ParseError {
Expand Down Expand Up @@ -149,6 +166,13 @@ impl fmt::Display for ParseError {
ParseError::EmptyRequirements { file } => {
write!(f, "{file}: `requirements` list is empty")
}
ParseError::ConditionalEmpty {
file,
requirement_id,
} => write!(
f,
"{file}: requirement `{requirement_id}` declares `kind: conditional` but provides neither `condition:` nor `antecedent.check_id` — at least one must be set"
),
}
}
}
Expand Down Expand Up @@ -282,10 +306,23 @@ pub fn emit_rust(reqs: &[ParsedRequirement], spec_version: &str) -> String {
Applicability::Universal => {
out.push_str(" applicability: Applicability::Universal,\n");
}
Applicability::Conditional(cond) => {
Applicability::Conditional {
condition,
antecedent,
} => {
let cond_lit = match condition {
Some(c) => format!("Some(\"{}\")", escape_rust_str(c)),
None => "None".to_string(),
};
let ante_lit = match antecedent {
Some(a) => format!(
"Some(Antecedent {{ check_id: \"{}\" }})",
escape_rust_str(&a.check_id)
),
None => "None".to_string(),
};
out.push_str(&format!(
" applicability: Applicability::Conditional(\"{}\"),\n",
escape_rust_str(cond)
" applicability: Applicability::Conditional {{ condition: {cond_lit}, antecedent: {ante_lit} }},\n"
));
}
}
Expand Down Expand Up @@ -397,6 +434,22 @@ fn parse_applicability(

if let Some(map) = value.as_mapping() {
let if_key = serde_yaml::Value::String("if".into());
let kind_key = serde_yaml::Value::String("kind".into());

// Mixing the legacy `if:` and new `kind:` shape in one applicability
// block is ambiguous: the legacy branch only fires when `if:` is the
// single key, so a row carrying both would silently use the new shape
// and drop the legacy prose. Reject early with a pointer at the
// schema the author probably meant to use.
if map.contains_key(&if_key) && map.contains_key(&kind_key) {
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`if:` and `kind:` cannot both be set; choose one (legacy `{ if: \"<prose>\" }` or new `{ kind: conditional, antecedent: { check_id: ... } }`)".into(),
});
}

// Legacy single-key `{ if: "<prose>" }` shape.
if map.len() == 1
&& let Some(if_val) = map.get(&if_key)
{
Expand All @@ -414,19 +467,121 @@ fn parse_applicability(
hint: "`if:` value must be a non-empty string".into(),
});
}
return Ok(Applicability::Conditional(cond.to_string()));
return Ok(Applicability::Conditional {
condition: Some(cond.to_string()),
antecedent: None,
});
}

// New `{ kind: conditional, antecedent: { check_id: ... }, condition? }`
// shape. The `kind:` key gates the new branch so legacy maps with
// unrelated keys still surface as UnknownApplicability.
if let Some(kind_val) = map.get(&kind_key) {
let kind = kind_val
.as_str()
.ok_or_else(|| ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`kind:` value must be the string `conditional`".into(),
})?;
if kind != "conditional" {
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: format!("unknown `kind: {kind}` — only `conditional` is supported"),
});
}

let antecedent_key = serde_yaml::Value::String("antecedent".into());
let condition_key = serde_yaml::Value::String("condition".into());

let antecedent = if let Some(ante_val) = map.get(&antecedent_key) {
let ante_map =
ante_val
.as_mapping()
.ok_or_else(|| ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`antecedent:` must be a mapping like `{ check_id: <id> }`"
.into(),
})?;
let check_id_key = serde_yaml::Value::String("check_id".into());
// v1 schema is strict: only `check_id` is permitted inside
// `antecedent`. Compound antecedents (`op: any_of | all_of`)
// and any other key are explicitly deferred to a future
// schema bump per plan Sub-decision 2b. Silently ignoring
// unknown keys would let v2 syntax land in v1 vendored spec
// and behave subtly wrong.
for (k, _) in ante_map {
let k_str = k.as_str().unwrap_or("<non-string>");
if k_str != "check_id" {
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: format!(
"`antecedent.{k_str}` is not part of the v1 schema (only `check_id` is permitted; compound antecedents deferred to v2 per plan Sub-decision 2b)"
),
});
}
}
let check_id = ante_map.get(&check_id_key).and_then(|v| v.as_str()).ok_or_else(|| ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`antecedent.check_id` must be a non-empty string (v1 schema supports a single antecedent only)".into(),
})?;
if check_id.trim().is_empty() {
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`antecedent.check_id` must be a non-empty string (whitespace-only is rejected)".into(),
});
}
Some(Antecedent {
check_id: check_id.to_string(),
})
} else {
None
};

let condition = match map.get(&condition_key) {
Some(v) => {
let s = v.as_str().ok_or_else(|| ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`condition:` must be a string".into(),
})?;
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
None => None,
};

if condition.is_none() && antecedent.is_none() {
return Err(ParseError::ConditionalEmpty {
file: file.to_string(),
requirement_id: req_id.to_string(),
});
}
return Ok(Applicability::Conditional {
condition,
antecedent,
});
}

return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "expected `{ if: \"<prose>\" }`".into(),
hint: "expected `{ if: \"<prose>\" }` or `{ kind: conditional, antecedent: { check_id: <id> } }`".into(),
});
}

Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "must be `universal` or `{ if: \"<prose>\" }`".into(),
hint: "must be `universal`, `{ if: \"<prose>\" }`, or `{ kind: conditional, antecedent: { check_id: <id> } }`".into(),
})
}

Expand Down
20 changes: 15 additions & 5 deletions coverage/matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@
"summary": "CLIs that emit structured output expose the output schema via a `schema` subcommand or `--schema` flag: runtime-discoverable, with a documented format identifier.",
"applicability": {
"kind": "conditional",
"condition": "CLI emits structured output"
"antecedent": {
"check_id": "p2-json-output"
}
},
"verifiers": [
{
Expand Down Expand Up @@ -223,7 +225,9 @@
"summary": "Output schemas are also exported to a stable file path (e.g., `schema/<command>.json`) so CI/static-analysis consumers pin without invoking the tool.",
"applicability": {
"kind": "conditional",
"condition": "CLI emits structured output"
"antecedent": {
"check_id": "p2-json-output"
}
},
"verifiers": [
{
Expand Down Expand Up @@ -885,7 +889,9 @@
"summary": "When a skill bundle exists, the CLI provides an install path (`tool skill install [<host>]`) that registers the bundle with installed agent runtimes.",
"applicability": {
"kind": "conditional",
"condition": "CLI ships an agent skill bundle"
"antecedent": {
"check_id": "p8-bundle-exists"
}
},
"verifiers": [
{
Expand Down Expand Up @@ -920,7 +926,9 @@
"summary": "An `--all` mode auto-detects installed runtimes (Claude Code, Cursor, Codex, OpenCode, etc.) and installs across all.",
"applicability": {
"kind": "conditional",
"condition": "CLI ships an agent skill bundle"
"antecedent": {
"check_id": "p8-bundle-exists"
}
},
"verifiers": [
{
Expand All @@ -936,7 +944,9 @@
"summary": "An update/upgrade subcommand (`tool skill update`) pulls the latest bundle version.",
"applicability": {
"kind": "conditional",
"condition": "CLI ships an agent skill bundle"
"antecedent": {
"check_id": "p8-bundle-exists"
}
},
"verifiers": [
{
Expand Down
10 changes: 5 additions & 5 deletions docs/coverage-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ When a requirement has no verifier, the cell reads **UNCOVERED** and the reader
| `p2-must-stdout-stderr-split` | MUST | Universal | `p2-output-module` (source) | Data goes to stdout; diagnostics/progress/warnings go to stderr, never interleaved. |
| `p2-must-exit-codes` | MUST | Universal | `p2-structured-exit-codes` (behavioral) | Exit codes are structured and documented (0 success, 1 general, 2 usage, 77 auth, 78 config). |
| `p2-must-json-errors` | MUST | Universal | `p2-json-errors` (behavioral) | When `--output json` is active, errors are emitted as JSON (to stderr) with at least `error`, `kind`, and `message` fields. |
| `p2-must-schema-print` | MUST | If: CLI emits structured output | `p2-schema-print` (behavioral) | CLIs that emit structured output expose the output schema via a `schema` subcommand or `--schema` flag: runtime-discoverable, with a documented format identifier. |
| `p2-must-schema-print` | MUST | If: `p2-json-output` is present | `p2-schema-print` (behavioral) | CLIs that emit structured output expose the output schema via a `schema` subcommand or `--schema` flag: runtime-discoverable, with a documented format identifier. |
| `p2-should-consistent-envelope` | SHOULD | Universal | `p2-consistent-envelope` (behavioral) | JSON output uses a consistent envelope (a top-level object with predictable keys) across every command. |
| `p2-should-schema-file` | SHOULD | If: CLI emits structured output | `p2-schema-file` (project) | Output schemas are also exported to a stable file path (e.g., `schema/<command>.json`) so CI/static-analysis consumers pin without invoking the tool. |
| `p2-should-schema-file` | SHOULD | If: `p2-json-output` is present | `p2-schema-file` (project) | Output schemas are also exported to a stable file path (e.g., `schema/<command>.json`) so CI/static-analysis consumers pin without invoking the tool. |
| `p2-should-json-aliases` | SHOULD | Universal | `p2-json-aliases` (behavioral) | `--json` and `--jsonl` are accepted as aliases for `--output json` and `--output jsonl`; the short forms work alongside the canonical enum. |
| `p2-may-more-formats` | MAY | Universal | `p2-more-formats` (behavioral) | Additional output formats (CSV, TSV, YAML) beyond the core three. |
| `p2-may-raw-flag` | MAY | Universal | `p2-raw-flag` (behavioral) | `--raw` flag for unformatted output suitable for piping to other tools. |
Expand Down Expand Up @@ -107,8 +107,8 @@ When a requirement has no verifier, the cell reads **UNCOVERED** and the reader

| ID | Level | Applicability | Verifier(s) | Summary |
| --- | --- | --- | --- | --- |
| `p8-must-bundle-install` | MUST | If: CLI ships an agent skill bundle | `p8-bundle-install` (behavioral) | When a skill bundle exists, the CLI provides an install path (`tool skill install [<host>]`) that registers the bundle with installed agent runtimes. |
| `p8-must-bundle-install` | MUST | If: `p8-bundle-exists` is present | `p8-bundle-install` (behavioral) | When a skill bundle exists, the CLI provides an install path (`tool skill install [<host>]`) that registers the bundle with installed agent runtimes. |
| `p8-should-bundle-exists` | SHOULD | Universal | `p6-agents-md` (project)<br>`p8-bundle-exists` (project) | CLIs ship a top-level agent-discoverable markdown bundle (`AGENTS.md`, `SKILL.md`, or equivalent) with YAML frontmatter naming the tool and capability summary. |
| `p8-may-install-all` | MAY | If: CLI ships an agent skill bundle | `p8-install-all` (behavioral) | An `--all` mode auto-detects installed runtimes (Claude Code, Cursor, Codex, OpenCode, etc.) and installs across all. |
| `p8-may-bundle-update` | MAY | If: CLI ships an agent skill bundle | `p8-bundle-update` (behavioral) | An update/upgrade subcommand (`tool skill update`) pulls the latest bundle version. |
| `p8-may-install-all` | MAY | If: `p8-bundle-exists` is present | `p8-install-all` (behavioral) | An `--all` mode auto-detects installed runtimes (Claude Code, Cursor, Codex, OpenCode, etc.) and installs across all. |
| `p8-may-bundle-update` | MAY | If: `p8-bundle-exists` is present | `p8-bundle-update` (behavioral) | An update/upgrade subcommand (`tool skill update`) pulls the latest bundle version. |

Loading
Loading