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
3 changes: 3 additions & 0 deletions crates/grund-cli/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ mod case_runner;
#[path = "support/chapter_rule_workflow.rs"]
mod chapter_rule_workflow;

#[path = "support/chapter_diagnostics.rs"]
mod chapter_diagnostics;

use case_runner::CaseKind::{E2e, Example};
use case_runner::{
assert_case_is_deterministic, assert_every_case_passed, discover_e2e_cases, discover_examples,
Expand Down
2 changes: 1 addition & 1 deletion crates/grund-cli/tests/rules_contract/authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ fn a_should_level_sentence_needs_suggestions_to_be_seen() {
),
0,
"docs/fs/FS-demo.md:1: suggestion: FS-demo has 0 security chapters; \
--rule requires exactly one\n",
--rule requires exactly one; expected display name \"security\" (case-insensitive, not section handle); observed direct chapters: \"goals: Goals\", \"requirements: Requirements\"\n",
"",
);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/grund-cli/tests/rules_contract/refusals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ fn every_listed_refusal_has_its_exact_rewrite_and_exit_two() {
),
(
"Each FS must have exactly one chapter.",
"chapter name must be a non-empty NAME with no surrounding whitespace; accepted form: Each FS must have exactly one requirements chapter.",
"chapter name must be a non-empty NAME with no surrounding whitespace; accepted form: Each FS must have exactly one requirements chapter. NAME forbids whitespace anywhere.",
),
(
"Each FS must cite at least one GOAL",
Expand Down
2 changes: 1 addition & 1 deletion crates/grund-cli/tests/rules_contract/regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ fn chapter_handles_and_display_names_have_distinct_meanings() {
assert_run(
&output,
1,
"docs/fs/FS-demo.md:1: error: FS-demo has 0 requirements chapters; --rule requires exactly one\n",
"docs/fs/FS-demo.md:1: error: FS-demo has 0 requirements chapters; --rule requires exactly one; expected display name \"requirements\" (case-insensitive, not section handle); observed direct chapters: \"requirements: Acceptance criteria\"\n",
"",
);
let output = run(&root, &["list", ".", "--selector", "FS.requirements"]);
Expand Down
2 changes: 1 addition & 1 deletion crates/grund-cli/tests/rules_contract/surfaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ fn ad_hoc_rule_is_additive_and_suggestions_do_not_change_the_exit() {
&["check", ".", "--rule", sentence, "--suggestions"],
);
let expected = format!(
"{TEXT_FINDINGS}docs/fs/FS-demo.md:1: suggestion: FS-demo has 0 security chapters; --rule requires exactly one\n"
"{TEXT_FINDINGS}docs/fs/FS-demo.md:1: suggestion: FS-demo has 0 security chapters; --rule requires exactly one; expected display name \"security\" (case-insensitive, not section handle); observed direct chapters: \"goals: Goals\", \"requirements: Requirements\"\n"
);
assert_run(&output, 1, &expected, "");
}
Expand Down
36 changes: 36 additions & 0 deletions crates/grund-cli/tests/support/chapter_diagnostics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//! Chapter diagnostic regressions for §FS-rules.7.2.1 and §FS-rules.3.5.1.
//! The unchanged-selector controls prove the goal chapter is actually reached.

use super::case_runner::{CaseKind::E2e, assert_every_case_passed, run_case};
use super::repo_root;

fn case(name: &str) {
let root = repo_root();
let path = root.join("tests/e2e/cases").join(name);
assert_every_case_passed(name, &[run_case(&root, &path, E2e)]);
}

/// §FS-rules.7.2.1, §FS-errors.3: old bytes plus display-name context in text.
#[test]
fn chapter_diagnostic_display_name_text() {
case("check-chapter-diagnostic-display-name-text");
}

/// §FS-rules.7.2.1: preserve every JSON field while explaining the zero count.
#[test]
fn chapter_diagnostic_display_name_json() {
case("check-chapter-diagnostic-display-name-json");
}

/// §FS-rules.3.5.1: keep the full refusal prefix and explain internal whitespace.
#[test]
fn chapter_diagnostic_whitespace_refusal() {
case("check-chapter-diagnostic-whitespace-refusal");
}

/// §FS-rules.2: a chapter subject selects the handle despite its display title.
#[test]
fn chapter_diagnostic_handle_subject_reaches_citation() {
case("check-chapter-diagnostic-handle-subject");
case("check-chapter-diagnostic-handle-subject-missing-citation");
}
4 changes: 3 additions & 1 deletion crates/grund-core/assets/skills/grund-init/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,8 @@ Subjects select local units only:
- `The requirements chapter of each FS` selects that named chapter in every FS.
- `FS-login.requirements` selects one exact named chapter.

Named chapter subjects select handles: `The goal chapter of each FS` reaches `## goal: Goal and hypothesis` by `goal` ([chapter subjects](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-rules.md#2-subject-selectors)). Presence rules compare direct chapter display names case-insensitively: `Each FS must have exactly one goal chapter.` counts zero for that heading and one for `## goal: Goal` ([presence matching](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-rules.md#31-chapter-presence)). The finding appends the expected display name and observed pairs such as `"goal: Goal and hypothesis"`, or `none` ([comparison context](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-rules.md#721-display-name-comparison-context)). Presence `NAME` must be one non-empty token: `Goal and hypothesis` is refused with `NAME forbids whitespace anywhere.` appended to the existing guidance; authored display titles may still contain spaces ([whitespace refusal](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-rules.md#351-presence-name-whitespace-refusal)).

Named-chapter subjects require `[id] named_sections = true`. Phase 1 has no
paths, files, folders, wildcards, subject namespaces, numbered-chapter subjects,
exceptions, definitions, derived terms, settings, or source-code symbols.
Expand Down Expand Up @@ -704,7 +706,7 @@ Common refusals are intentional and name the exact accepted rewrite:
- `Each FS must cite at least 1 GOAL.` → `numeric "at least 1" is not canonical; accepted form: Each FS must cite at least one GOAL.`
- `Each FS must cite exactly 1 GOAL.` → `numeric "exactly 1" is not canonical; accepted form: Each FS must cite exactly one GOAL.`
- `Each FS must cite at least one GOAL and must not cite any AR.` → `conjunctions are not accepted; accepted forms: "Each FS must cite at least one GOAL." and "Each FS must not cite any AR."`
- `Each FS must have exactly one chapter.` → `chapter name must be a non-empty NAME with no surrounding whitespace; accepted form: Each FS must have exactly one requirements chapter.`
- `Each FS must have exactly one chapter.` → `chapter name must be a non-empty NAME with no surrounding whitespace; accepted form: Each FS must have exactly one requirements chapter. NAME forbids whitespace anywhere.`
- `Each FS must cite at least one GOAL` → `rule must end with "."; accepted form: Each FS must cite at least one GOAL.`
- `each FS must cite at least one GOAL.` → `fixed word "Each" is case-sensitive; accepted form: Each FS must cite at least one GOAL.`
- `Each file in vendor/ must cite at least one FS.` → `path subjects are not accepted in phase 1; accepted form: Each FS must cite at least one GOAL.`
Expand Down
27 changes: 24 additions & 3 deletions crates/grund-core/src/rules/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,19 +137,40 @@ fn evaluate_one(
return;
};
for subject in subjects {
let count = index
.chapters_of(&subject)
let chapters = index.chapters_of(&subject);
let count = chapters
.iter()
.filter(|row| facts.chapter[**row].2.eq_ignore_ascii_case(name))
.count();
if !rule.cardinality.contains(count) {
// §FS-rules.7.2.1: show accepted direct chapters in source order,
// retaining the complete released prefix (§FS-errors.3).
let mut observed = chapters.to_vec();
observed.sort_by_key(|row| {
facts
.nodes
.get(&facts.chapter[*row].0)
.map(|meta| meta.anchor.line)
});
let observed = if observed.is_empty() {
"none".to_string()
} else {
observed
.iter()
.map(|row| {
let (_, coordinate, display_name) = &facts.chapter[*row];
format!("\"{coordinate}: {display_name}\"")
})
.collect::<Vec<_>>()
.join(", ")
};
push_node(
out,
facts,
&subject,
"chapter-cardinality",
format!(
"{} has {count} {name} chapters; {authority} requires {}",
"{} has {count} {name} chapters; {authority} requires {}; expected display name \"{name}\" (case-insensitive, not section handle); observed direct chapters: {observed}",
label(facts, &subject),
rule.cardinality.wording()
),
Expand Down
3 changes: 2 additions & 1 deletion crates/grund-core/src/rules/sentence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,9 @@ fn parse_predicate(
));
};
if name.is_empty() || name.trim() != name || name.chars().any(char::is_whitespace) {
// §FS-rules.3.5.1: append whitespace guidance to the released refusal prefix.
return Err(error(
"chapter name must be a non-empty NAME with no surrounding whitespace; accepted form: Each FS must have exactly one requirements chapter.",
"chapter name must be a non-empty NAME with no surrounding whitespace; accepted form: Each FS must have exactly one requirements chapter. NAME forbids whitespace anywhere.",
));
}
let expects_plural = match spelling {
Expand Down
37 changes: 37 additions & 0 deletions crates/grund-core/src/rules/tests_boundaries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,43 @@ fn rule() -> ParsedRule {
}
}

/// §FS-rules.3.5.1, §FS-errors.3: whitespace at any position remains refused,
/// with the entire released reason followed by actionable guidance.
#[test]
fn chapter_diagnostic_parser_refuses_whitespace_anywhere_in_presence_name() {
let vocabulary = RuleVocabulary {
kinds: BTreeSet::from(["FS".into()]),
target_kinds: BTreeSet::from(["FS".into()]),
target_namespaces: BTreeMap::new(),
named_sections: true,
id_grammars: Vec::new(),
section_separators: vec![".".into()],
};
let expected = concat!(
"chapter name must be a non-empty NAME with no surrounding whitespace; ",
"accepted form: Each FS must have exactly one requirements chapter.",
" NAME forbids whitespace anywhere."
);
for name in [
"Goal and hypothesis",
"Goal\tand",
"Goal\u{a0}and",
" goal",
"goal ",
"",
] {
let sentence = format!("Each FS must have exactly one {name} chapter.");
let refusal = parse_rule(
&sentence,
"RULE-presence".into(),
anchor("docs/rules.md", 1),
&vocabulary,
)
.expect_err("presence NAME with whitespace or no token stays refused");
assert_eq!(refusal.message, expected, "presence NAME {name:?}");
}
}

fn facts(completeness: Completeness) -> RuleFacts {
let fs = NodeKey("opaque-fs".into());
let goal = NodeKey("opaque-goal".into());
Expand Down
48 changes: 48 additions & 0 deletions docs/functional-spec/FS-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ The four accepted subject spellings are:
| `The NAME chapter of each KIND` | that named chapter of every local declaration of the kind that has one |
| `ID.NAME[.NAME…]` | the one local named chapter with that exact coordinate |

In chapter subjects, `NAME` selects the section handle, not its display name.
For `## goal: Goal and hypothesis`, `The goal chapter of each BENCH` selects
the `goal` coordinate even though its display name is `Goal and hypothesis`.
Presence rules compare the other field ([§FS-rules.3.1](FS-rules.md#31-chapter-presence)).

A token exactly equal to a configured citable kind name is the quantified kind
selector; any longer token must parse as a full ID under that kind's effective
grammar. Named components require `[id] named_sections = true` and obey the
Expand Down Expand Up @@ -135,6 +140,12 @@ spelling of a ceiling of one. The only accepted families are the following.
The subject may be a kind or exact declaration, not a chapter. The rule counts
the subject declaration's accepted direct chapters whose display name is
`NAME`. A count outside the stated interval produces `chapter-cardinality`.
The comparison is case-insensitive and does not match section handles: a direct
`## goal: Goal and hypothesis` chapter contributes zero to a presence rule for
`goal`, while `## goal: Goal` contributes one. Presence `NAME` is a non-empty
single token with no whitespace anywhere; the multi-word display name `Goal
and hypothesis` cannot be used as a presence `NAME`. This restriction does not
restrict authored display titles or change chapter-subject selection.
This family is how a repository states the *count* of a chapter — `exactly
one`, `at most N` — which no citation rule checks. A chapter-scoped citation
rule reaches a declaration that has no such chapter on its own
Expand Down Expand Up @@ -226,6 +237,21 @@ least these exact rows:
is not a pre-scan refusal. After scanning it produces the exact resolution
message `literal subject FS-missing does not resolve` ([§FS-rules.4](FS-rules.md#4-validation-lifecycle)).

#### 3.5.1 Presence-name whitespace refusal

A presence `NAME` containing whitespace anywhere, including internal spaces,
tabs, and Unicode whitespace, remains refused. The whole released reason,
including its accepted-form example, remains a verbatim contiguous prefix under
[§FS-errors.3](FS-errors.md#3-message-text); append the explanation:

```text
chapter name must be a non-empty NAME with no surrounding whitespace; accepted form: Each FS must have exactly one requirements chapter. NAME forbids whitespace anywhere.
```

An empty `NAME` uses the same refusal. This adds guidance without admitting any
new grammar. An ad-hoc refusal still writes nothing to stdout and exits 2
([§FS-rules.4](FS-rules.md#4-validation-lifecycle)).

## 4. Validation lifecycle

`config validate` validates only the optional `rules` key and its relationship
Expand Down Expand Up @@ -526,6 +552,28 @@ zero and surplus chapters:
<subject> has <actual> <name> chapters; <RULE-ID> requires <count>
```

#### 7.2.1 Display-name comparison context

Keep the complete message above as a verbatim contiguous prefix
([§FS-errors.3](FS-errors.md#3-message-text)) and append:

```text
; expected display name "<name>" (case-insensitive, not section handle); observed direct chapters: <observed>
```

`<observed>` lists every accepted direct chapter in source order, each quoted
as `"<coordinate>: <display name>"`, separated by `, `; when there are none it
is `none`. Named coordinates expose the handle alongside the authored display
name, so the reported example ends in `"goal: Goal and hypothesis"` rather
than implying that the accepted chapter was not scanned. Nested descendants
and rejected headings do not enter this direct-chapter list or the count.

Text output and JSON `message` carry the same complete explanation. The code,
subject-title location, authority, severity/channel, other finding fields and
exit verdict stay unchanged. The rule guide states this display-name/handle
distinction beside its presence and chapter-subject examples and explains the
single-token presence `NAME` restriction using `goal: Goal and hypothesis`.

### 7.3 Outbound citation cardinality

An ordinary hard `at least one` rule with zero matches reuses
Expand Down
10 changes: 9 additions & 1 deletion docs/user-facing/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ This keeps requirements tied to authority, because §GOAL-grounded-rules.
The title is a grammar island: formatting never inserts markers, links, or
expanded shorthand into it. The rationale remains ordinary grounded Markdown.

The shared guide below explains handle selection ([§FS-rules.2](../functional-spec/FS-rules.md#2-subject-selectors)),
display-name matching ([§FS-rules.3.1](../functional-spec/FS-rules.md#31-chapter-presence)),
comparison diagnostics ([§FS-rules.7.2.1](../functional-spec/FS-rules.md#721-display-name-comparison-context))
and whitespace guidance ([§FS-rules.3.5.1](../functional-spec/FS-rules.md#351-presence-name-whitespace-refusal)).
Its shipped skill copies use public links ([§REQ-shipped-surfaces.2](../requirements/REQ-shipped-surfaces.md#2-the-grounding-moves-it-is-not-deleted)).

<!-- BEGIN chapter-rules -->
### Chapter rules

Expand All @@ -44,6 +50,8 @@ Subjects select local units only:
- `The requirements chapter of each FS` selects that named chapter in every FS.
- `FS-login.requirements` selects one exact named chapter.

Named chapter subjects select handles: `The goal chapter of each FS` reaches `## goal: Goal and hypothesis` by `goal` ([chapter subjects](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-rules.md#2-subject-selectors)). Presence rules compare direct chapter display names case-insensitively: `Each FS must have exactly one goal chapter.` counts zero for that heading and one for `## goal: Goal` ([presence matching](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-rules.md#31-chapter-presence)). The finding appends the expected display name and observed pairs such as `"goal: Goal and hypothesis"`, or `none` ([comparison context](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-rules.md#721-display-name-comparison-context)). Presence `NAME` must be one non-empty token: `Goal and hypothesis` is refused with `NAME forbids whitespace anywhere.` appended to the existing guidance; authored display titles may still contain spaces ([whitespace refusal](https://github.com/agent-grounds/grund/blob/main/docs/functional-spec/FS-rules.md#351-presence-name-whitespace-refusal)).

Named-chapter subjects require `[id] named_sections = true`. Phase 1 has no
paths, files, folders, wildcards, subject namespaces, numbered-chapter subjects,
exceptions, definitions, derived terms, settings, or source-code symbols.
Expand Down Expand Up @@ -130,7 +138,7 @@ Common refusals are intentional and name the exact accepted rewrite:
- `Each FS must cite at least 1 GOAL.` → `numeric "at least 1" is not canonical; accepted form: Each FS must cite at least one GOAL.`
- `Each FS must cite exactly 1 GOAL.` → `numeric "exactly 1" is not canonical; accepted form: Each FS must cite exactly one GOAL.`
- `Each FS must cite at least one GOAL and must not cite any AR.` → `conjunctions are not accepted; accepted forms: "Each FS must cite at least one GOAL." and "Each FS must not cite any AR."`
- `Each FS must have exactly one chapter.` → `chapter name must be a non-empty NAME with no surrounding whitespace; accepted form: Each FS must have exactly one requirements chapter.`
- `Each FS must have exactly one chapter.` → `chapter name must be a non-empty NAME with no surrounding whitespace; accepted form: Each FS must have exactly one requirements chapter. NAME forbids whitespace anywhere.`
- `Each FS must cite at least one GOAL` → `rule must end with "."; accepted form: Each FS must cite at least one GOAL.`
- `each FS must cite at least one GOAL.` → `fixed word "Each" is case-sensitive; accepted form: Each FS must cite at least one GOAL.`
- `Each file in vendor/ must cite at least one FS.` → `path subjects are not accepted in phase 1; accepted form: Each FS must cite at least one GOAL.`
Expand Down
2 changes: 1 addition & 1 deletion examples/rules/expected.stdout
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
docs/ar/AR-overview.md:3: error: AR-overview.system-overview cites AR-one 2 times; RULE-overview requires exactly once
docs/ar/AR-overview.md:3: error: AR-overview.system-overview cites AR-one-more 0 times; RULE-overview requires exactly once
docs/fs/FS-demo.md:1: error: FS-demo has 0 security chapters; RULE-security requires exactly one
docs/fs/FS-demo.md:1: error: FS-demo has 0 security chapters; RULE-security requires exactly one; expected display name "security" (case-insensitive, not section handle); observed direct chapters: "goals: Goals", "requirements: Requirements"
docs/fs/FS-demo.md:1: error: FS-demo must cite GOAL (citation direction)
docs/fs/FS-demo.md:6: error: FS-demo.requirements cites REQ 0 times; RULE-requirements-count requires exactly 2
docs/fs/FS-demo.md:6: error: FS-demo.requirements cites REQ 0 times; RULE-requirements-floor requires at least 2
Expand Down
Loading
Loading