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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition = "2021"
rust-version = "1.75"
description = "Static binary CLI wrapping quire-rs (parse, extract, lookup, edit, validate, schema)."
authors = ["Agent IX <kreneskyp@gmail.com>"]
license = "MIT"
license = "AGPL-3.0-or-later"
repository = "https://github.com/agent-ix/quire-cli"

[lib]
Expand All @@ -17,7 +17,7 @@ name = "quire"
path = "src/main.rs"

[dependencies]
quire-rs = { git = "https://github.com/agent-ix/quire-rs", tag = "v0.11.0" }
quire-rs = { git = "https://github.com/agent-ix/quire-rs", tag = "v0.12.0" }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
682 changes: 661 additions & 21 deletions LICENSE

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,4 @@ to integration tests, benchmarks, or static audits.

## License

MIT
AGPL-3.0-or-later
1 change: 1 addition & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[licenses]
version = 2
allow = [
"AGPL-3.0-or-later",
"MIT",
"Apache-2.0",
"BSD-2-Clause",
Expand Down
75 changes: 74 additions & 1 deletion src/commands/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ pub struct Args {
/// always printed; --strict only changes the exit code.
#[arg(long)]
pub strict: bool,

/// Print an EARS requirement-grammar summary (FR-042) after validation:
/// documents scanned, how many are grammar-clean (doc-level conformance),
/// and a per-check finding histogram. Advisory — never changes the exit
/// code. Findings are also printed inline as warnings regardless.
#[arg(long)]
pub summary: bool,
}

/// The EARS check id inside a grammar warning message (`[ears:<check>] …`),
/// or `None` for a non-grammar warning.
fn grammar_check_name(message: &str) -> Option<&str> {
let rest = message.strip_prefix("[ears:")?;
let end = rest.find(']')?;
Some(&rest[..end])
}

pub fn run(ctx: &Ctx, args: Args) -> anyhow::Result<()> {
Expand All @@ -82,8 +97,21 @@ pub fn run(ctx: &Ctx, args: Args) -> anyhow::Result<()> {
// clap guarantees a non-empty `documents` here (required_unless_present).
let inputs = expand_documents(&args.documents, &scope, scoped)?;

// FR-044: harvest the scope repo's project Ubiquitous-Language terms (a
// `Glossary` `## Terms` table or `## Ubiquitous Language` sections) once,
// and compose the combined (module ∪ project) lexicon the EARS grammar
// check consumes for every validated file. Empty when the repo has none.
// Scans only glossary-bearing docs — never a full-corpus parse.
let project_terms = quire_rs::glossary_terms_from_path(&scope);
let lexicon = registry.lexicon_with(&project_terms);

let mut failures = 0usize;
let mut warned = 0usize;
// EARS grammar summary accumulators (FR-042) — populated only to print
// the optional --summary block; never affect the exit code.
let mut docs_scanned = 0usize;
let mut docs_grammar_clean = 0usize;
let mut grammar_checks: std::collections::BTreeMap<String, usize> = Default::default();
for input in inputs {
let label = input.label();
let text = input.read().with_context(|| format!("reading '{label}'"))?;
Expand Down Expand Up @@ -129,14 +157,32 @@ pub fn run(ctx: &Ctx, args: Args) -> anyhow::Result<()> {

// Composed type+object validation (FR-032-AC-11..13): the registry
// is available, so resolve the frontmatter `object:` archetype too.
let result = quire_rs::validate_document_in_registry(&registry, archetype, &text);
let result = quire_rs::validate_document_in_registry_with_lexicon(
&registry, archetype, &text, &lexicon,
);
let outcome = surface_result(ctx, &label, &result);
if outcome.had_errors {
failures += 1;
}
if outcome.had_warnings {
warned += 1;
}
// Tally EARS grammar findings for the optional summary.
docs_scanned += 1;
let mut doc_grammar = 0usize;
for w in &result.warnings {
if let Some(check) = grammar_check_name(&w.message) {
*grammar_checks.entry(check.to_string()).or_default() += 1;
doc_grammar += 1;
}
}
if doc_grammar == 0 {
docs_grammar_clean += 1;
}
}

if args.summary {
emit_grammar_summary(ctx, docs_scanned, docs_grammar_clean, &grammar_checks);
}

if failures > 0 {
Expand All @@ -150,6 +196,33 @@ pub fn run(ctx: &Ctx, args: Args) -> anyhow::Result<()> {
Ok(())
}

/// Emit the EARS requirement-grammar summary (FR-042) on stderr via the shared
/// diagnostic channel (human or JSON per --diagnostics-format). Advisory: a
/// one-line histogram + doc-level conformance, never affecting the exit code.
fn emit_grammar_summary(
ctx: &Ctx,
docs_scanned: usize,
docs_clean: usize,
checks: &std::collections::BTreeMap<String, usize>,
) {
let total_findings: usize = checks.values().sum();
let pct = (docs_clean * 100).checked_div(docs_scanned).unwrap_or(100);
let histogram = if checks.is_empty() {
"none".to_string()
} else {
checks
.iter()
.map(|(c, n)| format!("{c}={n}"))
.collect::<Vec<_>>()
.join(" ")
};
let message = format!(
"{docs_clean}/{docs_scanned} docs grammar-clean ({pct}%); \
{total_findings} EARS finding(s): {histogram}"
);
io::emit_diagnostic(ctx.diagnostics, "GrammarSummary", &message);
}

/// OKF bundle validation (permissive posture). Validates each bundle
/// directory wholesale via `quire_rs::validate_bundle_at`, surfacing
/// warnings and errors on stderr. Exit 1 only when there are hard errors
Expand Down
42 changes: 42 additions & 0 deletions tests/cli_validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,45 @@ fn it_075_json_warning_has_distinct_severity() {
.and(predicate::str::contains("totally-unknown")),
);
}

// ----------------------------------------------------------------------
// EARS requirement-grammar surfacing (quire-rs FR-042). The `iso` fixture
// module binds FR to `grammar_ref: iso-spec-core`.
// ----------------------------------------------------------------------

// A structurally-valid FR carrying EARS violations exits 0 (grammar findings
// are advisory) and surfaces them as warnings; --summary prints the doc-level
// conformance + per-check histogram on stderr, stdout stays empty.
#[test]
fn ears_grammar_warnings_are_advisory_and_summarized() {
quire()
.arg("validate")
.arg(iso_doc("FR-ears-warn.md"))
.arg("--module")
.arg(iso_module())
.arg("--summary")
.assert()
.success()
.stdout(predicate::str::is_empty())
.stderr(
predicate::str::contains("[ears:vague-response]")
.and(predicate::str::contains("[ears:non-canonical-trigger]"))
.and(predicate::str::contains("docs grammar-clean"))
.and(predicate::str::contains("vague-response=1")),
);
}

// --strict escalates the advisory EARS warnings to a failing exit code — the
// per-repo promotion lever: a converted repo flips EARS to blocking in CI.
#[test]
fn ears_grammar_warnings_fail_under_strict() {
quire()
.arg("validate")
.arg(iso_doc("FR-ears-warn.md"))
.arg("--module")
.arg(iso_module())
.arg("--strict")
.assert()
.failure()
.code(1);
}
11 changes: 11 additions & 0 deletions tests/fixtures/iso-docs/FR-ears-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
id: FR-009
title: FR EARS warning fixture
type: FR
---

# FR-009 FR EARS warning fixture

## Description

On startup, the system shall support publishing to registries.
Loading