From 8237c16b892fbe13c73767880fe05bfa9ddbf7a7 Mon Sep 17 00:00:00 2001 From: Agent IX Date: Mon, 22 Jun 2026 22:26:38 -0700 Subject: [PATCH 01/11] feat(grammar): EARS requirement-grammar check (FR-042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a deterministic requirement-grammar framework with EARS as its first grammar, bound to archetypes via the existing (previously unconsumed) `grammar_ref` manifest hook. Findings route into the validation result by severity — advisory `warning` by default (never blocks), promotable to `error` — so EARS can ship advisory during corpus conversion and enforce afterward without moving subsystems. - src/grammar/: framework (GrammarFinding/GrammarSeverity/dispatch) + EARS grammar (6-pattern classifier; non-singular/missing-subject/vague-response/ non-canonical-trigger/unclassifiable checks; FR/NFR/StR dialects; fence/quote/table-cell-aware statement segmentation). - validate_document: ValidationReason::Grammar + severity routing; runs on the resolved archetype so a --archetype override on a typeless doc is still checked. - python: check_grammar PyO3 binding surfaces findings directly. - spec: FR-042 + AC→TC (TC-657..666), matrix to 360/360, US-014 trace. Bound only to FR Description/Behavior/Constraints, NFR Statement, StR Stakeholder Need. Passive voice is a deliberate non-goal. Also sweeps two stale `artifact_type:` frontmatter fixtures to `type:` (type-rename cleanup). Co-Authored-By: Claude Opus 4.8 --- .../FR-042-requirement-grammar-check.md | 92 +++ spec/functional/index.md | 2 + spec/tests.md | 25 +- .../US-014-author-validates-markdown.md | 2 + src/grammar/ears.rs | 524 ++++++++++++++++++ src/grammar/mod.rs | 81 +++ src/lib.rs | 2 + src/python/mod.rs | 40 ++ src/validate_document.rs | 114 +++- tests/python/test_bindings.py | 30 +- tests/python/test_speedup.py | 2 +- 11 files changed, 902 insertions(+), 12 deletions(-) create mode 100644 spec/functional/FR-042-requirement-grammar-check.md create mode 100644 src/grammar/ears.rs create mode 100644 src/grammar/mod.rs diff --git a/spec/functional/FR-042-requirement-grammar-check.md b/spec/functional/FR-042-requirement-grammar-check.md new file mode 100644 index 0000000..814aee6 --- /dev/null +++ b/spec/functional/FR-042-requirement-grammar-check.md @@ -0,0 +1,92 @@ +--- +id: FR-042 +title: "Requirement-Grammar Check (EARS)" +type: FR +relationships: + - target: "ix://agent-ix/quire-rs/spec/functional/FR-032" + type: "requires" + cardinality: "1:1" + - target: "ix://agent-ix/quire-rs/spec/functional/FR-010" + type: "requires" + cardinality: "1:1" +--- + +## Description + +`quire-rs` SHALL provide a **grammar-check framework** that evaluates the +natural-language requirement statements inside requirement-bearing artifacts +against a registered **grammar**, and SHALL ship **EARS** (Easy Approach to +Requirements Syntax) as its first grammar. Grammar checking is a posture +distinct from declarative lint ([FR-036](./FR-036-declarative-lint-rules.md)) and +structural validation ([FR-032](./FR-032-validate-document.md)): a grammar +classifies and checks the *prose of individual normative statements*, whereas +lint checks declarative content patterns and validation checks document +structure. + +The framework SHALL register each grammar against an `(archetype, section)` +binding, so a grammar runs only on the sections it governs. When a +requirement-bearing document is validated, the framework SHALL segment each +governed section into **normative statements** — a sentence, list item, or +table cell bearing a modal verb (`shall`/`shall not`) — and SHALL skip text +inside fenced code blocks, blockquotes, and reference lines. + +The framework SHALL route every grammar finding into the validation result by +**severity**: when a finding's severity is `warning`, the framework SHALL record +it in `ValidationResult.warnings` without setting `is_valid` false; when the +severity is `error`, the framework SHALL record it in `ValidationResult.errors` +and SHALL fail validation. Severity SHALL be sourced from configuration so a +deployment can promote a grammar from advisory to enforcing without a code +change. + +### EARS grammar + +The EARS grammar SHALL classify each normative statement into exactly one +pattern — `ubiquitous`, `event` (`When …`), `state` (`While …`), `unwanted` +(`If … then …`), `optional` (`Where …`), or `complex` (a combination) — or SHALL +mark it `unclassifiable` when no pattern matches. + +For each statement, the EARS grammar SHALL emit a finding when the statement +violates a clause rule: + +1. **non-singular** — the statement contains more than one `shall`. +2. **missing-subject** — the statement names no system/actor subject. +3. **vague-response** — the response verb is vague (`support`, `handle`, + `manage`, `process`, `deal with`, `provide`, `enable`, `be able to`). +4. **non-canonical-trigger** — the statement leads with a non-EARS trigger + (`On …`, `Upon`, `After`, `Before`, `Once`, `During`) instead of `When`/ + `While`. + +The EARS grammar SHALL apply per-archetype dialects: an enumerated +`The SHALL:` stem followed by a numbered response list SHALL count as a +single statement (not non-singular); a `StR` statement SHALL accept a stakeholder +or product subject (not only "the system"); and an `NFR` statement absent any +trigger SHALL NOT be reported as a defect. The EARS grammar SHALL NOT flag +passive voice. + +### Non-goals (v1) + +- The framework SHALL NOT judge whether the author chose the *semantically* + correct keyword (e.g. `When` used for a continuous `While` state); that + judgment is left to the agent review lens. +- No grammar other than EARS ships in v1; `GWT` (acceptance criteria) and the + `US` story grammar register onto the same framework later. + +## Acceptance Criteria + +| ID | Criteria | Verification | +|----|----------|--------------| +| FR-042-AC-1 | Each of the six EARS patterns (`ubiquitous`, `event`, `state`, `unwanted`, `optional`, `complex`) is classified from a representative statement, and a statement matching none is reported `unclassifiable`. | Test | +| FR-042-AC-2 | A statement with two `shall` clauses yields exactly one `non-singular` finding; an enumerated `The X SHALL:` stem followed by a numbered list yields none. | Test | +| FR-042-AC-3 | A statement with no system subject yields a `missing-subject` finding; a `StR` statement with a stakeholder subject (`The operator …`) does not. | Test | +| FR-042-AC-4 | A statement using a vague response verb (`shall support`) yields a `vague-response` finding; a passive-voice statement (`shall be included`) yields none. | Test | +| FR-042-AC-5 | A statement leading with `On startup, … shall …` yields a `non-canonical-trigger` finding; an `NFR` statement with no trigger yields none. | Test | +| FR-042-AC-6 | A grammar runs only against its bound `(archetype, section)` pairs: an EARS rule bound to FR `Description` produces no findings against an FR `Dependencies` section or an `IT` document. | Test | +| FR-042-AC-7 | A `warning`-severity finding is recorded in `ValidationResult.warnings` and leaves `is_valid` true; promoting the same finding to `error` records it in `errors` and sets `is_valid` false. | Test | +| FR-042-AC-8 | Each finding carries the offending statement excerpt, a 1-based line number, the matched pattern, and a severity. | Test | +| FR-042-AC-9 | Statements inside fenced code blocks, blockquotes, and reference lines are not segmented as normative statements and yield no findings. | Test | +| FR-042-AC-10 | The framework entry point is exposed through the Python (PyO3) binding and returns the same findings as the in-process Rust call for a fixture document. | Test | + +## Dependencies + +- **Upstream**: [FR-032](./FR-032-validate-document.md) (requires — findings merge into `ValidationResult`), [FR-010](./FR-010-query-api.md) (requires — section/table/list extraction) +- **Downstream**: the authoring (`/specify`), coverage (`/spec-matrix`), and review (`/spec-review`) workflows consume grammar findings; the `US-014` author-validates-markdown story is extended by this gate. diff --git a/spec/functional/index.md b/spec/functional/index.md index d11c26b..45a9665 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -40,3 +40,5 @@ description: "Index of artifacts in this directory." * [FR-038: OKF Bundle Validation: Strict vs Okf Postures + Index Completeness](./FR-038-okf-bundle-validation.md) * [FR-039: Unlinked Reference Detection and Autofix Suggestions](./FR-039-unlinked-reference-detection.md) * [FR-040: Object-Axis Typed Edge Vocabulary and Cross-Domain Targets](./FR-040-object-edge-vocabulary.md) +* [FR-041: Authorable Inverse Edge Verbs](./FR-041-authorable-inverse-edges.md) +* [FR-042: Requirement-Grammar Check (EARS)](./FR-042-requirement-grammar-check.md) diff --git a/spec/tests.md b/spec/tests.md index 885de49..0aaa778 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -98,6 +98,7 @@ The spec was revised after authoring to reflect the **archetype-as-data** model: | FR-039 Unlinked reference detection (ADR 0007) | AC-1..10 | TC-623 (auto-fix bare id), TC-624 (sub-id→parent file), TC-625 (inline-code conversion), TC-626 (fenced/frontmatter ignored), TC-627 (already-linked + idempotence), TC-628 (self-reference), TC-629 (unresolved→warn-only), TC-630 (ambiguous→warn-only), TC-631 (sorted determinism), TC-632 (multi-token code span skipped) | 🚧 Pending implementation | | FR-040 Object-axis typed edge vocabulary + cross-domain targets | AC-1..11 | TC-636 (registries load + idempotent merge), TC-637 (conflict→first-wins diagnostic), TC-650 (unknown verb/role diagnostic + strict escalation), TC-638 (array→{v:["*"]} + map round-trip), TC-651 (roles parsed onto archetype), TC-639 (resolve union), TC-640 (target_satisfies name/role/"*"), TC-641 (DisallowedEdgeType warn + unknown-object fallback), TC-642 (DisallowedEdgeTarget warn + role match cross-module + skips), TC-643 (warn-only, non-blocking), TC-644 (sorted determinism), TC-645 (skeleton Relationships block) | ✅ Implemented (engine) | | FR-041 Authorable inverse edge verbs | AC-1..5 | TC-652 (inverse index), TC-653 (Tier-1 recognition), TC-654 (precedence + DuplicateInverseEdge), TC-655 (Tier-2 forward normalization), TC-656 (warn-only determinism) | 🚧 Pending implementation | +| FR-042 Requirement-grammar check (EARS) | AC-1..10 | TC-657 (6 patterns + unclassifiable), TC-658 (non-singular + enumerated-stem exemption), TC-659 (missing-subject + StR relaxed subject), TC-660 (vague-response + passive exemption), TC-661 (non-canonical-trigger + NFR no-trigger), TC-662 (archetype/section binding), TC-663 (warning vs error severity routing), TC-664 (finding fields), TC-665 (fence/quote/reference skip), TC-666 (PyO3 parity) | 🚧 Pending implementation | ### Non-Functional Requirement Coverage @@ -339,6 +340,16 @@ The spec was revised after authoring to reflect the **archetype-as-data** model: | TC-654 | A label that is both a forward `edge_types` key and an inverse of another verb resolves to the forward registration; two forward verbs declaring the same inverse label are first-wins and emit a non-fatal `DuplicateInverseEdge` (default load succeeds) | Unit | P0 | FR-041-AC-3 | 🚧 | | TC-655 | A corpus inverse-verb edge `(source, I, target)` is normalized to `(target, F, source)` before `target_satisfies`; a forward-valid target passes, a forward-direction mismatch yields one `DisallowedEdgeTarget` reported with the authored inverse source/target/edge_type | Integration | P0 | FR-041-AC-4 | 🚧 | | TC-656 | Inverse recognition and normalization are warnings only — they never block extraction or FR-032 structural validation; the inverse index and diagnostics are deterministic across runs and thread counts | Property | P0 | FR-041-AC-5, NFR-006 | 🚧 | +| TC-657 | Each EARS pattern (`ubiquitous`, `event`, `state`, `unwanted`, `optional`, `complex`) classifies from a representative statement; a statement matching no pattern is reported `unclassifiable` | Unit | P0 | FR-042-AC-1 | 🚧 | +| TC-658 | A statement with two `shall` clauses yields exactly one `non-singular` finding; an enumerated `The X SHALL:` stem followed by a numbered list yields none | Unit | P0 | FR-042-AC-2 | 🚧 | +| TC-659 | A statement with no system subject yields a `missing-subject` finding; a `StR` statement with a stakeholder subject (`The operator …`) yields none | Unit | P0 | FR-042-AC-3 | 🚧 | +| TC-660 | A statement using a vague response verb (`shall support`) yields a `vague-response` finding; a passive-voice statement (`shall be included`) yields none | Unit | P0 | FR-042-AC-4 | 🚧 | +| TC-661 | A statement leading with `On startup, … shall …` yields a `non-canonical-trigger` finding; an `NFR` statement with no trigger yields none | Unit | P0 | FR-042-AC-5 | 🚧 | +| TC-662 | A grammar runs only against its bound `(archetype, section)` pairs: an EARS rule bound to FR `Description` yields no findings against an FR `Dependencies` section or an `IT` document | Unit | P0 | FR-042-AC-6 | 🚧 | +| TC-663 | A `warning`-severity finding is recorded in `ValidationResult.warnings` and leaves `is_valid` true; the same finding promoted to `error` is recorded in `errors` and sets `is_valid` false | Unit | P0 | FR-042-AC-7, FR-032 | 🚧 | +| TC-664 | Each grammar finding carries the offending statement excerpt, a 1-based line number, the matched pattern label, and a severity | Unit | P0 | FR-042-AC-8 | 🚧 | +| TC-665 | Modal verbs inside fenced code blocks, blockquotes, and reference lines are not segmented as normative statements and yield no findings | Unit | P0 | FR-042-AC-9 | 🚧 | +| TC-666 | The grammar-check entry point exposed via PyO3 returns the same findings as the in-process Rust call for a fixture document | Integration | P0 | FR-042-AC-10, FR-023 | 🚧 | | TC-502 | Static audit: no Mutex/RwLock/Atomic in first-party src/; parallel parse collects owned results | Static | P0 | FR-024-AC-9 | 🚧 | | TC-503 | loom: parallel parse collection race-free; identical path-sorted output across all interleavings | Property | P0 | NFR-017-AC-1..3 | 🚧 | | TC-504 | TSAN lane: two-thread `load_repo` (GIL-release window) reports zero data races | Integration | P0 | NFR-018-AC-1, NFR-018-AC-3 | 🚧 | @@ -855,6 +866,16 @@ Comprehensive, post-audit explicit mapping. Every AC defined in the spec is list | FR-041-AC-3 | TC-654 | | FR-041-AC-4 | TC-655 | | FR-041-AC-5 | TC-656 | +| FR-042-AC-1 | TC-657 | +| FR-042-AC-2 | TC-658 | +| FR-042-AC-3 | TC-659 | +| FR-042-AC-4 | TC-660 | +| FR-042-AC-5 | TC-661 | +| FR-042-AC-6 | TC-662 | +| FR-042-AC-7 | TC-663 | +| FR-042-AC-8 | TC-664 | +| FR-042-AC-9 | TC-665 | +| FR-042-AC-10 | TC-666 | ### Non-Functional Requirements @@ -920,7 +941,7 @@ Comprehensive, post-audit explicit mapping. Every AC defined in the spec is list | NFR-019-AC-1 | TC-579 | | NFR-019-AC-2 | TC-580 | -**Coverage status: 350 / 350 ACs covered (100%).** The authorable-inverse-edges slice (FR-041, ADR 0008, 2026-06-21) adds FR-041-AC-1..5 (declared `inverse:` labels become authorable as derived views of their forward edge: inverse index, Tier-1 recognition, precedence/`DuplicateInverseEdge`, Tier-2 forward normalization, warn-only determinism, TC-652..656) — 5 ACs. The object-edge-vocabulary slice (FR-040, 2026-06-20) adds FR-040-AC-1..11 (object-axis typed edge vocabulary + cross-domain role-typed targets: mergeable `edge_types`/`roles` registries with first-wins+diagnostic merge, object `roles` parsed onto the archetype, array|map `allowed_links`, union resolution, warn-tier Tier-1/Tier-2 validation, composed skeleton, TC-636..645 + TC-650/651) and US-015-AC-1..4 (author declares an object's relationship vocabulary, TC-646..649) — 15 ACs. The per-value assert slice (CR-010, 2026-06-20) adds FR-033-AC-11..13 (`choices` scalar enum + `column_choices`/`column_patterns` per-column table validation, TC-633..635) — 3 ACs. The internal-links slice (ADR 0007, 2026-06-17) adds FR-026-AC-9..11 (relative-path link edge source + index/log exclusion + dedup parity, TC-620..622) and FR-039-AC-1..10 (unlinked-reference detection & autofix suggestions, incl. AC-10 multi-token code-span skip, TC-623..632) — 13 ACs. The composed type+object validation slice (2026-06-16) adds FR-032-AC-11..13 (`validate_document_in_registry` composes the `type` archetype with the frontmatter `object:` archetype; resolved-object failures are errors, unknown-object is a warning, `ValidationResult` carries typed `warnings`) — TC-610..613, 3 ACs. The assert/lint extension slice (2026-06-16) adds FR-033-AC-10 (CR-008 `matches` content assert, TC-608) and FR-036-AC-6 (CR-009 `section_body_pattern` lint rule, TC-609) — 2 ACs. The OKF slice (2026-06-16) adds FR-037-AC-1..6 (base concept frontmatter schema, TC-590..596 + TC-528) and FR-038-AC-1..8 (OKF bundle validation, TC-600..607) — 14 ACs. v0.4 adds FR-011-AC-21 (CR-006 `multiple: true`, TC-583) and FR-036-AC-1..5 (declarative lint rules, TC-584..588). v0.2 block model added 16 ACs (FR-019..022, TC-400..443). v0.3 adds 81 ACs — StR-005/006, US-011..013, FR-023..027 (incl. review-added FR-026-AC-8, FR-027-AC-9), NFR-015/016, plus the hardening re-review (NFR-003-AC-4, FR-024-AC-9, NFR-017, NFR-018) — covered by TC-455..507 (plus reused TC-456..459). The Miri ACs (NFR-012-AC-1..5) were **retired** (ADR 0006) and the compile-time **NFR-003-AC-5** (`forbid(unsafe_code)`, TC-582) added. PC (performance criteria) for US-011..013 are tracked as benches (TC-455..459, TC-469) and marked 🚧 pending implementation, consistent with the US-006..010 perf-bench convention. The v0.3 hardening re-review (loom NFR-017, TSAN/ASAN NFR-018) is recorded in spec.md §19. +**Coverage status: 360 / 360 ACs covered (100%).** The requirement-grammar slice (FR-042, EARS, 2026-06-22) adds FR-042-AC-1..10 (grammar-check framework with EARS as the first grammar: six-pattern classification, the non-singular/missing-subject/vague-response/non-canonical-trigger clause checks with per-archetype dialects, warning→error severity routing into `ValidationResult`, fenced/quote/reference skip, and PyO3 parity — TC-657..666) — 10 ACs. The authorable-inverse-edges slice (FR-041, ADR 0008, 2026-06-21) adds FR-041-AC-1..5 (declared `inverse:` labels become authorable as derived views of their forward edge: inverse index, Tier-1 recognition, precedence/`DuplicateInverseEdge`, Tier-2 forward normalization, warn-only determinism, TC-652..656) — 5 ACs. The object-edge-vocabulary slice (FR-040, 2026-06-20) adds FR-040-AC-1..11 (object-axis typed edge vocabulary + cross-domain role-typed targets: mergeable `edge_types`/`roles` registries with first-wins+diagnostic merge, object `roles` parsed onto the archetype, array|map `allowed_links`, union resolution, warn-tier Tier-1/Tier-2 validation, composed skeleton, TC-636..645 + TC-650/651) and US-015-AC-1..4 (author declares an object's relationship vocabulary, TC-646..649) — 15 ACs. The per-value assert slice (CR-010, 2026-06-20) adds FR-033-AC-11..13 (`choices` scalar enum + `column_choices`/`column_patterns` per-column table validation, TC-633..635) — 3 ACs. The internal-links slice (ADR 0007, 2026-06-17) adds FR-026-AC-9..11 (relative-path link edge source + index/log exclusion + dedup parity, TC-620..622) and FR-039-AC-1..10 (unlinked-reference detection & autofix suggestions, incl. AC-10 multi-token code-span skip, TC-623..632) — 13 ACs. The composed type+object validation slice (2026-06-16) adds FR-032-AC-11..13 (`validate_document_in_registry` composes the `type` archetype with the frontmatter `object:` archetype; resolved-object failures are errors, unknown-object is a warning, `ValidationResult` carries typed `warnings`) — TC-610..613, 3 ACs. The assert/lint extension slice (2026-06-16) adds FR-033-AC-10 (CR-008 `matches` content assert, TC-608) and FR-036-AC-6 (CR-009 `section_body_pattern` lint rule, TC-609) — 2 ACs. The OKF slice (2026-06-16) adds FR-037-AC-1..6 (base concept frontmatter schema, TC-590..596 + TC-528) and FR-038-AC-1..8 (OKF bundle validation, TC-600..607) — 14 ACs. v0.4 adds FR-011-AC-21 (CR-006 `multiple: true`, TC-583) and FR-036-AC-1..5 (declarative lint rules, TC-584..588). v0.2 block model added 16 ACs (FR-019..022, TC-400..443). v0.3 adds 81 ACs — StR-005/006, US-011..013, FR-023..027 (incl. review-added FR-026-AC-8, FR-027-AC-9), NFR-015/016, plus the hardening re-review (NFR-003-AC-4, FR-024-AC-9, NFR-017, NFR-018) — covered by TC-455..507 (plus reused TC-456..459). The Miri ACs (NFR-012-AC-1..5) were **retired** (ADR 0006) and the compile-time **NFR-003-AC-5** (`forbid(unsafe_code)`, TC-582) added. PC (performance criteria) for US-011..013 are tracked as benches (TC-455..459, TC-469) and marked 🚧 pending implementation, consistent with the US-006..010 perf-bench convention. The v0.3 hardening re-review (loom NFR-017, TSAN/ASAN NFR-018) is recorded in spec.md §19. **v0.4 markdown-validation slice** adds 42 ACs — US-014 (author validates markdown), FR-029 (archetype input contract, recast by ADR 0004), FR-030 (required-section validation, superseded by FR-032/FR-033), FR-031 (unified archetype shape), FR-032 (`validate_document`), FR-033 (locator `assert` facet), FR-034 (assert field interpolation), FR-035 (per-level heading uniqueness) — covered by TC-518..553. FR-030's ACs are mapped to the FR-032/FR-033 TCs that subsume them (per its CR note). This slice also back-fills 7 ACs that a prior commit left out of the audit table — FR-013-AC-11..14, FR-028-AC-9/10, US-003-AC-4 — via TC-554..560. New v0.4 TCs are 🚧 pending implementation. @@ -938,7 +959,7 @@ NFR-006-AC-4, NFR-019-AC-1..2 — covered by TC-565..580. TC-561 is re-pointed o FR-033-AC-4 onto FR-033-AC-9 (the non-table `id_pattern` case); TC-562 covers both FR-033-AC-4 and FR-033-AC-9. -**Integrity check (grep-verified):** all **350 distinct file-defined ACs** (definition-anchored: bold `**-AC-N**` declarations) across `stakeholder/ usecase/ functional/ non-functional/` appear in the AC→TC audit table — **0 uncovered**. Note: `FR-900-AC-1/2` appearing inside FR-034-AC-1's example prose are NOT defined ACs and are excluded from the denominator (match `**…**:` definitions, not inline mentions). Retired ACs (marked `(RETIRED)`, un-bolded) are excluded by construction. Count: 316 (pre-removal) − 41 (retired) + 16 (back-fill) + 1 (FR-011-AC-20, CR-005 heading normalization) − 5 (NFR-012-AC-1..5 retired, ADR 0006) + 1 (NFR-003-AC-5, forbid(unsafe_code)) + 1 (FR-011-AC-21, CR-006 multiple:true) + 5 (FR-036-AC-1..5, declarative lint rules) + 1 (FR-010-AC-4, CR-007 escaped pipes) + 6 (FR-037-AC-1..6, OKF base concept schema) + 8 (FR-038-AC-1..8, OKF bundle validation) + 1 (FR-033-AC-10, CR-008 `matches` content assert) + 1 (FR-036-AC-6, CR-009 `section_body_pattern`) + 3 (FR-032-AC-11..13, composed type+object validation) + 3 (FR-026-AC-9..11, relative-path link edge source) + 10 (FR-039-AC-1..10, unlinked-reference detection incl. multi-token code-span skip) + 3 (FR-033-AC-11..13, CR-010 per-value enum/regex asserts) + 11 (FR-040-AC-1..11, object-axis typed edge vocabulary + cross-domain targets) + 4 (US-015-AC-1..4, author declares object relationship vocabulary) + 5 (FR-041-AC-1..5, authorable inverse edge verbs) = **350**. +**Integrity check (grep-verified):** all **360 distinct file-defined ACs** (definition-anchored: bold `**-AC-N**` declarations) across `stakeholder/ usecase/ functional/ non-functional/` appear in the AC→TC audit table — **0 uncovered**. Note: `FR-900-AC-1/2` appearing inside FR-034-AC-1's example prose are NOT defined ACs and are excluded from the denominator (match `**…**:` definitions, not inline mentions). Retired ACs (marked `(RETIRED)`, un-bolded) are excluded by construction. Count: 316 (pre-removal) − 41 (retired) + 16 (back-fill) + 1 (FR-011-AC-20, CR-005 heading normalization) − 5 (NFR-012-AC-1..5 retired, ADR 0006) + 1 (NFR-003-AC-5, forbid(unsafe_code)) + 1 (FR-011-AC-21, CR-006 multiple:true) + 5 (FR-036-AC-1..5, declarative lint rules) + 1 (FR-010-AC-4, CR-007 escaped pipes) + 6 (FR-037-AC-1..6, OKF base concept schema) + 8 (FR-038-AC-1..8, OKF bundle validation) + 1 (FR-033-AC-10, CR-008 `matches` content assert) + 1 (FR-036-AC-6, CR-009 `section_body_pattern`) + 3 (FR-032-AC-11..13, composed type+object validation) + 3 (FR-026-AC-9..11, relative-path link edge source) + 10 (FR-039-AC-1..10, unlinked-reference detection incl. multi-token code-span skip) + 3 (FR-033-AC-11..13, CR-010 per-value enum/regex asserts) + 11 (FR-040-AC-1..11, object-axis typed edge vocabulary + cross-domain targets) + 4 (US-015-AC-1..4, author declares object relationship vocabulary) + 5 (FR-041-AC-1..5, authorable inverse edge verbs) + 10 (FR-042-AC-1..10, requirement-grammar check (EARS)) = **360**. --- diff --git a/spec/usecase/US-014-author-validates-markdown.md b/spec/usecase/US-014-author-validates-markdown.md index 4b7fd58..abc9d6d 100644 --- a/spec/usecase/US-014-author-validates-markdown.md +++ b/spec/usecase/US-014-author-validates-markdown.md @@ -16,6 +16,8 @@ relationships: type: "exercises" - target: "ix://agent-ix/quire-rs/spec/functional/FR-035" type: "exercises" + - target: "ix://agent-ix/quire-rs/spec/functional/FR-042" + type: "exercises" --- ## Story diff --git a/src/grammar/ears.rs b/src/grammar/ears.rs new file mode 100644 index 0000000..b735d1a --- /dev/null +++ b/src/grammar/ears.rs @@ -0,0 +1,524 @@ +//! The EARS grammar (FR-042) — first grammar in the `iso-spec-core` bundle. +//! +//! EARS (Easy Approach to Requirements Syntax) classifies each normative +//! statement into one of six patterns and checks the clause ruleset. It runs on +//! the requirement-bearing sections of FR / NFR / StR (the `(archetype, +//! section)` binding below); all other archetypes and sections are ignored. +//! +//! Dialects (FR-042): an enumerated `The X SHALL:` stem followed by a numbered +//! response list is one statement (the list items carry no `shall`, so +//! segmentation never inflates the count); a `StR` statement accepts a +//! stakeholder subject; an `NFR` statement is not pushed toward triggers +//! (absence of a trigger is never a defect — no check penalises it). + +use std::sync::OnceLock; + +use regex::Regex; + +use super::{GrammarFinding, GrammarSeverity}; +use crate::ast::QuireSection; +use crate::query; + +/// The six EARS patterns plus the `Unclassifiable` catch-all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EarsPattern { + Ubiquitous, + Event, + State, + Unwanted, + Optional, + Complex, + Unclassifiable, +} + +impl EarsPattern { + /// Stable machine-readable label. + pub fn as_str(self) -> &'static str { + match self { + Self::Ubiquitous => "ubiquitous", + Self::Event => "event", + Self::State => "state", + Self::Unwanted => "unwanted", + Self::Optional => "optional", + Self::Complex => "complex", + Self::Unclassifiable => "unclassifiable", + } + } +} + +/// v1 default severity: advisory. Promotion to `Error` is a config/policy lever +/// (FR-042); the engine ships warning-only. +const DEFAULT_SEVERITY: GrammarSeverity = GrammarSeverity::Warning; + +/// The `(archetype, section)` binding: which sections of each requirement- +/// bearing archetype carry normative statements. The bool marks a table-valued +/// section whose statements are the cells of the named column. +fn bound_sections(archetype: &str) -> &'static [(&'static str, Option<&'static str>)] { + match archetype { + "FR" => &[ + ("Description", None), + ("Behavior", None), + ("Constraints", Some("Constraint")), + ], + "NFR" => &[("Statement", None)], + "StR" => &[("Stakeholder Need", None)], + _ => &[], + } +} + +/// A normative statement extracted from a section, with its 1-based document +/// line. +struct Stmt { + text: String, + line: Option, +} + +/// Check `doc` (an `archetype` document) against the EARS grammar, returning one +/// finding per clause violation and per unclassifiable statement. +pub fn check( + archetype: &str, + doc: &crate::ast::QuireDocument, + line_offset: usize, +) -> Vec { + let mut findings = Vec::new(); + for (heading, table_column) in bound_sections(archetype) { + let Some(section) = query::section(doc, heading) else { + continue; + }; + let stmts = match table_column { + Some(col) => table_statements(section, line_offset, col), + None => prose_statements(section, line_offset), + }; + for stmt in stmts { + check_statement(archetype, &stmt, &mut findings); + } + } + findings +} + +fn check_statement(archetype: &str, stmt: &Stmt, out: &mut Vec) { + let pattern = classify(&stmt.text); + let label = Some(pattern.as_str().to_string()); + + let push = |out: &mut Vec, check: &str, message: String| { + out.push(GrammarFinding { + grammar: "ears".to_string(), + check: check.to_string(), + pattern: label.clone(), + message, + line: stmt.line, + statement: excerpt(&stmt.text), + severity: DEFAULT_SEVERITY, + }); + }; + + if pattern == EarsPattern::Unclassifiable { + push( + out, + "unclassifiable", + "statement does not match any EARS pattern (ubiquitous / event / state / unwanted / optional / complex)".to_string(), + ); + } + if shall_count(&stmt.text) > 1 { + push( + out, + "non-singular", + "statement contains more than one `shall`; split into one requirement per `shall`" + .to_string(), + ); + } + if has_missing_subject(&stmt.text) { + push( + out, + "missing-subject", + "statement names no system/actor subject before `shall`".to_string(), + ); + } + if let Some(verb) = vague_verb(&stmt.text) { + push( + out, + "vague-response", + format!("vague response verb `{verb}`; name a concrete, verifiable response"), + ); + } + // NFR dialect: absence of a trigger is never a defect, and no check + // penalises it. The non-canonical-trigger check only fires on a *present* + // non-EARS trigger, so it applies uniformly. + let _ = archetype; + if let Some(trigger) = non_canonical_trigger(&stmt.text) { + push( + out, + "non-canonical-trigger", + format!("non-EARS trigger `{trigger}`; use the canonical `When …` (event) or `While …` (state)"), + ); + } +} + +// ─── Classification ───────────────────────────────────────────────────────── + +/// Classify a single normative statement into an [`EarsPattern`]. +pub fn classify(statement: &str) -> EarsPattern { + let norm = normalize(statement); + let lower = norm.to_lowercase(); + if !contains_shall(&lower) { + return EarsPattern::Unclassifiable; + } + + // Complex: two or more distinct trigger keywords present. Token-based + // membership (no per-call regex compile — this runs once per statement). + let words: Vec<&str> = lower + .split(|c: char| !c.is_ascii_alphabetic()) + .filter(|w| !w.is_empty()) + .collect(); + let triggers = ["when", "while", "where", "if"] + .iter() + .filter(|kw| words.contains(kw)) + .count(); + if triggers >= 2 { + return EarsPattern::Complex; + } + + let first = first_word(&lower); + match first.as_str() { + "when" => EarsPattern::Event, + "while" => EarsPattern::State, + "where" => EarsPattern::Optional, + "if" => EarsPattern::Unwanted, + _ => { + // No leading trigger: ubiquitous when there is a subject before + // `shall`, otherwise unclassifiable. + if has_missing_subject(statement) { + EarsPattern::Unclassifiable + } else { + EarsPattern::Ubiquitous + } + } + } +} + +// ─── Clause checks ────────────────────────────────────────────────────────── + +fn shall_count(statement: &str) -> usize { + re_shall().find_iter(&statement.to_lowercase()).count() +} + +/// True when the statement names no subject before `shall` in its main clause. +/// The leading trigger clause (up to the first comma) is stripped first, and an +/// `if … then …` lead-in is reduced to its main clause, so `When X, the system +/// shall …` and `If X, then the system shall …` both keep `the system` as the +/// subject. +fn has_missing_subject(statement: &str) -> bool { + let norm = normalize(statement); + let lower = norm.to_lowercase(); + let main = main_clause(&lower); + let Some(idx) = main.find("shall") else { + return false; // no shall in main clause — not this check's concern + }; + let before = main[..idx].trim(); + let before = before.strip_prefix("then").unwrap_or(before).trim(); + before.is_empty() +} + +fn vague_verb(statement: &str) -> Option { + let lower = normalize(statement).to_lowercase(); + re_vague().captures(&lower).map(|c| { + c.get(2) + .map(|m| m.as_str().trim().to_string()) + .unwrap_or_default() + }) +} + +fn non_canonical_trigger(statement: &str) -> Option { + let lower = normalize(statement).to_lowercase(); + if !contains_shall(&lower) { + return None; + } + let first = first_word(&lower); + matches!( + first.as_str(), + "on" | "upon" | "after" | "before" | "once" | "during" + ) + .then_some(first) +} + +// ─── Statement extraction ─────────────────────────────────────────────────── + +/// Segment a prose section into normative statements. v1 is line-based: each +/// kept line is split on sentence boundaries and fragments bearing a modal verb +/// become statements. A statement wrapped across multiple source lines is +/// therefore segmented at its first line — this can *under*-count (a second +/// `shall` or vague verb on a continuation line is missed), never over-flag a +/// continuation line (those carry no `shall`). Conservative for an advisory +/// check; paragraph-joining is a planned refinement. +fn prose_statements(section: &QuireSection, line_offset: usize) -> Vec { + let mut out = Vec::new(); + let mut in_fence = false; + for (rel, raw_line) in section.content.lines().enumerate() { + let trimmed = raw_line.trim_start(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + in_fence = !in_fence; + continue; + } + if in_fence || trimmed.starts_with('>') || trimmed.starts_with('#') { + continue; + } + let line = strip_list_marker(trimmed); + for fragment in line.split(". ") { + if contains_shall(&fragment.to_lowercase()) { + out.push(Stmt { + text: fragment.trim().to_string(), + line: Some(abs_line(section, rel, line_offset)), + }); + } + } + } + out +} + +fn table_statements(section: &QuireSection, line_offset: usize, column: &str) -> Vec { + let Some(table) = query::parse_table(§ion.content) else { + return Vec::new(); + }; + let Some(col_idx) = table + .headers + .iter() + .position(|h| h.trim().eq_ignore_ascii_case(column)) + else { + return Vec::new(); + }; + table + .rows + .iter() + .filter_map(|row| row.get(col_idx)) + .filter(|cell| contains_shall(&cell.to_lowercase())) + .map(|cell| Stmt { + text: cell.trim().to_string(), + line: locate_line(section, cell, line_offset), + }) + .collect() +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/// Strip markdown decoration that would confuse subject/trigger detection: +/// links `[t](u)` → `t`, bold `**`, and inline code backticks. +fn normalize(s: &str) -> String { + let unlinked = re_link().replace_all(s, "$1"); + unlinked.replace("**", "").replace('`', "") +} + +fn excerpt(s: &str) -> String { + let s = s.trim(); + if s.chars().count() > 160 { + let truncated: String = s.chars().take(157).collect(); + format!("{truncated}…") + } else { + s.to_string() + } +} + +fn strip_list_marker(line: &str) -> &str { + let t = line.trim_start(); + if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) { + return rest; + } + // numbered list: "1. ", "12. " + if let Some(pos) = t.find(". ") { + if t[..pos].chars().all(|c| c.is_ascii_digit()) && !t[..pos].is_empty() { + return &t[pos + 2..]; + } + } + t +} + +/// The main clause: everything after the first comma when the statement leads +/// with a trigger keyword, else the whole statement. +fn main_clause(lower: &str) -> &str { + let first = first_word(lower); + if matches!(first.as_str(), "when" | "while" | "where" | "if") { + if let Some(idx) = lower.find(", ") { + return lower[idx + 2..].trim_start(); + } + } + lower +} + +fn first_word(lower: &str) -> String { + lower + .split_whitespace() + .next() + .unwrap_or("") + .trim_matches(|c: char| !c.is_ascii_alphabetic()) + .to_string() +} + +fn contains_shall(lower: &str) -> bool { + re_shall().is_match(lower) +} + +fn abs_line(section: &QuireSection, rel: usize, line_offset: usize) -> usize { + line_offset + section.start_line + rel + 1 +} + +/// Find the document line of a table cell by locating its text within the +/// section content; falls back to the section heading line. +fn locate_line(section: &QuireSection, cell: &str, line_offset: usize) -> Option { + let needle = cell.trim(); + for (rel, raw_line) in section.content.lines().enumerate() { + if !needle.is_empty() && raw_line.contains(needle) { + return Some(abs_line(section, rel, line_offset)); + } + } + Some(abs_line(section, 0, line_offset)) +} + +// ─── Compile-once regexes ─────────────────────────────────────────────────── + +fn re_shall() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| Regex::new(r"(?i)\bshall\b").expect("shall regex")) +} + +fn re_link() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| Regex::new(r"\[([^\]]*)\]\([^)]*\)").expect("link regex")) +} + +fn re_vague() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + Regex::new(r"(?i)\bshall\s+(not\s+)?(support|handle|manage|process|deal\s+with|provide|enable|be\s+able\s+to)\b") + .expect("vague regex") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc(text: &str) -> crate::ast::QuireDocument { + crate::parse_document(text) + } + + // TC-657 (FR-042-AC-1): every pattern classifies; no-match → unclassifiable. + #[test] + fn tc657_classification() { + assert_eq!( + classify("The system shall emit a structured log."), + EarsPattern::Ubiquitous + ); + assert_eq!( + classify("When the token expires, the system shall refresh it."), + EarsPattern::Event + ); + assert_eq!( + classify("While under load, the system shall shed traffic."), + EarsPattern::State + ); + assert_eq!( + classify("If the request is unauthenticated, then the system shall reject it."), + EarsPattern::Unwanted + ); + assert_eq!( + classify("Where billing is enabled, the system shall meter usage."), + EarsPattern::Optional + ); + assert_eq!( + classify("While idle, when a job arrives, the system shall start it."), + EarsPattern::Complex + ); + assert_eq!( + classify("This paragraph has no normative verb."), + EarsPattern::Unclassifiable + ); + } + + // TC-658 (FR-042-AC-2): non-singular fires on 2 shalls; enumerated stem doesn't. + #[test] + fn tc658_non_singular() { + let d = doc("---\ntype: FR\n---\n## Description\n\nThe service shall mint a token and shall carry it downstream.\n"); + let f = check("FR", &d, 0); + assert_eq!(f.iter().filter(|x| x.check == "non-singular").count(), 1); + + let enumerated = doc("---\ntype: FR\n---\n## Description\n\nThe function shall:\n\n1. Validate the input.\n2. Render the output.\n3. Return the result.\n"); + let f2 = check("FR", &enumerated, 0); + assert_eq!(f2.iter().filter(|x| x.check == "non-singular").count(), 0); + } + + // TC-659 (FR-042-AC-3): missing-subject fires; StR stakeholder subject doesn't. + #[test] + fn tc659_missing_subject() { + assert!(has_missing_subject("shall be created lazily on first use")); + assert!(!has_missing_subject( + "The operator shall be able to resync a repository" + )); + let str_doc = doc("---\ntype: StR\n---\n## Stakeholder Need\n\nThe operator shall reconcile a repository on demand.\n"); + let f = check("StR", &str_doc, 0); + assert_eq!(f.iter().filter(|x| x.check == "missing-subject").count(), 0); + } + + // TC-660 (FR-042-AC-4): vague-response fires; passive does not. + #[test] + fn tc660_vague_and_passive() { + let vague = doc("---\ntype: FR\n---\n## Description\n\nThe system shall support publishing to registries.\n"); + let f = check("FR", &vague, 0); + assert_eq!(f.iter().filter(|x| x.check == "vague-response").count(), 1); + + let passive = doc("---\ntype: FR\n---\n## Description\n\nMessages shall be included from newest to oldest.\n"); + let f2 = check("FR", &passive, 0); + assert_eq!(f2.iter().filter(|x| x.check == "vague-response").count(), 0); + } + + // TC-661 (FR-042-AC-5): non-canonical trigger fires; NFR no-trigger does not. + #[test] + fn tc661_non_canonical_trigger() { + let latent = doc("---\ntype: FR\n---\n## Description\n\nOn startup, the service shall perform a full scan.\n"); + let f = check("FR", &latent, 0); + assert_eq!( + f.iter() + .filter(|x| x.check == "non-canonical-trigger") + .count(), + 1 + ); + + let nfr = doc("---\ntype: NFR\n---\n## Statement\n\nThe system shall sustain p95 latency under 200ms.\n"); + let f2 = check("NFR", &nfr, 0); + assert_eq!( + f2.iter() + .filter(|x| x.check == "non-canonical-trigger") + .count(), + 0 + ); + } + + // TC-662 (FR-042-AC-6): grammar runs only on bound (archetype, section) pairs. + #[test] + fn tc662_binding() { + // A vague statement parked in Dependencies (not bound) yields nothing. + let d = + doc("---\ntype: FR\n---\n## Dependencies\n\nThe system shall support everything.\n"); + assert!(check("FR", &d, 0).is_empty()); + // An IT document binds no sections at all. + let it = doc("---\ntype: IT\n---\n## Objective\n\nThe system shall support everything.\n"); + assert!(check("IT", &it, 0).is_empty()); + } + + // TC-664 (FR-042-AC-8): findings carry excerpt, line, pattern, severity. + #[test] + fn tc664_finding_fields() { + let d = doc("---\ntype: FR\n---\n## Description\n\nThe system shall support publishing.\n"); + let f = check("FR", &d, 0); + let v = f.iter().find(|x| x.check == "vague-response").unwrap(); + assert!(!v.statement.is_empty()); + assert!(v.line.is_some()); + assert_eq!(v.pattern.as_deref(), Some("ubiquitous")); + assert_eq!(v.severity, GrammarSeverity::Warning); + } + + // TC-665 (FR-042-AC-9): fenced/quote/reference content is not a statement. + #[test] + fn tc665_skips() { + let d = doc("---\ntype: FR\n---\n## Description\n\n```\nThe system shall support publishing.\n```\n\n> The system shall support quoting.\n"); + assert!(check("FR", &d, 0).is_empty()); + } +} diff --git a/src/grammar/mod.rs b/src/grammar/mod.rs new file mode 100644 index 0000000..835f00e --- /dev/null +++ b/src/grammar/mod.rs @@ -0,0 +1,81 @@ +//! Requirement-grammar checking (FR-042). +//! +//! A *grammar* evaluates the natural-language requirement statements inside +//! requirement-bearing artifacts. This is a posture distinct from declarative +//! lint ([`crate::lint`], FR-036) and structural validation +//! ([`crate::validate_document`], FR-032): a grammar classifies and checks the +//! *prose of individual normative statements*, and its findings carry a +//! [`GrammarSeverity`] that routes them into the validation result. +//! +//! Archetypes bind to a grammar **bundle** via the manifest `grammar_ref` +//! field (e.g. `grammar_ref: iso-spec-core`). The framework holds a registry of +//! bundles by name and dispatches the right sub-grammar by +//! `(archetype, section)` inside the bundle. v1 ships the `iso-spec-core` +//! bundle implementing **EARS** for FR/NFR/StR normative sections; GWT (for +//! acceptance criteria) and the `US` story grammar register onto the same +//! framework later. + +pub mod ears; + +use crate::ast::QuireDocument; + +/// Severity of a grammar finding. `Warning` is advisory (never fails +/// validation); `Error` blocks. Severity is **policy**: a deployment may +/// promote a grammar from advisory to enforcing without a code change — the +/// migration lever for adopting EARS across the corpus (FR-042). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GrammarSeverity { + Warning, + Error, +} + +impl GrammarSeverity { + /// Stable machine-readable string for surfaces (CLI, wheel). + pub fn as_str(self) -> &'static str { + match self { + Self::Warning => "warning", + Self::Error => "error", + } + } +} + +/// One grammar diagnostic against a single normative statement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GrammarFinding { + /// Grammar bundle that produced the finding (e.g. `ears`). + pub grammar: String, + /// Stable check id (e.g. `non-singular`, `vague-response`). + pub check: String, + /// Classification label for the statement, when the grammar classifies it + /// (e.g. an EARS pattern: `ubiquitous`, `event`, …, `unclassifiable`). + pub pattern: Option, + /// Human-readable message. + pub message: String, + /// 1-based document line of the offending statement, when known. + pub line: Option, + /// The offending statement excerpt. + pub statement: String, + /// Severity (advisory `Warning` by default in v1). + pub severity: GrammarSeverity, +} + +/// Run the grammar bundle named `grammar_ref` against `doc`, dispatching by +/// `archetype` (the **resolved** archetype name, so a `--archetype` override on +/// a typeless document is still checked). An unknown bundle name yields no +/// findings — grammar checking is advisory by construction and never errors on +/// an unrecognised binding (a module may declare a `grammar_ref` this engine +/// build does not implement). +/// +/// `line_offset` is the number of leading lines (frontmatter + fence) before +/// the document body, so findings can carry 1-based document line numbers. +pub fn check_document_grammar( + grammar_ref: &str, + archetype: &str, + doc: &QuireDocument, + line_offset: usize, +) -> Vec { + match grammar_ref { + "iso-spec-core" => ears::check(archetype, doc, line_offset), + _ => Vec::new(), + } +} diff --git a/src/lib.rs b/src/lib.rs index d83ecc0..d8a382b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod corpus; pub mod diagnostic; pub mod error; pub mod extract; +pub mod grammar; pub mod lint; pub mod loader; pub mod merge; @@ -69,6 +70,7 @@ pub use extract::{extract, ExtractionResult}; pub use extract::assert_eval::{evaluate_assert, AssertFailure, AssertReason}; pub use extract::interpolate::{interpolate, UnresolvedField}; // Declarative lint rules (FR-036) — advisory, never blocks extraction. +pub use grammar::{check_document_grammar, GrammarFinding, GrammarSeverity}; pub use lint::{lint_document, LintFinding, LintRule, LintSeverity}; // Writeback (FR-022) — byte-splice section/block edit. pub use writeback::{update_block, update_section}; diff --git a/src/python/mod.rs b/src/python/mod.rs index 5d54d54..c8a6d1c 100644 --- a/src/python/mod.rs +++ b/src/python/mod.rs @@ -44,6 +44,7 @@ fn quire(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(load_repo, m)?)?; m.add_function(wrap_pyfunction!(validate, m)?)?; m.add_function(wrap_pyfunction!(validate_document, m)?)?; + m.add_function(wrap_pyfunction!(check_grammar, m)?)?; m.add_function(wrap_pyfunction!(input_contract, m)?)?; m.add_function(wrap_pyfunction!(input_skeleton, m)?)?; m.add_function(wrap_pyfunction!(validate_manifest, m)?)?; @@ -136,6 +137,45 @@ fn validate_document<'py>( Ok(out) } +/// Run the requirement-grammar bundle `grammar_ref` (e.g. `iso-spec-core`) +/// against `document_text` as the resolved `archetype` (FR-042). Returns the +/// list of grammar findings — the same findings the in-process Rust +/// `check_document_grammar` produces — each a dict +/// `{grammar, check, pattern, message, line, statement, severity}`. Unlike +/// `validate_document`, this surfaces the findings directly (not folded into +/// validation warnings) so the corpus report and review lens can consume them. +#[pyfunction] +fn check_grammar<'py>( + py: Python<'py>, + grammar_ref: &str, + archetype: &str, + document_text: &str, +) -> PyResult> { + let gref = grammar_ref.to_string(); + let arch = archetype.to_string(); + let text = document_text.to_string(); + + let findings = py.detach(|| { + let doc = crate::parse_document(&text); + let line_offset = crate::validate_document::body_line_offset(&text); + crate::grammar::check_document_grammar(&gref, &arch, &doc, line_offset) + }); + + let out = PyList::empty(py); + for f in findings { + let d = PyDict::new(py); + d.set_item("grammar", &f.grammar)?; + d.set_item("check", &f.check)?; + d.set_item("pattern", f.pattern.as_deref())?; + d.set_item("message", &f.message)?; + d.set_item("line", f.line)?; + d.set_item("statement", &f.statement)?; + d.set_item("severity", f.severity.as_str())?; + out.append(d)?; + } + Ok(out) +} + /// Return the input contract for `archetype_name` from `module_root` /// (FR-029 recast) as a JSON-serializable dict: `{archetype, /// frontmatter_schema, sections, diagnostics}`. Derived from the loaded diff --git a/src/validate_document.rs b/src/validate_document.rs index 90a630a..ec8cc01 100644 --- a/src/validate_document.rs +++ b/src/validate_document.rs @@ -55,6 +55,10 @@ pub enum ValidationReason { /// resolved `allowed_links` vocabulary (FR-040-AC-8, Tier-1). /// Warning-severity — advisory, never fails validation. DisallowedEdgeType, + /// A requirement-grammar (EARS, FR-042) finding. Severity is policy: + /// advisory `warning` by default, promotable to `error`. Carried for both + /// the warning and error routing of grammar findings. + Grammar, } impl ValidationReason { @@ -70,6 +74,7 @@ impl ValidationReason { Self::UnresolvedField => "unresolved-field", Self::UnknownObjectType => "unknown-object-type", Self::DisallowedEdgeType => "disallowed-edge-type", + Self::Grammar => "grammar", } } } @@ -120,10 +125,6 @@ impl ValidationResult { warnings, } } - - fn from_errors(errors: Vec) -> Self { - Self::new(errors, Vec::new()) - } } /// Validate an authored markdown `doc_text` against `archetype` (FR-032). @@ -157,7 +158,55 @@ pub fn validate_document(archetype: &CompiledArchetype, doc_text: &str) -> Valid } check_heading_uniqueness(&doc, line_offset, &mut errors); - ValidationResult::from_errors(errors) + let mut warnings: Vec = Vec::new(); + run_grammar(archetype, &doc, line_offset, &mut errors, &mut warnings); + + ValidationResult::new(errors, warnings) +} + +/// Run the requirement-grammar (EARS, FR-042) bundle bound to `archetype` via +/// its `grammar_ref`, routing findings into `errors`/`warnings` by severity. +/// No `grammar_ref` (or an unknown bundle) is a no-op — grammar checking is +/// advisory by construction and never the reason a document fails to validate +/// in v1 (severity is policy, defaulting to `warning`). +fn run_grammar( + archetype: &CompiledArchetype, + doc: &QuireDocument, + line_offset: usize, + errors: &mut Vec, + warnings: &mut Vec, +) { + let Some(grammar_ref) = archetype.grammar_ref() else { + return; + }; + for f in crate::grammar::check_document_grammar(grammar_ref, &archetype.name, doc, line_offset) + { + route_grammar_finding(f, errors, warnings); + } +} + +/// Route one grammar finding into `errors` or `warnings` by its severity +/// (FR-042-AC-7). `Warning` is advisory (never fails validation); `Error` +/// blocks. Split out from [`run_grammar`] so the severity routing is unit +/// testable without a `CompiledArchetype`. +fn route_grammar_finding( + f: crate::grammar::GrammarFinding, + errors: &mut Vec, + warnings: &mut Vec, +) { + let message = format!("[{}:{}] {}", f.grammar, f.check, f.message); + match f.severity { + crate::grammar::GrammarSeverity::Warning => warnings.push(ValidationWarning { + message, + line: f.line, + reason: ValidationReason::Grammar, + }), + crate::grammar::GrammarSeverity::Error => errors.push(ValidationError { + message, + line: f.line, + reason: ValidationReason::Grammar, + }), + } } /// Validate an authored markdown `doc_text` against BOTH the `type` @@ -264,6 +313,9 @@ pub fn validate_document_in_registry( } } + // ── Requirement-grammar layer (EARS, FR-042; advisory by default) ── + run_grammar(archetype, &doc, line_offset, &mut errors, &mut warnings); + ValidationResult::new(errors, warnings) } @@ -312,7 +364,7 @@ fn frontmatter_object(doc: &QuireDocument) -> Option<&str> { /// the count of newlines consumed by any frontmatter block (plus a /// leading BOM). Used to convert a section's 0-based body `start_line` /// into a 1-based document line. -fn body_line_offset(doc_text: &str) -> usize { +pub(crate) fn body_line_offset(doc_text: &str) -> usize { let stripped = doc_text.strip_prefix('\u{FEFF}').unwrap_or(doc_text); // `parse_document` stores the verbatim input in `raw`; the parsed // body is `raw` minus frontmatter. Recompute the body the same way @@ -779,6 +831,56 @@ yield_pattern: serde_json::from_str(FR_SCHEMA).unwrap() } + const FR_DSL_DESC: &str = r#" +yield_pattern: + match: + description: + from: section_body + after_heading: Description + required: true +"#; + + // TC-663 (FR-042-AC-7): grammar warnings never fail validation; an + // error-severity finding routes to `errors` and would block. + #[test] + fn tc663_grammar_severity_routing() { + // End-to-end: an FR bound to the EARS grammar (`grammar_ref`) with a + // vague Description yields a Grammar *warning* yet still validates. + let mut a = archetype(Some(fr_schema_value()), Some(FR_DSL_DESC)); + a.carry_over.grammar_ref = Some("iso-spec-core".into()); + let doc = "---\nid: FR-001\ntitle: A Thing\n---\n\ + ## Description\nThe system shall support publishing.\n"; + let r = validate_document(&a, doc); + assert!( + r.is_valid, + "advisory grammar findings must not fail validation" + ); + assert!(r + .warnings + .iter() + .any(|w| w.reason == ValidationReason::Grammar)); + + // Routing: an Error-severity finding lands in `errors` (would block). + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + route_grammar_finding( + crate::grammar::GrammarFinding { + grammar: "ears".into(), + check: "non-singular".into(), + pattern: None, + message: "x".into(), + line: Some(3), + statement: "s".into(), + severity: crate::grammar::GrammarSeverity::Error, + }, + &mut errors, + &mut warnings, + ); + assert_eq!(errors.len(), 1); + assert!(warnings.is_empty()); + assert_eq!(errors[0].reason, ValidationReason::Grammar); + } + // TC-528 (FR-032-AC-1): conformant document validates. #[test] fn tc528_conformant_document_is_valid() { diff --git a/tests/python/test_bindings.py b/tests/python/test_bindings.py index cf0eaf5..9a8cc38 100644 --- a/tests/python/test_bindings.py +++ b/tests/python/test_bindings.py @@ -20,7 +20,7 @@ def test_parse_document_returns_structured_object(): """TC-461/467: parse returns a native dict, not a JSON string.""" - md = "---\nid: FR-023\nartifact_type: FR\n---\n# Behavior {#blk-1}\n\nbody\n" + md = "---\nid: FR-023\ntype: FR\n---\n# Behavior {#blk-1}\n\nbody\n" doc = quire.parse_document(md) assert isinstance(doc, dict) @@ -38,10 +38,34 @@ def test_parse_document_malformed_frontmatter_is_tolerant(): assert doc["frontmatter"] is None # malformed -> None (FR-006) +def test_check_grammar_ears_findings_cross_boundary(): + """TC-666 (FR-042-AC-10): the EARS grammar entry point is exposed via PyO3 + and returns the same findings as the in-process Rust call for a fixture.""" + md = ( + "---\nid: FR-001\ntype: FR\n---\n" + "## Description\n\nOn startup, the service shall support publishing.\n" + ) + findings = quire.check_grammar("iso-spec-core", "FR", md) + assert isinstance(findings, list) + checks = {f["check"] for f in findings} + # vague verb `support` + latent trigger `On …` (matches the Rust unit tests) + assert "vague-response" in checks + assert "non-canonical-trigger" in checks + vague = next(f for f in findings if f["check"] == "vague-response") + assert vague["grammar"] == "ears" + assert vague["pattern"] == "ubiquitous" + assert vague["severity"] == "warning" + assert vague["line"] is not None + assert vague["statement"] + + # An unknown grammar bundle yields no findings (advisory, never errors). + assert quire.check_grammar("nonexistent", "FR", md) == [] + + def test_load_repo_returns_documents(tmp_path): """TC-463: load_repo yields one structured doc per markdown file.""" (tmp_path / "FR-001.md").write_text( - "---\nid: FR-001\nartifact_type: FR\nuuid: 0190b6a0-0000-7000-8000-000000000001\n---\n# H\n" + "---\nid: FR-001\ntype: FR\nuuid: 0190b6a0-0000-7000-8000-000000000001\n---\n# H\n" ) docs = quire.load_repo(str(tmp_path)) assert len(docs) == 1 @@ -434,7 +458,7 @@ def test_extraction_context_errors_on_required_per_match_miss(): def test_extract_frontmatter_returns_dict_or_none(): """TC-514: FR-006 parity — Rust returns frontmatter and body.""" - result = quire.extract_frontmatter("---\nid: X\nartifact_type: FR\n---\n# H\n") + result = quire.extract_frontmatter("---\nid: X\ntype: FR\n---\n# H\n") assert result == { "frontmatter": {"id": "X", "type": "FR"}, "body": "# H\n", diff --git a/tests/python/test_speedup.py b/tests/python/test_speedup.py index be9405e..dc77afb 100644 --- a/tests/python/test_speedup.py +++ b/tests/python/test_speedup.py @@ -35,7 +35,7 @@ def _make_corpus(root, n): for i in range(n): (root / f"FR-{i:04}.md").write_text( - f"---\nid: FR-{i:04}\nartifact_type: FR\n" + f"---\nid: FR-{i:04}\ntype: FR\n" f"uuid: 0190b6a0-0000-7000-8000-{i:012}\n" "relationships:\n" ' - target: "StR-001"\n' From 828ce7bfe367b721b2a77b824537c3ff124979fa Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 07:15:47 -0700 Subject: [PATCH 02/11] refine(grammar): make vague-response object-aware (FR-042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review/calibration follow-up. The flat verb lexicon over-flagged: vagueness is a property of the OBJECT, not the verb — `provide an endpoint`, `process push events`, `handle X by Y` are concrete despite a "vague" verb. Across the corpus the old rule fired 791×, ~55% of them `provide` on concrete CRUD/API objects. vague-response is now object-aware: a verb is flagged only when its object is abstract (`provide flexibility`, `handle errors gracefully`) or absent. A concrete object surface or a mechanism/numeric qualifier suppresses it. `be able to` (capability-not-behavior) is the one verb-intrinsic case and is always flagged. Corpus: 791 → 216 vague-response findings (concrete CRUD/event uses no longer flagged), calibrated against the full corpus. Co-Authored-By: Claude Opus 4.8 --- .../FR-042-requirement-grammar-check.md | 7 +- src/grammar/ears.rs | 111 ++++++++++++++++-- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/spec/functional/FR-042-requirement-grammar-check.md b/spec/functional/FR-042-requirement-grammar-check.md index 814aee6..c159414 100644 --- a/spec/functional/FR-042-requirement-grammar-check.md +++ b/spec/functional/FR-042-requirement-grammar-check.md @@ -51,7 +51,12 @@ violates a clause rule: 1. **non-singular** — the statement contains more than one `shall`. 2. **missing-subject** — the statement names no system/actor subject. 3. **vague-response** — the response verb is vague (`support`, `handle`, - `manage`, `process`, `deal with`, `provide`, `enable`, `be able to`). + `manage`, `process`, `deal with`, `provide`, `enable`, `be able to`). The + check is **object-aware**: a verb is flagged only when its object is abstract + or absent — a concrete object surface (`provide an endpoint`, `process push + events`) or a mechanism/quantitative qualifier (`handle X by Y`, `process + within 16 ms`) states a verifiable response and is not flagged. `be able to` + is the one verb-intrinsic case (capability-not-behavior) and is always flagged. 4. **non-canonical-trigger** — the statement leads with a non-EARS trigger (`On …`, `Upon`, `After`, `Before`, `Once`, `During`) instead of `When`/ `While`. diff --git a/src/grammar/ears.rs b/src/grammar/ears.rs index b735d1a..92255ec 100644 --- a/src/grammar/ears.rs +++ b/src/grammar/ears.rs @@ -219,13 +219,31 @@ fn has_missing_subject(statement: &str) -> bool { before.is_empty() } +/// Detect a vague response verb — **object-aware** (FR-042). A verb from the +/// lexicon is only vague when its *object* is abstract or absent: a concrete +/// object surface (`provide an endpoint`, `process push events`) or a +/// mechanism/quantitative qualifier (`handle X by Y`, `process within 16 ms`) +/// states a verifiable response and is suppressed. `be able to` is the one +/// verb-intrinsic case — capability phrasing — and is always flagged. fn vague_verb(statement: &str) -> Option { let lower = normalize(statement).to_lowercase(); - re_vague().captures(&lower).map(|c| { - c.get(2) - .map(|m| m.as_str().trim().to_string()) - .unwrap_or_default() - }) + let caps = re_vague().captures(&lower)?; + let m = caps.get(2)?; + let verb = m.as_str().split_whitespace().collect::>().join(" "); + if verb == "be able to" { + return Some(verb); // capability-not-behavior — always vague + } + let remainder = &lower[m.end()..]; + if re_concrete_qualifier().is_match(remainder) { + return None; // mechanism / numeric / ordering qualifier → concrete + } + if re_vague_quality().is_match(remainder) { + return Some(verb); // abstract quality/manner object → vague + } + if re_concrete_noun().is_match(remainder) { + return None; // concrete object surface → verifiable + } + Some(verb) // bare / generic response → vague } fn non_canonical_trigger(statement: &str) -> Option { @@ -392,6 +410,43 @@ fn re_vague() -> &'static Regex { }) } +/// A mechanism, numeric, or ordering qualifier after the verb — the response is +/// concrete/measurable (`handle X by Y`, `process within 16 ms`, `process only +/// push events`). Calibrated against the corpus (FR-042 object-aware tuning). +fn re_concrete_qualifier() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + Regex::new( + r"(?i)\b(by|using|via|per|through|according to|that|which|within|before|after|only|not)\b|\bin order\b|\bidempotent|[0-9<>%]", + ) + .expect("concrete-qualifier regex") + }) +} + +/// An abstract quality noun or unmeasurable manner — genuine vagueness even with +/// a grammatical object (`provide flexibility`, `handle errors gracefully`). +fn re_vague_quality() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + Regex::new( + r"(?i)\b(flexibility|extensibility|robustness|scalability|usability|reliability|maintainability|interoperability|capabilit(y|ies)|functionality|complexity|seamless(ly)?|gracefully|appropriately|efficiently|properly|correctly|reliably|acceptable|reasonable|sufficient|adequate|user-friendly|intuitive|performant|flexible|robust|scalable)\b|\bas (needed|appropriate|required|possible)\b", + ) + .expect("vague-quality regex") + }) +} + +/// A concrete object surface — the verb names a verifiable response +/// (`provide an endpoint`, `support listing reviews`, `manage the signing key`). +fn re_concrete_noun() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + Regex::new( + r"(?i)\b(create|read|update|delete|list|crud|get|post|put|patch)\b|\b(endpoint|operation|method|api|command|event|field|record|token|route|schema|message|request|response|header|query|webhook|handler|interface|file|key|step|layer|connection|session|state|lifecycle|transition|signal|thread|comment|review|workflow|config|overlay|frame|digest|index|mapping|page|view|report|graph|node|edge|module|plugin|object|artifact|document|spec|content|button|box|component|panel|parameter|attachment|dropdown|menu|form|dialog|reply|replies|account|provider|tab|widget|column|row|cell)s?\b", + ) + .expect("concrete-noun regex") + }) +} + #[cfg(test)] mod tests { use super::*; @@ -457,16 +512,48 @@ mod tests { assert_eq!(f.iter().filter(|x| x.check == "missing-subject").count(), 0); } - // TC-660 (FR-042-AC-4): vague-response fires; passive does not. + // TC-660 (FR-042-AC-4): vague-response fires; passive does not; the check + // is object-aware (a concrete object or mechanism qualifier suppresses it). #[test] fn tc660_vague_and_passive() { - let vague = doc("---\ntype: FR\n---\n## Description\n\nThe system shall support publishing to registries.\n"); - let f = check("FR", &vague, 0); - assert_eq!(f.iter().filter(|x| x.check == "vague-response").count(), 1); + let count = |text: &str| { + check("FR", &doc(text), 0) + .iter() + .filter(|x| x.check == "vague-response") + .count() + }; + let fr = |body: &str| format!("---\ntype: FR\n---\n## Description\n\n{body}\n"); + + // Bare / abstract object → vague. + assert_eq!( + count(&fr("The system shall support publishing to registries.")), + 1 + ); + assert_eq!(count(&fr("The system shall provide flexibility.")), 1); + // `be able to` is capability phrasing — always vague. + assert_eq!(count(&fr("The user shall be able to export the data.")), 1); - let passive = doc("---\ntype: FR\n---\n## Description\n\nMessages shall be included from newest to oldest.\n"); - let f2 = check("FR", &passive, 0); - assert_eq!(f2.iter().filter(|x| x.check == "vague-response").count(), 0); + // Object-aware suppression: concrete object / mechanism / numeric bound. + assert_eq!( + count(&fr("The system shall provide an endpoint for review runs.")), + 0 + ); + assert_eq!( + count(&fr("The webhook handler shall process only push events.")), + 0 + ); + assert_eq!( + count(&fr( + "The system shall handle 404 errors by displaying a toast." + )), + 0 + ); + + // Passive voice is not a vague verb at all. + assert_eq!( + count(&fr("Messages shall be included from newest to oldest.")), + 0 + ); } // TC-661 (FR-042-AC-5): non-canonical trigger fires; NFR no-trigger does not. From d9419936c5a8d6f86d20f3a2fdcbbcfdd17edb9d Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 07:32:31 -0700 Subject: [PATCH 03/11] refine(grammar): treat backticked identifiers as concrete objects (FR-042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object-aware vague-response follow-up. A backticked identifier in the response object (`provide `CodeBlockEditor``, `support `ecvector` opclasses`) is the most concrete object there is — but `normalize()` was stripping backticks before the check, so named code objects fell back to the noun lexicon and leaked as false positives. Keep backticks; treat a backticked object as a concrete signal, so authors can reference things outside the fixed noun lexicon. Corpus: vague-response 216 → 173 (43 named-identifier leaks suppressed). The residual is be-able-to capability phrasing + genuinely-abstract objects. Co-Authored-By: Claude Opus 4.8 --- .../FR-042-requirement-grammar-check.md | 5 +++-- src/grammar/ears.rs | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/spec/functional/FR-042-requirement-grammar-check.md b/spec/functional/FR-042-requirement-grammar-check.md index c159414..6c063a8 100644 --- a/spec/functional/FR-042-requirement-grammar-check.md +++ b/spec/functional/FR-042-requirement-grammar-check.md @@ -54,8 +54,9 @@ violates a clause rule: `manage`, `process`, `deal with`, `provide`, `enable`, `be able to`). The check is **object-aware**: a verb is flagged only when its object is abstract or absent — a concrete object surface (`provide an endpoint`, `process push - events`) or a mechanism/quantitative qualifier (`handle X by Y`, `process - within 16 ms`) states a verifiable response and is not flagged. `be able to` + events`), a **backticked code identifier** (`provide \`CodeBlockEditor\``), or + a mechanism/quantitative qualifier (`handle X by Y`, `process within 16 ms`) + states a verifiable response and is not flagged. `be able to` is the one verb-intrinsic case (capability-not-behavior) and is always flagged. 4. **non-canonical-trigger** — the statement leads with a non-EARS trigger (`On …`, `Upon`, `After`, `Before`, `Once`, `During`) instead of `When`/ diff --git a/src/grammar/ears.rs b/src/grammar/ears.rs index 92255ec..2a848da 100644 --- a/src/grammar/ears.rs +++ b/src/grammar/ears.rs @@ -319,10 +319,13 @@ fn table_statements(section: &QuireSection, line_offset: usize, column: &str) -> // ─── Helpers ──────────────────────────────────────────────────────────────── /// Strip markdown decoration that would confuse subject/trigger detection: -/// links `[t](u)` → `t`, bold `**`, and inline code backticks. +/// links `[t](u)` → `t` and bold `**`. Backticks are **kept** — a backticked +/// identifier in the response object (`provide \`CodeBlockEditor\``) is the most +/// concrete object there is, and the object-aware vague-response check treats it +/// as a concrete signal (FR-042). fn normalize(s: &str) -> String { let unlinked = re_link().replace_all(s, "$1"); - unlinked.replace("**", "").replace('`', "") + unlinked.replace("**", "") } fn excerpt(s: &str) -> String { @@ -417,7 +420,7 @@ fn re_concrete_qualifier() -> &'static Regex { static R: OnceLock = OnceLock::new(); R.get_or_init(|| { Regex::new( - r"(?i)\b(by|using|via|per|through|according to|that|which|within|before|after|only|not)\b|\bin order\b|\bidempotent|[0-9<>%]", + r"(?i)\b(by|using|via|per|through|according to|that|which|within|before|after|only|not)\b|\bin order\b|\bidempotent|[0-9<>%`]", ) .expect("concrete-qualifier regex") }) @@ -538,6 +541,14 @@ mod tests { count(&fr("The system shall provide an endpoint for review runs.")), 0 ); + // A backticked identifier is a concrete code object — accepted, so + // authors can reference things outside the fixed noun lexicon. + assert_eq!( + count(&fr( + "The system shall provide `CodeBlockEditor`, a fenced editor." + )), + 0 + ); assert_eq!( count(&fr("The webhook handler shall process only push events.")), 0 From a584a7e754c60f93af54e25fb91a04a33cc063bb Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 08:45:05 -0700 Subject: [PATCH 04/11] feat(grammar): module-supplied concrete lexicon, drop hardcoded nouns (FR-043) Concrete grammar vocabulary is now DATA, not engine (ADR 0009). The EARS object-aware vague-response check (FR-042) no longer carries a hardcoded ~60-term software-noun list (`re_concrete_noun` removed); concrete terms come from a mergeable manifest `lexicon:` registry, exactly like edge_types/roles (FR-040). - vocab/manifest/loader/registry: `lexicon` registry merged first-wins across modules (DuplicateLexiconTerm advisory; load_strict escalates); Registry::lexicon() + a precompiled lexicon_matcher() built once at load. - grammar: vague_verb consumes the matcher; the bounded vague-quality lexicon, mechanism/bound qualifiers, and backtick hatch stay in-engine (generic, not domain vocabulary). - run_grammar threads it: registry-backed validate_document_in_registry passes the merged lexicon; type-only validate_document passes empty (documented degradation). check_grammar PyO3 binding gains module_root. - spec: FR-043 + ADR 0009 + matrix to 367/367 (TC-667..673). High recall kept (flag-unless-concrete, NOT flipped). Baseline software terms land in the spec-objects-* modules next (distributed, per ADR 0009). Co-Authored-By: Claude Opus 4.8 --- ...0009-concrete-vocabulary-is-module-data.md | 73 ++++++++++ spec/assets/adr/index.md | 1 + .../FR-043-module-concrete-lexicon.md | 67 +++++++++ spec/functional/index.md | 1 + spec/tests.md | 19 ++- src/diagnostic.rs | 17 +++ src/grammar/ears.rs | 130 ++++++++++++------ src/grammar/mod.rs | 54 +++++++- src/lib.rs | 2 +- src/loader/manifest.rs | 6 + src/loader/mod.rs | 23 +++- src/python/mod.rs | 24 +++- src/registry.rs | 81 +++++++++++ src/validate_document.rs | 34 ++++- src/vocab.rs | 11 ++ tests/python/test_bindings.py | 21 +++ 16 files changed, 508 insertions(+), 56 deletions(-) create mode 100644 spec/assets/adr/0009-concrete-vocabulary-is-module-data.md create mode 100644 spec/functional/FR-043-module-concrete-lexicon.md diff --git a/spec/assets/adr/0009-concrete-vocabulary-is-module-data.md b/spec/assets/adr/0009-concrete-vocabulary-is-module-data.md new file mode 100644 index 0000000..0c68748 --- /dev/null +++ b/spec/assets/adr/0009-concrete-vocabulary-is-module-data.md @@ -0,0 +1,73 @@ +--- +id: ADR-0009 +title: "Concrete grammar vocabulary is module data, not engine" +type: ADR +--- + +# ADR 0009: Concrete grammar vocabulary is module data, not engine + +**Status**: Accepted +**Date**: 2026-06-23 +**Decision authority**: kreneskyp + +## Context + +The EARS `vague-response` check ([FR-042](../../functional/FR-042-requirement-grammar-check.md)) is +**object-aware**: a weak verb (`provide`/`support`/`process`/…) is flagged only when its object is +abstract or absent; a concrete object, a mechanism/numeric qualifier, or a backticked code +identifier suppresses it. This keeps high recall (flag-unless-concrete) while removing the worst +noise — a full-corpus pass went from 791 to 173 vague-response findings. + +The "concrete object" test, however, leaned on a **hardcoded ~60-term software-noun list** baked +into the engine (`re_concrete_noun()` in `src/grammar/ears.rs`). A corpus study of the residual +soft tail showed this is a dead end: the leaks are ~40 distinct domain nouns (`pagination`, +`cursor`, `replica`, `backend`, …), almost all appearing exactly once. The concrete-noun space is +**unbounded and domain-specific** — every domain has its own — so no fixed engine list can close it, +and a software-noun list hardcoded into a domain-agnostic engine is a layering violation. + +Two structural alternatives were weighed: + +- **Flip the default** to *suppress-unless-vague* (flag only on an abstract-term match). This makes + the maintained lexicon small and bounded, but trades the failure mode from visible, self- + correcting false positives to **silent false negatives** — a tool that fails to catch vagueness + without telling anyone. Since the check will eventually enforce, a silent miss defeats the gate. +- **Grow the engine noun list.** Whack-a-mole against an unbounded, domain-specific set; bakes a + software assumption into a generic engine. + +## Decision + +**Concrete vocabulary is data supplied by modules, not code in the engine.** + +- The engine keeps only the *bounded, generic* signals: the vague-quality lexicon, the + mechanism/numeric qualifiers, and the backtick hatch. `re_concrete_noun()` is removed. +- Modules declare a `lexicon` registry in `manifest.yaml`, merged first-wins across all loaded + modules exactly like `edge_types`/`roles` ([FR-040](../../functional/FR-040-object-edge-vocabulary.md)). + The grammar consumes the merged set ([FR-043](../../functional/FR-043-module-concrete-lexicon.md)). +- The baseline software vocabulary is **distributed across the domain `spec-objects-*` modules** — + each domain owns its terms, co-located with the objects that define them, and a software project + gets them by activating the modules it already uses. +- A project-scoped **Ubiquitous-Language** artifact layers on top later (possibly several, per + framework — DDD via `spec-objects-business`, EA via `spec-objects-enterprise`). + +We deliberately **do not flip** the default: flag-unless-concrete is kept, so recall stays high and +the only failure mode is a visible, self-correcting false positive — which an author silences by +making the object concrete (backtick it, name it, or define it in the project glossary), each of +which improves the spec. The backtick hatch already lets authors reference code objects outside any +fixed lexicon. + +## Consequences + +- **Positive**: the noun list becomes each domain's bounded vocabulary, not an ever-growing engine + list; the engine stays domain-agnostic; defining a term to silence a finding is itself good + requirements hygiene (ISO/IEC/IEEE 29148 wants a project glossary for specially-used terms, and + does not require redefining common industry terms — so a shipped baseline is standards-aligned). +- **Negative / accepted**: the grammar's type-only path (`validate_document`, no registry) cannot + see the merged lexicon, so under it bare domain nouns are not suppressed (mechanism/bound/backtick + still are). The registry-backed path — the CLI and Python surfaces — is the standard one and + applies the full lexicon. Revisit only if the type-only path becomes a real consumer. + +## Alternatives rejected + +- **Flip to suppress-unless-vague** — rejected: silent false negatives defeat an enforcing gate. +- **Grow the hardcoded engine noun list** — rejected: unbounded, domain-specific, and a layering + violation in a generic engine. diff --git a/spec/assets/adr/index.md b/spec/assets/adr/index.md index 5a19246..3a901a0 100644 --- a/spec/assets/adr/index.md +++ b/spec/assets/adr/index.md @@ -15,3 +15,4 @@ description: "Index of artifacts in this directory." * [0006 Forbid Unsafe Retire Miri](./0006-forbid-unsafe-retire-miri.md) * [0007 Internal Relative Path Links](./0007-internal-relative-path-links.md) * [0008 Authorable Inverse Edges](./0008-authorable-inverse-edges.md) +* [0009 Concrete Vocabulary Is Module Data](./0009-concrete-vocabulary-is-module-data.md) diff --git a/spec/functional/FR-043-module-concrete-lexicon.md b/spec/functional/FR-043-module-concrete-lexicon.md new file mode 100644 index 0000000..d4228d9 --- /dev/null +++ b/spec/functional/FR-043-module-concrete-lexicon.md @@ -0,0 +1,67 @@ +--- +id: FR-043 +title: "Module-Supplied Concrete Lexicon for the Grammar" +type: FR +relationships: + - target: "ix://agent-ix/quire-rs/spec/functional/FR-042" + type: "requires" + cardinality: "1:1" + - target: "ix://agent-ix/quire-rs/spec/functional/FR-040" + type: "requires" + cardinality: "1:1" +--- + +# [FR-043] Module-Supplied Concrete Lexicon for the Grammar + +## Description + +A Filament module MAY declare a `lexicon` registry in its `manifest.yaml` — a map of accepted +**concrete terms** to their definitions. The loader SHALL merge the per-module `lexicon` registries +across all loaded modules first-wins, mirroring the `edge_types`/`roles` registries ([FR-040](./FR-040-object-edge-vocabulary.md)). +A conflicting redeclaration of a term SHALL emit a non-fatal `DuplicateLexiconTerm` diagnostic. +The `Registry` SHALL expose the merged map through a `lexicon()` accessor. The `Registry` SHALL +also expose a precompiled matcher, built once at load, that recognises a lexicon term in a span of +text. + +The EARS object-aware `vague-response` check ([FR-042](./FR-042-requirement-grammar-check.md)) SHALL +consume the merged lexicon as its set of concrete object nouns. The engine SHALL NOT carry a +hardcoded concrete-noun list — concrete vocabulary lives in the domain modules that define it, not +in the engine. A statement whose response object matches a lexicon term SHALL be treated as +concrete and SHALL NOT be flagged. The engine SHALL retain the bounded vague-quality lexicon, the +mechanism/numeric qualifiers, and the backticked-identifier suppression +([FR-042](./FR-042-requirement-grammar-check.md)) — these are generic, not domain vocabulary. + +The merged lexicon reaches the grammar through the registry-backed validation path. When +`validate_document_in_registry` runs the grammar, the engine SHALL pass the `Registry`'s merged +lexicon to the check. When the type-only `validate_document` path runs the grammar without a +registry, the engine SHALL apply an empty lexicon: the mechanism, numeric-bound, and backticked +suppressions still apply, but bare domain-noun suppression does not. This degradation SHALL be +documented as the type-only path's known limitation. + +The `check_grammar` Python binding SHALL accept an optional `module_root`. When `module_root` is +given, the binding SHALL load that module registry and apply its merged lexicon; when it is absent, +the binding SHALL apply an empty lexicon. + +## Constraints + +| ID | Constraint | Type | Validation | +|----|------------|------|------------| +| FR-043-CON-1 | A malformed `lexicon` entry SHALL fail module load like any other manifest shape error | Operational | Unit Test | +| FR-043-CON-2 | An empty or absent `lexicon` registry SHALL load without error | Operational | Unit Test | + +## Acceptance Criteria + +| ID | Criteria | Verification | +|----|----------|--------------| +| FR-043-AC-1 | A manifest declaring a `lexicon` entry loads, and `Registry::lexicon()` returns the merged map containing that term. | Test | +| FR-043-AC-2 | Two modules declaring the same term with different definitions are first-wins and emit one `DuplicateLexiconTerm` diagnostic; identical redeclaration emits none. | Test | +| FR-043-AC-3 | With a registry whose lexicon contains `pagination`, the statement `The system shall support pagination.` yields no `vague-response` finding; with the term removed, the same statement yields one. | Test | +| FR-043-AC-4 | The engine contains no hardcoded concrete-noun list: a bare domain noun absent from the lexicon (and not backticked, with no mechanism/bound) yields a `vague-response` finding under an empty lexicon. | Test | +| FR-043-AC-5 | Backticked-identifier, mechanism, and numeric-bound suppression (FR-042) still hold under an empty lexicon — they do not depend on the lexicon. | Test | +| FR-043-AC-6 | `validate_document_in_registry` applies the registry's merged lexicon; the type-only `validate_document` applies an empty lexicon (more findings) and never errors on the difference. | Test | +| FR-043-AC-7 | The `check_grammar` Python binding with a `module_root` applies that registry's lexicon; without one it applies an empty lexicon. | Test | + +## Dependencies + +- **Upstream**: [FR-042](./FR-042-requirement-grammar-check.md) (the grammar consumer), [FR-040](./FR-040-object-edge-vocabulary.md) (the merge-vocab registry pattern this mirrors) +- **Downstream**: the `spec-objects-*` modules ship the domain `lexicon` registries; a project Ubiquitous-Language artifact adds a project-scoped layer (future) diff --git a/spec/functional/index.md b/spec/functional/index.md index 45a9665..f9862c3 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -42,3 +42,4 @@ description: "Index of artifacts in this directory." * [FR-040: Object-Axis Typed Edge Vocabulary and Cross-Domain Targets](./FR-040-object-edge-vocabulary.md) * [FR-041: Authorable Inverse Edge Verbs](./FR-041-authorable-inverse-edges.md) * [FR-042: Requirement-Grammar Check (EARS)](./FR-042-requirement-grammar-check.md) +* [FR-043: Module-Supplied Concrete Lexicon for the Grammar](./FR-043-module-concrete-lexicon.md) diff --git a/spec/tests.md b/spec/tests.md index 0aaa778..ba9c4ef 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -99,6 +99,7 @@ The spec was revised after authoring to reflect the **archetype-as-data** model: | FR-040 Object-axis typed edge vocabulary + cross-domain targets | AC-1..11 | TC-636 (registries load + idempotent merge), TC-637 (conflict→first-wins diagnostic), TC-650 (unknown verb/role diagnostic + strict escalation), TC-638 (array→{v:["*"]} + map round-trip), TC-651 (roles parsed onto archetype), TC-639 (resolve union), TC-640 (target_satisfies name/role/"*"), TC-641 (DisallowedEdgeType warn + unknown-object fallback), TC-642 (DisallowedEdgeTarget warn + role match cross-module + skips), TC-643 (warn-only, non-blocking), TC-644 (sorted determinism), TC-645 (skeleton Relationships block) | ✅ Implemented (engine) | | FR-041 Authorable inverse edge verbs | AC-1..5 | TC-652 (inverse index), TC-653 (Tier-1 recognition), TC-654 (precedence + DuplicateInverseEdge), TC-655 (Tier-2 forward normalization), TC-656 (warn-only determinism) | 🚧 Pending implementation | | FR-042 Requirement-grammar check (EARS) | AC-1..10 | TC-657 (6 patterns + unclassifiable), TC-658 (non-singular + enumerated-stem exemption), TC-659 (missing-subject + StR relaxed subject), TC-660 (vague-response + passive exemption), TC-661 (non-canonical-trigger + NFR no-trigger), TC-662 (archetype/section binding), TC-663 (warning vs error severity routing), TC-664 (finding fields), TC-665 (fence/quote/reference skip), TC-666 (PyO3 parity) | 🚧 Pending implementation | +| FR-043 Module-supplied concrete lexicon | AC-1..7 | TC-667 (lexicon registry load + accessor), TC-668 (first-wins merge + DuplicateLexiconTerm), TC-669 (lexicon term suppresses vague-response; removed re-flags), TC-670 (no hardcoded noun list under empty lexicon), TC-671 (backtick/mechanism/bound hold under empty lexicon), TC-672 (registry vs type-only lexicon paths), TC-673 (PyO3 check_grammar module_root) | 🚧 Pending implementation | ### Non-Functional Requirement Coverage @@ -350,6 +351,13 @@ The spec was revised after authoring to reflect the **archetype-as-data** model: | TC-664 | Each grammar finding carries the offending statement excerpt, a 1-based line number, the matched pattern label, and a severity | Unit | P0 | FR-042-AC-8 | 🚧 | | TC-665 | Modal verbs inside fenced code blocks, blockquotes, and reference lines are not segmented as normative statements and yield no findings | Unit | P0 | FR-042-AC-9 | 🚧 | | TC-666 | The grammar-check entry point exposed via PyO3 returns the same findings as the in-process Rust call for a fixture document | Integration | P0 | FR-042-AC-10, FR-023 | 🚧 | +| TC-667 | A manifest `lexicon` entry loads and `Registry::lexicon()` returns the merged map containing the term | Unit | P0 | FR-043-AC-1 | 🚧 | +| TC-668 | Two modules declaring the same term with different definitions are first-wins + emit one `DuplicateLexiconTerm`; identical redeclaration emits none | Unit | P0 | FR-043-AC-2 | 🚧 | +| TC-669 | With a lexicon containing `pagination`, `shall support pagination` yields no vague-response finding; with the term removed it yields one | Unit | P0 | FR-043-AC-3 | 🚧 | +| TC-670 | Under an empty lexicon, a bare domain noun (no backtick, no mechanism/bound) yields a vague-response finding — proving no hardcoded noun list remains | Unit | P0 | FR-043-AC-4 | 🚧 | +| TC-671 | Under an empty lexicon, backticked-identifier / mechanism / numeric-bound suppression (FR-042) still hold | Unit | P0 | FR-043-AC-5 | 🚧 | +| TC-672 | `validate_document_in_registry` applies the registry lexicon; type-only `validate_document` applies an empty lexicon (more findings) and never errors on the difference | Unit | P0 | FR-043-AC-6 | 🚧 | +| TC-673 | The `check_grammar` PyO3 binding with `module_root` applies that registry's lexicon; without one applies an empty lexicon | Integration | P0 | FR-043-AC-7, FR-023 | 🚧 | | TC-502 | Static audit: no Mutex/RwLock/Atomic in first-party src/; parallel parse collects owned results | Static | P0 | FR-024-AC-9 | 🚧 | | TC-503 | loom: parallel parse collection race-free; identical path-sorted output across all interleavings | Property | P0 | NFR-017-AC-1..3 | 🚧 | | TC-504 | TSAN lane: two-thread `load_repo` (GIL-release window) reports zero data races | Integration | P0 | NFR-018-AC-1, NFR-018-AC-3 | 🚧 | @@ -876,6 +884,13 @@ Comprehensive, post-audit explicit mapping. Every AC defined in the spec is list | FR-042-AC-8 | TC-664 | | FR-042-AC-9 | TC-665 | | FR-042-AC-10 | TC-666 | +| FR-043-AC-1 | TC-667 | +| FR-043-AC-2 | TC-668 | +| FR-043-AC-3 | TC-669 | +| FR-043-AC-4 | TC-670 | +| FR-043-AC-5 | TC-671 | +| FR-043-AC-6 | TC-672 | +| FR-043-AC-7 | TC-673 | ### Non-Functional Requirements @@ -941,7 +956,7 @@ Comprehensive, post-audit explicit mapping. Every AC defined in the spec is list | NFR-019-AC-1 | TC-579 | | NFR-019-AC-2 | TC-580 | -**Coverage status: 360 / 360 ACs covered (100%).** The requirement-grammar slice (FR-042, EARS, 2026-06-22) adds FR-042-AC-1..10 (grammar-check framework with EARS as the first grammar: six-pattern classification, the non-singular/missing-subject/vague-response/non-canonical-trigger clause checks with per-archetype dialects, warning→error severity routing into `ValidationResult`, fenced/quote/reference skip, and PyO3 parity — TC-657..666) — 10 ACs. The authorable-inverse-edges slice (FR-041, ADR 0008, 2026-06-21) adds FR-041-AC-1..5 (declared `inverse:` labels become authorable as derived views of their forward edge: inverse index, Tier-1 recognition, precedence/`DuplicateInverseEdge`, Tier-2 forward normalization, warn-only determinism, TC-652..656) — 5 ACs. The object-edge-vocabulary slice (FR-040, 2026-06-20) adds FR-040-AC-1..11 (object-axis typed edge vocabulary + cross-domain role-typed targets: mergeable `edge_types`/`roles` registries with first-wins+diagnostic merge, object `roles` parsed onto the archetype, array|map `allowed_links`, union resolution, warn-tier Tier-1/Tier-2 validation, composed skeleton, TC-636..645 + TC-650/651) and US-015-AC-1..4 (author declares an object's relationship vocabulary, TC-646..649) — 15 ACs. The per-value assert slice (CR-010, 2026-06-20) adds FR-033-AC-11..13 (`choices` scalar enum + `column_choices`/`column_patterns` per-column table validation, TC-633..635) — 3 ACs. The internal-links slice (ADR 0007, 2026-06-17) adds FR-026-AC-9..11 (relative-path link edge source + index/log exclusion + dedup parity, TC-620..622) and FR-039-AC-1..10 (unlinked-reference detection & autofix suggestions, incl. AC-10 multi-token code-span skip, TC-623..632) — 13 ACs. The composed type+object validation slice (2026-06-16) adds FR-032-AC-11..13 (`validate_document_in_registry` composes the `type` archetype with the frontmatter `object:` archetype; resolved-object failures are errors, unknown-object is a warning, `ValidationResult` carries typed `warnings`) — TC-610..613, 3 ACs. The assert/lint extension slice (2026-06-16) adds FR-033-AC-10 (CR-008 `matches` content assert, TC-608) and FR-036-AC-6 (CR-009 `section_body_pattern` lint rule, TC-609) — 2 ACs. The OKF slice (2026-06-16) adds FR-037-AC-1..6 (base concept frontmatter schema, TC-590..596 + TC-528) and FR-038-AC-1..8 (OKF bundle validation, TC-600..607) — 14 ACs. v0.4 adds FR-011-AC-21 (CR-006 `multiple: true`, TC-583) and FR-036-AC-1..5 (declarative lint rules, TC-584..588). v0.2 block model added 16 ACs (FR-019..022, TC-400..443). v0.3 adds 81 ACs — StR-005/006, US-011..013, FR-023..027 (incl. review-added FR-026-AC-8, FR-027-AC-9), NFR-015/016, plus the hardening re-review (NFR-003-AC-4, FR-024-AC-9, NFR-017, NFR-018) — covered by TC-455..507 (plus reused TC-456..459). The Miri ACs (NFR-012-AC-1..5) were **retired** (ADR 0006) and the compile-time **NFR-003-AC-5** (`forbid(unsafe_code)`, TC-582) added. PC (performance criteria) for US-011..013 are tracked as benches (TC-455..459, TC-469) and marked 🚧 pending implementation, consistent with the US-006..010 perf-bench convention. The v0.3 hardening re-review (loom NFR-017, TSAN/ASAN NFR-018) is recorded in spec.md §19. +**Coverage status: 367 / 367 ACs covered (100%).** The module-lexicon slice (FR-043, ADR 0009, 2026-06-23) adds FR-043-AC-1..7 (modules ship a mergeable `lexicon:` registry the EARS object-aware vague-response check consumes; the engine drops its hardcoded concrete-noun list; the type-only path degrades to an empty lexicon; PyO3 `check_grammar` gains `module_root` — TC-667..673) — 7 ACs. The requirement-grammar slice (FR-042, EARS, 2026-06-22) adds FR-042-AC-1..10 (grammar-check framework with EARS as the first grammar: six-pattern classification, the non-singular/missing-subject/vague-response/non-canonical-trigger clause checks with per-archetype dialects, warning→error severity routing into `ValidationResult`, fenced/quote/reference skip, and PyO3 parity — TC-657..666) — 10 ACs. The authorable-inverse-edges slice (FR-041, ADR 0008, 2026-06-21) adds FR-041-AC-1..5 (declared `inverse:` labels become authorable as derived views of their forward edge: inverse index, Tier-1 recognition, precedence/`DuplicateInverseEdge`, Tier-2 forward normalization, warn-only determinism, TC-652..656) — 5 ACs. The object-edge-vocabulary slice (FR-040, 2026-06-20) adds FR-040-AC-1..11 (object-axis typed edge vocabulary + cross-domain role-typed targets: mergeable `edge_types`/`roles` registries with first-wins+diagnostic merge, object `roles` parsed onto the archetype, array|map `allowed_links`, union resolution, warn-tier Tier-1/Tier-2 validation, composed skeleton, TC-636..645 + TC-650/651) and US-015-AC-1..4 (author declares an object's relationship vocabulary, TC-646..649) — 15 ACs. The per-value assert slice (CR-010, 2026-06-20) adds FR-033-AC-11..13 (`choices` scalar enum + `column_choices`/`column_patterns` per-column table validation, TC-633..635) — 3 ACs. The internal-links slice (ADR 0007, 2026-06-17) adds FR-026-AC-9..11 (relative-path link edge source + index/log exclusion + dedup parity, TC-620..622) and FR-039-AC-1..10 (unlinked-reference detection & autofix suggestions, incl. AC-10 multi-token code-span skip, TC-623..632) — 13 ACs. The composed type+object validation slice (2026-06-16) adds FR-032-AC-11..13 (`validate_document_in_registry` composes the `type` archetype with the frontmatter `object:` archetype; resolved-object failures are errors, unknown-object is a warning, `ValidationResult` carries typed `warnings`) — TC-610..613, 3 ACs. The assert/lint extension slice (2026-06-16) adds FR-033-AC-10 (CR-008 `matches` content assert, TC-608) and FR-036-AC-6 (CR-009 `section_body_pattern` lint rule, TC-609) — 2 ACs. The OKF slice (2026-06-16) adds FR-037-AC-1..6 (base concept frontmatter schema, TC-590..596 + TC-528) and FR-038-AC-1..8 (OKF bundle validation, TC-600..607) — 14 ACs. v0.4 adds FR-011-AC-21 (CR-006 `multiple: true`, TC-583) and FR-036-AC-1..5 (declarative lint rules, TC-584..588). v0.2 block model added 16 ACs (FR-019..022, TC-400..443). v0.3 adds 81 ACs — StR-005/006, US-011..013, FR-023..027 (incl. review-added FR-026-AC-8, FR-027-AC-9), NFR-015/016, plus the hardening re-review (NFR-003-AC-4, FR-024-AC-9, NFR-017, NFR-018) — covered by TC-455..507 (plus reused TC-456..459). The Miri ACs (NFR-012-AC-1..5) were **retired** (ADR 0006) and the compile-time **NFR-003-AC-5** (`forbid(unsafe_code)`, TC-582) added. PC (performance criteria) for US-011..013 are tracked as benches (TC-455..459, TC-469) and marked 🚧 pending implementation, consistent with the US-006..010 perf-bench convention. The v0.3 hardening re-review (loom NFR-017, TSAN/ASAN NFR-018) is recorded in spec.md §19. **v0.4 markdown-validation slice** adds 42 ACs — US-014 (author validates markdown), FR-029 (archetype input contract, recast by ADR 0004), FR-030 (required-section validation, superseded by FR-032/FR-033), FR-031 (unified archetype shape), FR-032 (`validate_document`), FR-033 (locator `assert` facet), FR-034 (assert field interpolation), FR-035 (per-level heading uniqueness) — covered by TC-518..553. FR-030's ACs are mapped to the FR-032/FR-033 TCs that subsume them (per its CR note). This slice also back-fills 7 ACs that a prior commit left out of the audit table — FR-013-AC-11..14, FR-028-AC-9/10, US-003-AC-4 — via TC-554..560. New v0.4 TCs are 🚧 pending implementation. @@ -959,7 +974,7 @@ NFR-006-AC-4, NFR-019-AC-1..2 — covered by TC-565..580. TC-561 is re-pointed o FR-033-AC-4 onto FR-033-AC-9 (the non-table `id_pattern` case); TC-562 covers both FR-033-AC-4 and FR-033-AC-9. -**Integrity check (grep-verified):** all **360 distinct file-defined ACs** (definition-anchored: bold `**-AC-N**` declarations) across `stakeholder/ usecase/ functional/ non-functional/` appear in the AC→TC audit table — **0 uncovered**. Note: `FR-900-AC-1/2` appearing inside FR-034-AC-1's example prose are NOT defined ACs and are excluded from the denominator (match `**…**:` definitions, not inline mentions). Retired ACs (marked `(RETIRED)`, un-bolded) are excluded by construction. Count: 316 (pre-removal) − 41 (retired) + 16 (back-fill) + 1 (FR-011-AC-20, CR-005 heading normalization) − 5 (NFR-012-AC-1..5 retired, ADR 0006) + 1 (NFR-003-AC-5, forbid(unsafe_code)) + 1 (FR-011-AC-21, CR-006 multiple:true) + 5 (FR-036-AC-1..5, declarative lint rules) + 1 (FR-010-AC-4, CR-007 escaped pipes) + 6 (FR-037-AC-1..6, OKF base concept schema) + 8 (FR-038-AC-1..8, OKF bundle validation) + 1 (FR-033-AC-10, CR-008 `matches` content assert) + 1 (FR-036-AC-6, CR-009 `section_body_pattern`) + 3 (FR-032-AC-11..13, composed type+object validation) + 3 (FR-026-AC-9..11, relative-path link edge source) + 10 (FR-039-AC-1..10, unlinked-reference detection incl. multi-token code-span skip) + 3 (FR-033-AC-11..13, CR-010 per-value enum/regex asserts) + 11 (FR-040-AC-1..11, object-axis typed edge vocabulary + cross-domain targets) + 4 (US-015-AC-1..4, author declares object relationship vocabulary) + 5 (FR-041-AC-1..5, authorable inverse edge verbs) + 10 (FR-042-AC-1..10, requirement-grammar check (EARS)) = **360**. +**Integrity check (grep-verified):** all **367 distinct file-defined ACs** (definition-anchored: bold `**-AC-N**` declarations) across `stakeholder/ usecase/ functional/ non-functional/` appear in the AC→TC audit table — **0 uncovered**. Note: `FR-900-AC-1/2` appearing inside FR-034-AC-1's example prose are NOT defined ACs and are excluded from the denominator (match `**…**:` definitions, not inline mentions). Retired ACs (marked `(RETIRED)`, un-bolded) are excluded by construction. Count: 316 (pre-removal) − 41 (retired) + 16 (back-fill) + 1 (FR-011-AC-20, CR-005 heading normalization) − 5 (NFR-012-AC-1..5 retired, ADR 0006) + 1 (NFR-003-AC-5, forbid(unsafe_code)) + 1 (FR-011-AC-21, CR-006 multiple:true) + 5 (FR-036-AC-1..5, declarative lint rules) + 1 (FR-010-AC-4, CR-007 escaped pipes) + 6 (FR-037-AC-1..6, OKF base concept schema) + 8 (FR-038-AC-1..8, OKF bundle validation) + 1 (FR-033-AC-10, CR-008 `matches` content assert) + 1 (FR-036-AC-6, CR-009 `section_body_pattern`) + 3 (FR-032-AC-11..13, composed type+object validation) + 3 (FR-026-AC-9..11, relative-path link edge source) + 10 (FR-039-AC-1..10, unlinked-reference detection incl. multi-token code-span skip) + 3 (FR-033-AC-11..13, CR-010 per-value enum/regex asserts) + 11 (FR-040-AC-1..11, object-axis typed edge vocabulary + cross-domain targets) + 4 (US-015-AC-1..4, author declares object relationship vocabulary) + 5 (FR-041-AC-1..5, authorable inverse edge verbs) + 10 (FR-042-AC-1..10, requirement-grammar check (EARS)) + 7 (FR-043-AC-1..7, module-supplied concrete lexicon) = **367**. --- diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 8bc59e6..bd0153b 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -92,6 +92,13 @@ pub enum Diagnostic { name: String, modules: Vec, }, + /// A `lexicon` term is declared with differing bodies by more than one + /// module (FR-043). First-wins; advisory, mirrors `DuplicateEdgeType`. + /// `load_strict` escalates. + DuplicateLexiconTerm { + name: String, + modules: Vec, + }, /// Two distinct forward `edge_types` verbs declare the **same** /// `inverse:` label (FR-041-AC-3). First-wins (the lexicographically /// first forward verb owns the label); advisory, mirrors @@ -244,6 +251,11 @@ impl std::fmt::Display for Diagnostic { "DuplicateRole: '{}' contributed by modules {:?}; first-wins", name, modules ), + Self::DuplicateLexiconTerm { name, modules } => write!( + f, + "DuplicateLexiconTerm: '{}' contributed by modules {:?}; first-wins", + name, modules + ), Self::DuplicateInverseEdge { name, forwards } => write!( f, "DuplicateInverseEdge: inverse label '{}' declared by verbs {:?}; first-wins", @@ -365,6 +377,11 @@ impl Diagnostic { "name": name, "modules": modules, }), + Self::DuplicateLexiconTerm { name, modules } => json!({ + "kind": "DuplicateLexiconTerm", + "name": name, + "modules": modules, + }), Self::DuplicateInverseEdge { name, forwards } => json!({ "kind": "DuplicateInverseEdge", "name": name, diff --git a/src/grammar/ears.rs b/src/grammar/ears.rs index 2a848da..d2805cb 100644 --- a/src/grammar/ears.rs +++ b/src/grammar/ears.rs @@ -15,7 +15,7 @@ use std::sync::OnceLock; use regex::Regex; -use super::{GrammarFinding, GrammarSeverity}; +use super::{GrammarFinding, GrammarLexicon, GrammarSeverity}; use crate::ast::QuireSection; use crate::query; @@ -79,6 +79,7 @@ pub fn check( archetype: &str, doc: &crate::ast::QuireDocument, line_offset: usize, + lexicon: &GrammarLexicon, ) -> Vec { let mut findings = Vec::new(); for (heading, table_column) in bound_sections(archetype) { @@ -90,13 +91,18 @@ pub fn check( None => prose_statements(section, line_offset), }; for stmt in stmts { - check_statement(archetype, &stmt, &mut findings); + check_statement(archetype, &stmt, lexicon, &mut findings); } } findings } -fn check_statement(archetype: &str, stmt: &Stmt, out: &mut Vec) { +fn check_statement( + archetype: &str, + stmt: &Stmt, + lexicon: &GrammarLexicon, + out: &mut Vec, +) { let pattern = classify(&stmt.text); let label = Some(pattern.as_str().to_string()); @@ -134,7 +140,7 @@ fn check_statement(archetype: &str, stmt: &Stmt, out: &mut Vec) "statement names no system/actor subject before `shall`".to_string(), ); } - if let Some(verb) = vague_verb(&stmt.text) { + if let Some(verb) = vague_verb(&stmt.text, lexicon) { push( out, "vague-response", @@ -219,13 +225,15 @@ fn has_missing_subject(statement: &str) -> bool { before.is_empty() } -/// Detect a vague response verb — **object-aware** (FR-042). A verb from the -/// lexicon is only vague when its *object* is abstract or absent: a concrete -/// object surface (`provide an endpoint`, `process push events`) or a -/// mechanism/quantitative qualifier (`handle X by Y`, `process within 16 ms`) -/// states a verifiable response and is suppressed. `be able to` is the one -/// verb-intrinsic case — capability phrasing — and is always flagged. -fn vague_verb(statement: &str) -> Option { +/// Detect a vague response verb — **object-aware** (FR-042) over the +/// module-supplied concrete `lexicon` (FR-043). A verb from the weak-verb set is +/// only vague when its *object* is abstract or absent: a mechanism/quantitative +/// qualifier (`handle X by Y`, `process within 16 ms`), a backticked identifier, +/// or a **lexicon term** (`provide an endpoint`, `support pagination`) states a +/// verifiable response and is suppressed. The engine carries no hardcoded +/// concrete-noun list — concrete terms come from the lexicon. `be able to` is +/// the one verb-intrinsic case (capability phrasing) and is always flagged. +fn vague_verb(statement: &str, lexicon: &GrammarLexicon) -> Option { let lower = normalize(statement).to_lowercase(); let caps = re_vague().captures(&lower)?; let m = caps.get(2)?; @@ -235,13 +243,13 @@ fn vague_verb(statement: &str) -> Option { } let remainder = &lower[m.end()..]; if re_concrete_qualifier().is_match(remainder) { - return None; // mechanism / numeric / ordering qualifier → concrete + return None; // mechanism / numeric / ordering / backtick qualifier → concrete } if re_vague_quality().is_match(remainder) { return Some(verb); // abstract quality/manner object → vague } - if re_concrete_noun().is_match(remainder) { - return None; // concrete object surface → verifiable + if lexicon.contains_term(remainder) { + return None; // a module-declared concrete term → verifiable } Some(verb) // bare / generic response → vague } @@ -438,18 +446,6 @@ fn re_vague_quality() -> &'static Regex { }) } -/// A concrete object surface — the verb names a verifiable response -/// (`provide an endpoint`, `support listing reviews`, `manage the signing key`). -fn re_concrete_noun() -> &'static Regex { - static R: OnceLock = OnceLock::new(); - R.get_or_init(|| { - Regex::new( - r"(?i)\b(create|read|update|delete|list|crud|get|post|put|patch)\b|\b(endpoint|operation|method|api|command|event|field|record|token|route|schema|message|request|response|header|query|webhook|handler|interface|file|key|step|layer|connection|session|state|lifecycle|transition|signal|thread|comment|review|workflow|config|overlay|frame|digest|index|mapping|page|view|report|graph|node|edge|module|plugin|object|artifact|document|spec|content|button|box|component|panel|parameter|attachment|dropdown|menu|form|dialog|reply|replies|account|provider|tab|widget|column|row|cell)s?\b", - ) - .expect("concrete-noun regex") - }) -} - #[cfg(test)] mod tests { use super::*; @@ -458,6 +454,14 @@ mod tests { crate::parse_document(text) } + fn empty() -> GrammarLexicon { + GrammarLexicon::empty() + } + + fn lex(terms: &[&str]) -> GrammarLexicon { + GrammarLexicon::from_terms(terms.iter().copied()) + } + // TC-657 (FR-042-AC-1): every pattern classifies; no-match → unclassifiable. #[test] fn tc657_classification() { @@ -495,11 +499,11 @@ mod tests { #[test] fn tc658_non_singular() { let d = doc("---\ntype: FR\n---\n## Description\n\nThe service shall mint a token and shall carry it downstream.\n"); - let f = check("FR", &d, 0); + let f = check("FR", &d, 0, &empty()); assert_eq!(f.iter().filter(|x| x.check == "non-singular").count(), 1); let enumerated = doc("---\ntype: FR\n---\n## Description\n\nThe function shall:\n\n1. Validate the input.\n2. Render the output.\n3. Return the result.\n"); - let f2 = check("FR", &enumerated, 0); + let f2 = check("FR", &enumerated, 0, &empty()); assert_eq!(f2.iter().filter(|x| x.check == "non-singular").count(), 0); } @@ -511,7 +515,7 @@ mod tests { "The operator shall be able to resync a repository" )); let str_doc = doc("---\ntype: StR\n---\n## Stakeholder Need\n\nThe operator shall reconcile a repository on demand.\n"); - let f = check("StR", &str_doc, 0); + let f = check("StR", &str_doc, 0, &empty()); assert_eq!(f.iter().filter(|x| x.check == "missing-subject").count(), 0); } @@ -519,8 +523,10 @@ mod tests { // is object-aware (a concrete object or mechanism qualifier suppresses it). #[test] fn tc660_vague_and_passive() { + // Empty lexicon: only the engine-generic signals (mechanism/bound/ + // backtick) suppress; bare/abstract objects flag. let count = |text: &str| { - check("FR", &doc(text), 0) + check("FR", &doc(text), 0, &empty()) .iter() .filter(|x| x.check == "vague-response") .count() @@ -536,13 +542,8 @@ mod tests { // `be able to` is capability phrasing — always vague. assert_eq!(count(&fr("The user shall be able to export the data.")), 1); - // Object-aware suppression: concrete object / mechanism / numeric bound. - assert_eq!( - count(&fr("The system shall provide an endpoint for review runs.")), - 0 - ); - // A backticked identifier is a concrete code object — accepted, so - // authors can reference things outside the fixed noun lexicon. + // Object-aware suppression via engine-generic signals (no lexicon needed): + // a backticked identifier, an ordering qualifier, a mechanism + bound. assert_eq!( count(&fr( "The system shall provide `CodeBlockEditor`, a fenced editor." @@ -567,11 +568,54 @@ mod tests { ); } + // TC-669 (FR-043-AC-3): a lexicon term suppresses; removing it re-flags. + // TC-670 (FR-043-AC-4): under an empty lexicon a bare domain noun flags — + // proving no hardcoded concrete-noun list remains in the engine. + #[test] + fn tc669_670_lexicon_drives_concrete() { + let fr = |body: &str| format!("---\ntype: FR\n---\n## Description\n\n{body}\n"); + let count = |text: &str, lexicon: &GrammarLexicon| { + check("FR", &doc(text), 0, lexicon) + .iter() + .filter(|x| x.check == "vague-response") + .count() + }; + let doc_text = fr("The system shall support pagination."); + // Empty lexicon → bare noun flags (no hardcoded list). + assert_eq!(count(&doc_text, &empty()), 1); + // Lexicon with the term → suppressed. + assert_eq!(count(&doc_text, &lex(&["pagination"])), 0); + // A different term does not suppress it. + assert_eq!(count(&doc_text, &lex(&["cursor"])), 1); + // The same for `provide an endpoint` once `endpoint` is a lexicon term. + let ep = fr("The system shall provide an endpoint for review runs."); + assert_eq!(count(&ep, &empty()), 1); + assert_eq!(count(&ep, &lex(&["endpoint"])), 0); + } + + // TC-671 (FR-043-AC-5): backtick / mechanism / bound suppression does not + // depend on the lexicon — it still holds under an empty lexicon. + #[test] + fn tc671_generic_suppression_lexicon_independent() { + let fr = |body: &str| format!("---\ntype: FR\n---\n## Description\n\n{body}\n"); + let count = |text: &str| { + check("FR", &doc(text), 0, &empty()) + .iter() + .filter(|x| x.check == "vague-response") + .count() + }; + assert_eq!(count(&fr("The system shall provide `RepoSource`.")), 0); // backtick + assert_eq!(count(&fr("The system shall handle errors by retrying.")), 0); // mechanism + assert_eq!(count(&fr("The system shall process up to 100 items.")), 0); // bound + // …but an abstract object still flags regardless of lexicon. + assert_eq!(count(&fr("The system shall provide flexibility.")), 1); + } + // TC-661 (FR-042-AC-5): non-canonical trigger fires; NFR no-trigger does not. #[test] fn tc661_non_canonical_trigger() { let latent = doc("---\ntype: FR\n---\n## Description\n\nOn startup, the service shall perform a full scan.\n"); - let f = check("FR", &latent, 0); + let f = check("FR", &latent, 0, &empty()); assert_eq!( f.iter() .filter(|x| x.check == "non-canonical-trigger") @@ -580,7 +624,7 @@ mod tests { ); let nfr = doc("---\ntype: NFR\n---\n## Statement\n\nThe system shall sustain p95 latency under 200ms.\n"); - let f2 = check("NFR", &nfr, 0); + let f2 = check("NFR", &nfr, 0, &empty()); assert_eq!( f2.iter() .filter(|x| x.check == "non-canonical-trigger") @@ -595,17 +639,17 @@ mod tests { // A vague statement parked in Dependencies (not bound) yields nothing. let d = doc("---\ntype: FR\n---\n## Dependencies\n\nThe system shall support everything.\n"); - assert!(check("FR", &d, 0).is_empty()); + assert!(check("FR", &d, 0, &empty()).is_empty()); // An IT document binds no sections at all. let it = doc("---\ntype: IT\n---\n## Objective\n\nThe system shall support everything.\n"); - assert!(check("IT", &it, 0).is_empty()); + assert!(check("IT", &it, 0, &empty()).is_empty()); } // TC-664 (FR-042-AC-8): findings carry excerpt, line, pattern, severity. #[test] fn tc664_finding_fields() { let d = doc("---\ntype: FR\n---\n## Description\n\nThe system shall support publishing.\n"); - let f = check("FR", &d, 0); + let f = check("FR", &d, 0, &empty()); let v = f.iter().find(|x| x.check == "vague-response").unwrap(); assert!(!v.statement.is_empty()); assert!(v.line.is_some()); @@ -617,6 +661,6 @@ mod tests { #[test] fn tc665_skips() { let d = doc("---\ntype: FR\n---\n## Description\n\n```\nThe system shall support publishing.\n```\n\n> The system shall support quoting.\n"); - assert!(check("FR", &d, 0).is_empty()); + assert!(check("FR", &d, 0, &empty()).is_empty()); } } diff --git a/src/grammar/mod.rs b/src/grammar/mod.rs index 835f00e..38c7f50 100644 --- a/src/grammar/mod.rs +++ b/src/grammar/mod.rs @@ -17,8 +17,59 @@ pub mod ears; +use std::sync::OnceLock; + +use regex::Regex; + use crate::ast::QuireDocument; +/// A precompiled matcher over the merged module `lexicon` (FR-043): the set of +/// accepted **concrete terms** the EARS vague-response check (FR-042) treats as +/// verifiable objects. Built once from the `Registry`'s merged lexicon; the +/// engine carries no hardcoded concrete-noun list. An empty lexicon (the +/// type-only `validate_document` path, or `check_grammar` without a module) +/// matches nothing — generic mechanism/bound/backtick suppression still applies. +#[derive(Debug, Default)] +pub struct GrammarLexicon { + matcher: Option, +} + +impl GrammarLexicon { + /// Build a matcher from concrete terms (the merged lexicon keys). Terms are + /// matched case-insensitively at word boundaries. An empty term set yields a + /// matcher that recognises nothing. + pub fn from_terms<'a, I: IntoIterator>(terms: I) -> Self { + let escaped: Vec = terms + .into_iter() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(regex::escape) + .collect(); + let matcher = if escaped.is_empty() { + None + } else { + Regex::new(&format!(r"(?i)\b({})\b", escaped.join("|"))).ok() + }; + Self { matcher } + } + + /// The empty lexicon — recognises no concrete term. + pub fn empty() -> Self { + Self { matcher: None } + } + + /// True when `text` contains an accepted concrete term (word-boundary). + pub fn contains_term(&self, text: &str) -> bool { + self.matcher.as_ref().is_some_and(|r| r.is_match(text)) + } +} + +/// The shared empty lexicon for the registry-free paths. +pub fn empty_lexicon() -> &'static GrammarLexicon { + static EMPTY: OnceLock = OnceLock::new(); + EMPTY.get_or_init(GrammarLexicon::empty) +} + /// Severity of a grammar finding. `Warning` is advisory (never fails /// validation); `Error` blocks. Severity is **policy**: a deployment may /// promote a grammar from advisory to enforcing without a code change — the @@ -73,9 +124,10 @@ pub fn check_document_grammar( archetype: &str, doc: &QuireDocument, line_offset: usize, + lexicon: &GrammarLexicon, ) -> Vec { match grammar_ref { - "iso-spec-core" => ears::check(archetype, doc, line_offset), + "iso-spec-core" => ears::check(archetype, doc, line_offset, lexicon), _ => Vec::new(), } } diff --git a/src/lib.rs b/src/lib.rs index d8a382b..9192af3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,7 +70,7 @@ pub use extract::{extract, ExtractionResult}; pub use extract::assert_eval::{evaluate_assert, AssertFailure, AssertReason}; pub use extract::interpolate::{interpolate, UnresolvedField}; // Declarative lint rules (FR-036) — advisory, never blocks extraction. -pub use grammar::{check_document_grammar, GrammarFinding, GrammarSeverity}; +pub use grammar::{check_document_grammar, GrammarFinding, GrammarLexicon, GrammarSeverity}; pub use lint::{lint_document, LintFinding, LintRule, LintSeverity}; // Writeback (FR-022) — byte-splice section/block edit. pub use writeback::{update_block, update_section}; diff --git a/src/loader/manifest.rs b/src/loader/manifest.rs index be0a534..9d2bfcd 100644 --- a/src/loader/manifest.rs +++ b/src/loader/manifest.rs @@ -64,6 +64,12 @@ pub struct Manifest { /// `allowed_links` target roles instead of concrete type names. #[serde(default)] pub roles: BTreeMap, + /// Mergeable concrete-term lexicon (FR-043): term → {definition, + /// optional category}. Merged across modules first-wins; the EARS + /// object-aware vague-response check (FR-042) consumes the merged keys + /// as accepted concrete objects, so the engine carries no hardcoded list. + #[serde(default)] + pub lexicon: BTreeMap, } impl Manifest { diff --git a/src/loader/mod.rs b/src/loader/mod.rs index ebf69b5..2496ba5 100644 --- a/src/loader/mod.rs +++ b/src/loader/mod.rs @@ -30,7 +30,7 @@ use crate::loader::manifest::{load_manifest, Archetype, Manifest}; use crate::loader::paths::{ default_module_root, module_path_env, resolve_search_paths, PathDiagnostic, }; -use crate::vocab::{EdgeTypeDef, RoleDef}; +use crate::vocab::{EdgeTypeDef, LexiconTermDef, RoleDef}; /// Module-level entry produced by [`load_modules`]. #[derive(Debug)] @@ -45,6 +45,8 @@ pub struct LoadedModule { pub edge_types: BTreeMap, /// Role registry contributed by this module (FR-040). pub roles: BTreeMap, + /// Concrete-term lexicon contributed by this module (FR-043). + pub lexicon: BTreeMap, } /// Outcome of a full load pass. @@ -350,6 +352,7 @@ pub fn load_inline_module(manifest_yaml: &[u8], schemas: &BTreeMap RegistryShape { .diagnostics .push(Diagnostic::DuplicateRole { name, modules }); } + // FR-043: merge the concrete-term lexicon (same first-wins machinery). + let (lexicon, mut lexicon_dups) = merge_vocab(&outcome.modules, |m| &m.lexicon); + for (name, modules) in lexicon_dups.drain(..) { + outcome + .diagnostics + .push(Diagnostic::DuplicateLexiconTerm { name, modules }); + } // ── FR-041: derive the inverse-label → forward-verb index from the // merged edge_types. A declared `inverse:` label becomes an authorable @@ -769,6 +780,7 @@ pub fn flatten_into_registry(mut outcome: LoadOutcome) -> RegistryShape { edge_types, inverse_edges, roles, + lexicon, failures: outcome.failures, diagnostics: outcome.diagnostics, path_diagnostics: outcome.path_diagnostics, @@ -849,6 +861,13 @@ pub fn flatten_into_registry_strict(outcome: LoadOutcome) -> Result { + return Err(QuireError::EdgeVocabularyViolation { + kind: "DuplicateLexiconTerm".to_string(), + name: name.clone(), + }); + } // FR-041-AC-3: load_strict escalates a colliding inverse label. Diagnostic::DuplicateInverseEdge { name, .. } => { return Err(QuireError::EdgeVocabularyViolation { @@ -896,6 +915,8 @@ pub struct RegistryShape { pub inverse_edges: BTreeMap, /// Merged role registry, first-wins across modules (FR-040). pub roles: BTreeMap, + /// Merged concrete-term lexicon, first-wins across modules (FR-043). + pub lexicon: BTreeMap, pub failures: Vec, pub diagnostics: Vec, pub path_diagnostics: Vec, diff --git a/src/python/mod.rs b/src/python/mod.rs index c8a6d1c..2b9e05f 100644 --- a/src/python/mod.rs +++ b/src/python/mod.rs @@ -145,21 +145,39 @@ fn validate_document<'py>( /// `validate_document`, this surfaces the findings directly (not folded into /// validation warnings) so the corpus report and review lens can consume them. #[pyfunction] +#[pyo3(signature = (grammar_ref, archetype, document_text, module_root=None))] fn check_grammar<'py>( py: Python<'py>, grammar_ref: &str, archetype: &str, document_text: &str, + module_root: Option<&str>, ) -> PyResult> { let gref = grammar_ref.to_string(); let arch = archetype.to_string(); let text = document_text.to_string(); + let module = module_root.map(str::to_string); - let findings = py.detach(|| { + let findings = py.detach(|| -> PyResult> { let doc = crate::parse_document(&text); let line_offset = crate::validate_document::body_line_offset(&text); - crate::grammar::check_document_grammar(&gref, &arch, &doc, line_offset) - }); + // FR-043: with a module, apply its merged lexicon; without, empty. + let empty = crate::grammar::GrammarLexicon::empty(); + let registry = match &module { + Some(m) => Some( + crate::Registry::load_module(Path::new(m)).map_err(quire_error_to_schema_pyerr)?, + ), + None => None, + }; + let lexicon = registry.as_ref().map_or(&empty, |r| r.lexicon_matcher()); + Ok(crate::grammar::check_document_grammar( + &gref, + &arch, + &doc, + line_offset, + lexicon, + )) + })?; let out = PyList::empty(py); for f in findings { diff --git a/src/registry.rs b/src/registry.rs index f72fcad..4b6a18d 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -36,6 +36,9 @@ struct Inner { edge_types: std::collections::BTreeMap, inverse_edges: std::collections::BTreeMap, roles: std::collections::BTreeMap, + /// Merged concrete-term lexicon (FR-043) + its precompiled matcher. + lexicon: std::collections::BTreeMap, + lexicon_matcher: crate::grammar::GrammarLexicon, failures: Vec, diagnostics: Vec, path_diagnostics: Vec, @@ -182,10 +185,14 @@ impl Registry { edge_types, inverse_edges, roles, + lexicon, failures, diagnostics, path_diagnostics, } = shape; + // FR-043: precompile the matcher once from the merged lexicon keys. + let lexicon_matcher = + crate::grammar::GrammarLexicon::from_terms(lexicon.keys().map(String::as_str)); Self { inner: Arc::new(Inner { archetypes, @@ -196,6 +203,8 @@ impl Registry { edge_types, inverse_edges, roles, + lexicon, + lexicon_matcher, failures, diagnostics, path_diagnostics, @@ -284,6 +293,19 @@ impl Registry { &self.inner.roles } + /// Merged concrete-term lexicon (FR-043), first-wins across modules. The + /// EARS object-aware vague-response check consumes these as accepted + /// concrete objects; the engine carries no hardcoded noun list. + pub fn lexicon(&self) -> &std::collections::BTreeMap { + &self.inner.lexicon + } + + /// The precompiled matcher over the merged lexicon (FR-043), passed to the + /// grammar on the registry-backed validation path. + pub fn lexicon_matcher(&self) -> &crate::grammar::GrammarLexicon { + &self.inner.lexicon_matcher + } + /// Inverse-label → forward-verb index (FR-041). A declared `inverse:` /// label is an authorable verb (a derived view of its forward edge); /// this maps each such label to the forward verb that declared it. @@ -750,6 +772,65 @@ roles: .any(|d| matches!(d, Diagnostic::DuplicateEdgeType { name, .. } if name == "calls"))); } + // TC-667/668 (FR-043-AC-1/2): a `lexicon` term merges first-wins across + // modules, is readable via `Registry::lexicon()`, and a conflicting + // redeclaration emits one `DuplicateLexiconTerm`; the precompiled matcher + // recognises a merged term. + #[test] + fn tc667_668_lexicon_merge_and_accessor() { + let p = tmpdir("lexicon-667"); + write_vocab_module( + &p.join("m1"), + "m1", + "lexicon:\n pagination:\n definition: page-splitting\n cursor:\n definition: position token\n", + ); + write_vocab_module( + &p.join("m2"), + "m2", + "lexicon:\n pagination:\n definition: DIFFERENT\n", + ); + let r = Registry::load_from(&[&p]).expect("tolerant ok"); + assert_eq!(r.lexicon()["pagination"].definition, "page-splitting"); // first-wins + assert!(r.lexicon().contains_key("cursor")); + assert!(r.diagnostics().iter().any( + |d| matches!(d, Diagnostic::DuplicateLexiconTerm { name, .. } if name == "pagination") + )); + assert!(r + .lexicon_matcher() + .contains_term("supports pagination today")); + assert!(!r.lexicon_matcher().contains_term("unrelated text")); + } + + // TC-672 (FR-043-AC-6): the registry-backed path applies the merged + // lexicon; the type-only path applies an empty one (more findings). + #[test] + fn tc672_registry_vs_type_only_lexicon_paths() { + let p = tmpdir("lexicon-672"); + let m = p.join("m"); + fs::create_dir_all(m.join("schemas")).unwrap(); + fs::write( + m.join("manifest.yaml"), + "name: m\nartifact_types:\n- name: FR\n frontmatter_schema_ref: schemas/fr.schema.json\n grammar_ref: iso-spec-core\nlexicon:\n pagination:\n definition: page splitting\n", + ) + .unwrap(); + fs::write(m.join("schemas/fr.schema.json"), r#"{"type":"object"}"#).unwrap(); + let r = Registry::load_from(&[&p]).expect("tolerant ok"); + let fr = r.archetype("FR").expect("FR archetype"); + let doc = "---\ntype: FR\n---\n## Description\n\nThe system shall support pagination.\n"; + // Registry path: lexicon has `pagination` → no vague-response. + let with_reg = crate::validate_document_in_registry(&r, fr, doc); + assert!(!with_reg + .warnings + .iter() + .any(|w| w.message.contains("[ears:vague-response]"))); + // Type-only path: empty lexicon → vague-response present. + let type_only = crate::validate_document(fr, doc); + assert!(type_only + .warnings + .iter() + .any(|w| w.message.contains("[ears:vague-response]"))); + } + // TC-650 (FR-040-AC-3): unknown verb/role → non-fatal diagnostic // (default load succeeds); load_strict escalates to an error. #[test] diff --git a/src/validate_document.rs b/src/validate_document.rs index ec8cc01..aac7cbb 100644 --- a/src/validate_document.rs +++ b/src/validate_document.rs @@ -159,7 +159,16 @@ pub fn validate_document(archetype: &CompiledArchetype, doc_text: &str) -> Valid check_heading_uniqueness(&doc, line_offset, &mut errors); let mut warnings: Vec = Vec::new(); - run_grammar(archetype, &doc, line_offset, &mut errors, &mut warnings); + // Type-only path: no registry, so an empty lexicon (FR-043) — only the + // engine-generic mechanism/bound/backtick suppression applies. + run_grammar( + archetype, + &doc, + line_offset, + crate::grammar::empty_lexicon(), + &mut errors, + &mut warnings, + ); ValidationResult::new(errors, warnings) } @@ -168,19 +177,26 @@ pub fn validate_document(archetype: &CompiledArchetype, doc_text: &str) -> Valid /// its `grammar_ref`, routing findings into `errors`/`warnings` by severity. /// No `grammar_ref` (or an unknown bundle) is a no-op — grammar checking is /// advisory by construction and never the reason a document fails to validate -/// in v1 (severity is policy, defaulting to `warning`). +/// in v1 (severity is policy, defaulting to `warning`). `lexicon` is the merged +/// concrete-term lexicon (FR-043); the type-only path passes an empty one. fn run_grammar( archetype: &CompiledArchetype, doc: &QuireDocument, line_offset: usize, + lexicon: &crate::grammar::GrammarLexicon, errors: &mut Vec, warnings: &mut Vec, ) { let Some(grammar_ref) = archetype.grammar_ref() else { return; }; - for f in crate::grammar::check_document_grammar(grammar_ref, &archetype.name, doc, line_offset) - { + for f in crate::grammar::check_document_grammar( + grammar_ref, + &archetype.name, + doc, + line_offset, + lexicon, + ) { route_grammar_finding(f, errors, warnings); } } @@ -314,7 +330,15 @@ pub fn validate_document_in_registry( } // ── Requirement-grammar layer (EARS, FR-042; advisory by default) ── - run_grammar(archetype, &doc, line_offset, &mut errors, &mut warnings); + // Registry-backed path: pass the merged concrete lexicon (FR-043). + run_grammar( + archetype, + &doc, + line_offset, + registry.lexicon_matcher(), + &mut errors, + &mut warnings, + ); ValidationResult::new(errors, warnings) } diff --git a/src/vocab.rs b/src/vocab.rs index ca10900..cd903b8 100644 --- a/src/vocab.rs +++ b/src/vocab.rs @@ -73,6 +73,17 @@ pub struct RoleDef { pub description: String, } +/// One `lexicon` registry entry (FR-043). A module-declared **concrete term** +/// the EARS object-aware vague-response check (FR-042) accepts as a verifiable +/// object. The engine uses the term (the map key); `definition` is documentation +/// (and feeds a future glossary surface). `category` is an optional grouping tag. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LexiconTermDef { + pub definition: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, +} + /// Normalize a raw `allowed_links` value (array **or** map) into the /// canonical [`AllowedLinks`] map (FR-040-AC-4). /// diff --git a/tests/python/test_bindings.py b/tests/python/test_bindings.py index 9a8cc38..9e3e522 100644 --- a/tests/python/test_bindings.py +++ b/tests/python/test_bindings.py @@ -62,6 +62,27 @@ def test_check_grammar_ears_findings_cross_boundary(): assert quire.check_grammar("nonexistent", "FR", md) == [] +def test_check_grammar_applies_module_lexicon(tmp_path): + """TC-673 (FR-043-AC-7): check_grammar applies a module's concrete lexicon + when given module_root; without one it uses an empty lexicon.""" + md = ( + "---\nid: FR-001\ntype: FR\n---\n" + "## Description\n\nThe system shall support pagination.\n" + ) + # No module → empty lexicon → the bare noun is flagged. + findings = quire.check_grammar("iso-spec-core", "FR", md) + assert any(f["check"] == "vague-response" for f in findings) + + # A module declaring `pagination` in its lexicon → suppressed. + mod = tmp_path / "m" + (mod / "schemas").mkdir(parents=True) + (mod / "manifest.yaml").write_text( + "name: m\nlexicon:\n pagination:\n definition: page splitting\n" + ) + findings2 = quire.check_grammar("iso-spec-core", "FR", md, str(mod)) + assert not any(f["check"] == "vague-response" for f in findings2) + + def test_load_repo_returns_documents(tmp_path): """TC-463: load_repo yields one structured doc per markdown file.""" (tmp_path / "FR-001.md").write_text( From d7baee5f7a08b5114f1033ffd4249773fab0de7f Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 08:57:12 -0700 Subject: [PATCH 05/11] refine(grammar): plural lexicon matching + dual-load in check_grammar (FR-043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrammarLexicon matches the regular plural (`endpoint`→`endpoints`); corpus objects are frequently plural (mirrors the old concrete-noun `s?` rule). - check_grammar's module_root accepts a search root (merging child modules) as well as a single module dir, like the CLI — so the merged project lexicon applies for the corpus report and review lens. Corpus re-baseline with the distributed spec-objects-* lexicons: vague-response 149 (vs 173 under the old hardcoded engine list) — a strict superset, so no regressions, and the soft-tail concretes (pagination/deployment/token) are now suppressed too. Co-Authored-By: Claude Opus 4.8 --- src/grammar/mod.rs | 4 +++- src/python/mod.rs | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/grammar/mod.rs b/src/grammar/mod.rs index 38c7f50..b578c7b 100644 --- a/src/grammar/mod.rs +++ b/src/grammar/mod.rs @@ -45,10 +45,12 @@ impl GrammarLexicon { .filter(|t| !t.is_empty()) .map(regex::escape) .collect(); + // `s?` matches the regular plural (`endpoint`→`endpoints`) — corpus + // objects are frequently plural; mirrors the old concrete-noun rule. let matcher = if escaped.is_empty() { None } else { - Regex::new(&format!(r"(?i)\b({})\b", escaped.join("|"))).ok() + Regex::new(&format!(r"(?i)\b({})s?\b", escaped.join("|"))).ok() }; Self { matcher } } diff --git a/src/python/mod.rs b/src/python/mod.rs index 2b9e05f..6a9afba 100644 --- a/src/python/mod.rs +++ b/src/python/mod.rs @@ -163,10 +163,19 @@ fn check_grammar<'py>( let line_offset = crate::validate_document::body_line_offset(&text); // FR-043: with a module, apply its merged lexicon; without, empty. let empty = crate::grammar::GrammarLexicon::empty(); + // `module_root` may be a single module dir (has `manifest.yaml`) or a + // search root whose children are modules — merged like the CLI does. let registry = match &module { - Some(m) => Some( - crate::Registry::load_module(Path::new(m)).map_err(quire_error_to_schema_pyerr)?, - ), + Some(m) => { + let path = Path::new(m); + let r = if path.join("manifest.yaml").is_file() { + crate::Registry::load_module(path) + } else { + crate::Registry::load_from(&[path]) + } + .map_err(quire_error_to_schema_pyerr)?; + Some(r) + } None => None, }; let lexicon = registry.as_ref().map_or(&empty, |r| r.lexicon_matcher()); From 2d1b2c3ba5dc8f611f8ac545859944ba66670814 Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 10:05:36 -0700 Subject: [PATCH 06/11] refine(grammar): irregular-plural lexicon matching (FR-043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GrammarLexicon now contributes both singular and plural forms per term, including irregular plurals (`policy`→`policies`, `box`→`boxes`, `endpoint`→`endpoints`) — corpus objects are frequently plural and the prior `s?` missed `-ies`/`-es`. Corpus re-baseline (with the module top-up): vague-response 149 → 122. Co-Authored-By: Claude Opus 4.8 --- src/grammar/ears.rs | 28 ++++++++++++++++++++++++++++ src/grammar/mod.rs | 45 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/grammar/ears.rs b/src/grammar/ears.rs index d2805cb..0894d1d 100644 --- a/src/grammar/ears.rs +++ b/src/grammar/ears.rs @@ -611,6 +611,34 @@ mod tests { assert_eq!(count(&fr("The system shall provide flexibility.")), 1); } + // A lexicon term suppresses its plural — including irregular plurals + // (FR-043 plural matching; corpus objects are frequently plural). + #[test] + fn lexicon_matches_plurals() { + let fr = |body: &str| format!("---\ntype: FR\n---\n## Description\n\n{body}\n"); + let count = |text: &str, lexicon: &GrammarLexicon| { + check("FR", &doc(text), 0, lexicon) + .iter() + .filter(|x| x.check == "vague-response") + .count() + }; + assert_eq!( + count( + &fr("The system shall support endpoints."), + &lex(&["endpoint"]) + ), + 0 + ); // -s + assert_eq!( + count(&fr("The system shall enforce policies."), &lex(&["policy"])), + 0 + ); // -ies + assert_eq!( + count(&fr("The system shall provide boxes."), &lex(&["box"])), + 0 + ); // -es + } + // TC-661 (FR-042-AC-5): non-canonical trigger fires; NFR no-trigger does not. #[test] fn tc661_non_canonical_trigger() { diff --git a/src/grammar/mod.rs b/src/grammar/mod.rs index b578c7b..a7f729b 100644 --- a/src/grammar/mod.rs +++ b/src/grammar/mod.rs @@ -39,18 +39,22 @@ impl GrammarLexicon { /// matched case-insensitively at word boundaries. An empty term set yields a /// matcher that recognises nothing. pub fn from_terms<'a, I: IntoIterator>(terms: I) -> Self { - let escaped: Vec = terms - .into_iter() - .map(str::trim) - .filter(|t| !t.is_empty()) - .map(regex::escape) - .collect(); - // `s?` matches the regular plural (`endpoint`→`endpoints`) — corpus - // objects are frequently plural; mirrors the old concrete-noun rule. - let matcher = if escaped.is_empty() { + // Each term contributes both its singular and its plural form, so + // corpus objects match regardless of number — including irregular + // plurals (`policy`→`policies`, `box`→`boxes`). + let mut forms: Vec = Vec::new(); + for t in terms { + let t = t.trim(); + if t.is_empty() { + continue; + } + forms.push(regex::escape(t)); + forms.push(regex::escape(&pluralize(t))); + } + let matcher = if forms.is_empty() { None } else { - Regex::new(&format!(r"(?i)\b({})s?\b", escaped.join("|"))).ok() + Regex::new(&format!(r"(?i)\b({})\b", forms.join("|"))).ok() }; Self { matcher } } @@ -66,6 +70,27 @@ impl GrammarLexicon { } } +/// Naive English pluralizer for lexicon terms: consonant+`y`→`ies`, sibilant +/// (`s`/`x`/`z`/`ch`/`sh`)→`es`, else `+s`. Lexicon terms are lowercase ASCII +/// nouns, so byte handling is safe. +fn pluralize(term: &str) -> String { + let lower = term.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + let n = bytes.len(); + if n >= 2 && bytes[n - 1] == b'y' && !b"aeiou".contains(&bytes[n - 2]) { + format!("{}ies", &term[..n - 1]) + } else if lower.ends_with('s') + || lower.ends_with('x') + || lower.ends_with('z') + || lower.ends_with("ch") + || lower.ends_with("sh") + { + format!("{term}es") + } else { + format!("{term}s") + } +} + /// The shared empty lexicon for the registry-free paths. pub fn empty_lexicon() -> &'static GrammarLexicon { static EMPTY: OnceLock = OnceLock::new(); From cb030eba0c2875ee465381247c0bc1e36f12f4b8 Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 10:16:55 -0700 Subject: [PATCH 07/11] feat(grammar): project Ubiquitous-Language lexicon (FR-044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo's own authored glossary terms now suppress in its grammar check, alongside the merged module lexicon (FR-043). The immutable Registry is unchanged: project terms — which vary per repo — are composed into an ad-hoc GrammarLexicon at validation time and injected. - corpus::glossary_terms(&Spec): harvests the `Term` column of every `## Terms` table and the bold term of every `## Ubiquitous Language` bullet (reuses query::table_from_section + parse_bullet_list). - Registry::lexicon_with(extra): composes module keys ∪ project terms. - validate_document_in_registry_with_lexicon: injects an explicit lexicon (the existing entry point delegates to it with the module lexicon — one shared body). - corpus::validate_bundle harvests the Spec once and validates each doc with the combined lexicon; advisory (never a bundle error). - spec: FR-044 + integrity SpecReview + matrix to 374/374 (TC-674..680), authored via /specify → /spec-matrix → /spec-review. Also splits the FR-043 TC tests so each TC-id maps to its own test function. Co-Authored-By: Claude Opus 4.8 --- .../FR-044-project-glossary-lexicon.md | 66 +++++++ spec/functional/index.md | 1 + spec/reviews/fr044-integrity.md | 25 +++ spec/tests.md | 19 +- src/corpus/glossary.rs | 165 ++++++++++++++++++ src/corpus/mod.rs | 2 + src/corpus/validate.rs | 17 +- src/grammar/ears.rs | 53 +++--- src/lib.rs | 5 +- src/registry.rs | 91 ++++++++-- src/validate_document.rs | 30 +++- 11 files changed, 434 insertions(+), 40 deletions(-) create mode 100644 spec/functional/FR-044-project-glossary-lexicon.md create mode 100644 spec/reviews/fr044-integrity.md create mode 100644 src/corpus/glossary.rs diff --git a/spec/functional/FR-044-project-glossary-lexicon.md b/spec/functional/FR-044-project-glossary-lexicon.md new file mode 100644 index 0000000..ad5f07b --- /dev/null +++ b/spec/functional/FR-044-project-glossary-lexicon.md @@ -0,0 +1,66 @@ +--- +id: FR-044 +title: "Project Ubiquitous-Language Lexicon for the Grammar" +type: FR +relationships: + - target: "ix://agent-ix/quire-rs/spec/functional/FR-043" + type: "requires" + cardinality: "1:1" + - target: "ix://agent-ix/quire-rs/spec/functional/FR-027" + type: "requires" + cardinality: "1:1" +--- + +# [FR-044] Project Ubiquitous-Language Lexicon for the Grammar + +## Description + +A repository MAY author its own **concrete vocabulary** — a `Glossary` artifact carrying a +`## Terms` table (`Term | Definition`), or a `## Ubiquitous Language` section on a domain object. The +engine SHALL harvest those project terms and feed them, alongside the merged module lexicon +([FR-043](./FR-043-module-concrete-lexicon.md)), into the EARS object-aware vague-response check +([FR-042](./FR-042-requirement-grammar-check.md)), so a repo's domain terms are accepted as concrete +objects in its own grammar check. + +The engine SHALL provide a **harvester** that reads project terms from a loaded `Spec` corpus +([FR-027](./FR-027-whole-spec-query-api.md)). The harvester SHALL collect the `Term` column of every +`Glossary` artifact's `## Terms` table. The harvester SHALL also collect the bold term of every +`## Ubiquitous Language` bullet. A repository that authors no glossary SHALL harvest an empty term +set. + +The merged module lexicon lives on the immutable `Registry`; the project terms vary per repository, +so the engine SHALL NOT store them on the `Registry`. Instead, the orchestrator SHALL compose an +ad-hoc `GrammarLexicon` from the registry's lexicon keys and the harvested project terms, and SHALL +pass that combined lexicon to the grammar. + +The engine SHALL expose `validate_document_in_registry_with_lexicon`, which validates a document +against an explicitly supplied `GrammarLexicon` rather than the registry's own. The existing +`validate_document_in_registry` SHALL delegate to it with `Registry::lexicon_matcher()`, so the two +entry points share one validation body. + +When a `Spec` corpus is validated as a bundle, `validate_bundle` SHALL harvest the corpus's project +terms once and SHALL validate each document with the combined lexicon. + +## Constraints + +| ID | Constraint | Type | Validation | +|----|------------|------|------------| +| FR-044-CON-1 | The project-glossary layer SHALL remain advisory — a harvested term only suppresses `vague-response` findings and never blocks validation | Operational | Unit Test | +| FR-044-CON-2 | A malformed or absent glossary SHALL harvest zero terms without failing validation | Operational | Unit Test | + +## Acceptance Criteria + +| ID | Criteria | Verification | +|----|----------|--------------| +| FR-044-AC-1 | The harvester collects the `Term` column of a `Glossary` artifact's `## Terms` table into the project term set. | Test | +| FR-044-AC-2 | The harvester collects the bold term of each `## Ubiquitous Language` bullet (`- **Term** — …`) into the project term set. | Test | +| FR-044-AC-3 | A combined `GrammarLexicon` contains both the registry lexicon keys and the harvested project terms; a project-only term is recognised as concrete. | Test | +| FR-044-AC-4 | `validate_document_in_registry_with_lexicon` injects the supplied lexicon: a document whose response object is a project term yields no `vague-response` finding, while the same document under the module-only lexicon yields one. | Test | +| FR-044-AC-5 | `validate_bundle` harvests the loaded `Spec`'s project terms and applies the combined lexicon to every document in the bundle. | Test | +| FR-044-AC-6 | The project-glossary suppression is advisory: a document with project-glossary-suppressed and remaining findings still reports `is_valid` per its structural errors alone. | Test | +| FR-044-AC-7 | A repository that authors no glossary harvests an empty term set, and its validation is identical to the module-only lexicon path. | Test | + +## Dependencies + +- **Upstream**: [FR-043](./FR-043-module-concrete-lexicon.md) (the merged module lexicon this composes with), [FR-027](./FR-027-whole-spec-query-api.md) (the whole-spec corpus the harvester reads) +- **Downstream**: `quire-cli` `validate` harvests the `--scope` repo's glossary terms and injects the combined lexicon; the `Glossary` artifact ships in `spec-artifacts-iso` diff --git a/spec/functional/index.md b/spec/functional/index.md index f9862c3..87fbf69 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -43,3 +43,4 @@ description: "Index of artifacts in this directory." * [FR-041: Authorable Inverse Edge Verbs](./FR-041-authorable-inverse-edges.md) * [FR-042: Requirement-Grammar Check (EARS)](./FR-042-requirement-grammar-check.md) * [FR-043: Module-Supplied Concrete Lexicon for the Grammar](./FR-043-module-concrete-lexicon.md) +* [FR-044: Project Ubiquitous-Language Lexicon for the Grammar](./FR-044-project-glossary-lexicon.md) diff --git a/spec/reviews/fr044-integrity.md b/spec/reviews/fr044-integrity.md new file mode 100644 index 0000000..b8c749c --- /dev/null +++ b/spec/reviews/fr044-integrity.md @@ -0,0 +1,25 @@ +--- +id: SR-044 +title: "integrity review of FR-044 (project Ubiquitous-Language lexicon)" +type: SpecReview +analysis: integrity +scope: "spec/functional/FR-044-project-glossary-lexicon.md" +review_set: subset +--- + +## Summary + +Pre-build review of FR-044 (base checklist + integrity + EARS conformance). The requirement is +well-formed, complete, and traceable: all 7 ACs map to TC-674..680, both constraints are covered +(CON-1→AC-6, CON-2→AC-7), it traces upstream to FR-043 (the merged lexicon) and FR-027 (the corpus), +and its `SHALL` statements validate EARS-clean (no non-singular/vague/missing-subject findings). +Two low, non-blocking findings; nothing high or medium. **Ready to build.** + +## Findings + +| ID | Severity | Summary | Refs | +| ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | +| FND-001 | low | AC-3 bundles two assertions (combined lexicon contains both sources AND a project-only term is recognised concrete); coherent as one test, acceptable — could split if a reviewer prefers strict atomicity. | FR-044-AC-3 | +| FND-002 | low | The "existing `validate_document_in_registry` delegates to the `…_with_lexicon` variant" refactor invariant has no dedicated AC; behavioral equivalence is covered indirectly by AC-7 (no-glossary path identical to module-only). Accepted. | FR-044-AC-7 | +| FND-003 | low | Harvested-term normalization (trim, empty-cell skip, case-insensitive match, multi-word terms) is inherited from `GrammarLexicon::from_terms` (FR-043) and not re-asserted here. No action — correct by construction. | FR-044-AC-1 | +| FND-004 | low | EARS conformance + coverage + traceability: no issues. All ACs covered by TCs; requirement statements EARS-clean; upstream traces resolve. | FR-044 | diff --git a/spec/tests.md b/spec/tests.md index ba9c4ef..6494805 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -100,6 +100,7 @@ The spec was revised after authoring to reflect the **archetype-as-data** model: | FR-041 Authorable inverse edge verbs | AC-1..5 | TC-652 (inverse index), TC-653 (Tier-1 recognition), TC-654 (precedence + DuplicateInverseEdge), TC-655 (Tier-2 forward normalization), TC-656 (warn-only determinism) | 🚧 Pending implementation | | FR-042 Requirement-grammar check (EARS) | AC-1..10 | TC-657 (6 patterns + unclassifiable), TC-658 (non-singular + enumerated-stem exemption), TC-659 (missing-subject + StR relaxed subject), TC-660 (vague-response + passive exemption), TC-661 (non-canonical-trigger + NFR no-trigger), TC-662 (archetype/section binding), TC-663 (warning vs error severity routing), TC-664 (finding fields), TC-665 (fence/quote/reference skip), TC-666 (PyO3 parity) | 🚧 Pending implementation | | FR-043 Module-supplied concrete lexicon | AC-1..7 | TC-667 (lexicon registry load + accessor), TC-668 (first-wins merge + DuplicateLexiconTerm), TC-669 (lexicon term suppresses vague-response; removed re-flags), TC-670 (no hardcoded noun list under empty lexicon), TC-671 (backtick/mechanism/bound hold under empty lexicon), TC-672 (registry vs type-only lexicon paths), TC-673 (PyO3 check_grammar module_root) | 🚧 Pending implementation | +| FR-044 Project Ubiquitous-Language lexicon | AC-1..7 | TC-674 (harvest Term column from Glossary `## Terms` table), TC-675 (harvest bold term from `## Ubiquitous Language` bullets), TC-676 (combined lexicon = registry keys ∪ project terms), TC-677 (validate_document_in_registry_with_lexicon injects lexicon), TC-678 (validate_bundle harvests Spec + applies combined lexicon), TC-679 (advisory: project suppression never changes is_valid), TC-680 (no glossary → empty terms → module-only path) | 🚧 Pending implementation | ### Non-Functional Requirement Coverage @@ -358,6 +359,13 @@ The spec was revised after authoring to reflect the **archetype-as-data** model: | TC-671 | Under an empty lexicon, backticked-identifier / mechanism / numeric-bound suppression (FR-042) still hold | Unit | P0 | FR-043-AC-5 | 🚧 | | TC-672 | `validate_document_in_registry` applies the registry lexicon; type-only `validate_document` applies an empty lexicon (more findings) and never errors on the difference | Unit | P0 | FR-043-AC-6 | 🚧 | | TC-673 | The `check_grammar` PyO3 binding with `module_root` applies that registry's lexicon; without one applies an empty lexicon | Integration | P0 | FR-043-AC-7, FR-023 | 🚧 | +| TC-674 | The glossary harvester collects the `Term` column of a `Glossary` artifact's `## Terms` table into the project term set | Unit | P0 | FR-044-AC-1 | 🚧 | +| TC-675 | The harvester collects the bold term of each `## Ubiquitous Language` bullet (`- **Term** — …`) into the project term set | Unit | P0 | FR-044-AC-2 | 🚧 | +| TC-676 | A combined `GrammarLexicon` contains both the registry lexicon keys and the harvested project terms; a project-only term is recognised concrete | Unit | P0 | FR-044-AC-3 | 🚧 | +| TC-677 | `validate_document_in_registry_with_lexicon` injects the supplied lexicon — a project-term object yields no vague-response, while the module-only lexicon yields one | Unit | P0 | FR-044-AC-4 | 🚧 | +| TC-678 | `validate_bundle` harvests the loaded `Spec`'s project terms and applies the combined lexicon to every document in the bundle | Integration | P0 | FR-044-AC-5, FR-027 | 🚧 | +| TC-679 | Project-glossary suppression is advisory — a doc with project-suppressed + remaining findings reports `is_valid` per its structural errors alone | Unit | P0 | FR-044-AC-6 | 🚧 | +| TC-680 | A repository with no glossary harvests an empty term set; its validation is identical to the module-only lexicon path | Unit | P0 | FR-044-AC-7 | 🚧 | | TC-502 | Static audit: no Mutex/RwLock/Atomic in first-party src/; parallel parse collects owned results | Static | P0 | FR-024-AC-9 | 🚧 | | TC-503 | loom: parallel parse collection race-free; identical path-sorted output across all interleavings | Property | P0 | NFR-017-AC-1..3 | 🚧 | | TC-504 | TSAN lane: two-thread `load_repo` (GIL-release window) reports zero data races | Integration | P0 | NFR-018-AC-1, NFR-018-AC-3 | 🚧 | @@ -891,6 +899,13 @@ Comprehensive, post-audit explicit mapping. Every AC defined in the spec is list | FR-043-AC-5 | TC-671 | | FR-043-AC-6 | TC-672 | | FR-043-AC-7 | TC-673 | +| FR-044-AC-1 | TC-674 | +| FR-044-AC-2 | TC-675 | +| FR-044-AC-3 | TC-676 | +| FR-044-AC-4 | TC-677 | +| FR-044-AC-5 | TC-678 | +| FR-044-AC-6 | TC-679 | +| FR-044-AC-7 | TC-680 | ### Non-Functional Requirements @@ -956,7 +971,7 @@ Comprehensive, post-audit explicit mapping. Every AC defined in the spec is list | NFR-019-AC-1 | TC-579 | | NFR-019-AC-2 | TC-580 | -**Coverage status: 367 / 367 ACs covered (100%).** The module-lexicon slice (FR-043, ADR 0009, 2026-06-23) adds FR-043-AC-1..7 (modules ship a mergeable `lexicon:` registry the EARS object-aware vague-response check consumes; the engine drops its hardcoded concrete-noun list; the type-only path degrades to an empty lexicon; PyO3 `check_grammar` gains `module_root` — TC-667..673) — 7 ACs. The requirement-grammar slice (FR-042, EARS, 2026-06-22) adds FR-042-AC-1..10 (grammar-check framework with EARS as the first grammar: six-pattern classification, the non-singular/missing-subject/vague-response/non-canonical-trigger clause checks with per-archetype dialects, warning→error severity routing into `ValidationResult`, fenced/quote/reference skip, and PyO3 parity — TC-657..666) — 10 ACs. The authorable-inverse-edges slice (FR-041, ADR 0008, 2026-06-21) adds FR-041-AC-1..5 (declared `inverse:` labels become authorable as derived views of their forward edge: inverse index, Tier-1 recognition, precedence/`DuplicateInverseEdge`, Tier-2 forward normalization, warn-only determinism, TC-652..656) — 5 ACs. The object-edge-vocabulary slice (FR-040, 2026-06-20) adds FR-040-AC-1..11 (object-axis typed edge vocabulary + cross-domain role-typed targets: mergeable `edge_types`/`roles` registries with first-wins+diagnostic merge, object `roles` parsed onto the archetype, array|map `allowed_links`, union resolution, warn-tier Tier-1/Tier-2 validation, composed skeleton, TC-636..645 + TC-650/651) and US-015-AC-1..4 (author declares an object's relationship vocabulary, TC-646..649) — 15 ACs. The per-value assert slice (CR-010, 2026-06-20) adds FR-033-AC-11..13 (`choices` scalar enum + `column_choices`/`column_patterns` per-column table validation, TC-633..635) — 3 ACs. The internal-links slice (ADR 0007, 2026-06-17) adds FR-026-AC-9..11 (relative-path link edge source + index/log exclusion + dedup parity, TC-620..622) and FR-039-AC-1..10 (unlinked-reference detection & autofix suggestions, incl. AC-10 multi-token code-span skip, TC-623..632) — 13 ACs. The composed type+object validation slice (2026-06-16) adds FR-032-AC-11..13 (`validate_document_in_registry` composes the `type` archetype with the frontmatter `object:` archetype; resolved-object failures are errors, unknown-object is a warning, `ValidationResult` carries typed `warnings`) — TC-610..613, 3 ACs. The assert/lint extension slice (2026-06-16) adds FR-033-AC-10 (CR-008 `matches` content assert, TC-608) and FR-036-AC-6 (CR-009 `section_body_pattern` lint rule, TC-609) — 2 ACs. The OKF slice (2026-06-16) adds FR-037-AC-1..6 (base concept frontmatter schema, TC-590..596 + TC-528) and FR-038-AC-1..8 (OKF bundle validation, TC-600..607) — 14 ACs. v0.4 adds FR-011-AC-21 (CR-006 `multiple: true`, TC-583) and FR-036-AC-1..5 (declarative lint rules, TC-584..588). v0.2 block model added 16 ACs (FR-019..022, TC-400..443). v0.3 adds 81 ACs — StR-005/006, US-011..013, FR-023..027 (incl. review-added FR-026-AC-8, FR-027-AC-9), NFR-015/016, plus the hardening re-review (NFR-003-AC-4, FR-024-AC-9, NFR-017, NFR-018) — covered by TC-455..507 (plus reused TC-456..459). The Miri ACs (NFR-012-AC-1..5) were **retired** (ADR 0006) and the compile-time **NFR-003-AC-5** (`forbid(unsafe_code)`, TC-582) added. PC (performance criteria) for US-011..013 are tracked as benches (TC-455..459, TC-469) and marked 🚧 pending implementation, consistent with the US-006..010 perf-bench convention. The v0.3 hardening re-review (loom NFR-017, TSAN/ASAN NFR-018) is recorded in spec.md §19. +**Coverage status: 374 / 374 ACs covered (100%).** The project-glossary slice (FR-044, 2026-06-23) adds FR-044-AC-1..7 (a repo's authored Ubiquitous-Language terms — a `Glossary` `## Terms` table + `## Ubiquitous Language` bullets — are harvested and composed with the module lexicon into an ad-hoc `GrammarLexicon` injected via `validate_document_in_registry_with_lexicon`; the corpus `validate_bundle` applies it per doc; advisory and a no-op when no glossary exists — TC-674..680) — 7 ACs. The module-lexicon slice (FR-043, ADR 0009, 2026-06-23) adds FR-043-AC-1..7 (modules ship a mergeable `lexicon:` registry the EARS object-aware vague-response check consumes; the engine drops its hardcoded concrete-noun list; the type-only path degrades to an empty lexicon; PyO3 `check_grammar` gains `module_root` — TC-667..673) — 7 ACs. The requirement-grammar slice (FR-042, EARS, 2026-06-22) adds FR-042-AC-1..10 (grammar-check framework with EARS as the first grammar: six-pattern classification, the non-singular/missing-subject/vague-response/non-canonical-trigger clause checks with per-archetype dialects, warning→error severity routing into `ValidationResult`, fenced/quote/reference skip, and PyO3 parity — TC-657..666) — 10 ACs. The authorable-inverse-edges slice (FR-041, ADR 0008, 2026-06-21) adds FR-041-AC-1..5 (declared `inverse:` labels become authorable as derived views of their forward edge: inverse index, Tier-1 recognition, precedence/`DuplicateInverseEdge`, Tier-2 forward normalization, warn-only determinism, TC-652..656) — 5 ACs. The object-edge-vocabulary slice (FR-040, 2026-06-20) adds FR-040-AC-1..11 (object-axis typed edge vocabulary + cross-domain role-typed targets: mergeable `edge_types`/`roles` registries with first-wins+diagnostic merge, object `roles` parsed onto the archetype, array|map `allowed_links`, union resolution, warn-tier Tier-1/Tier-2 validation, composed skeleton, TC-636..645 + TC-650/651) and US-015-AC-1..4 (author declares an object's relationship vocabulary, TC-646..649) — 15 ACs. The per-value assert slice (CR-010, 2026-06-20) adds FR-033-AC-11..13 (`choices` scalar enum + `column_choices`/`column_patterns` per-column table validation, TC-633..635) — 3 ACs. The internal-links slice (ADR 0007, 2026-06-17) adds FR-026-AC-9..11 (relative-path link edge source + index/log exclusion + dedup parity, TC-620..622) and FR-039-AC-1..10 (unlinked-reference detection & autofix suggestions, incl. AC-10 multi-token code-span skip, TC-623..632) — 13 ACs. The composed type+object validation slice (2026-06-16) adds FR-032-AC-11..13 (`validate_document_in_registry` composes the `type` archetype with the frontmatter `object:` archetype; resolved-object failures are errors, unknown-object is a warning, `ValidationResult` carries typed `warnings`) — TC-610..613, 3 ACs. The assert/lint extension slice (2026-06-16) adds FR-033-AC-10 (CR-008 `matches` content assert, TC-608) and FR-036-AC-6 (CR-009 `section_body_pattern` lint rule, TC-609) — 2 ACs. The OKF slice (2026-06-16) adds FR-037-AC-1..6 (base concept frontmatter schema, TC-590..596 + TC-528) and FR-038-AC-1..8 (OKF bundle validation, TC-600..607) — 14 ACs. v0.4 adds FR-011-AC-21 (CR-006 `multiple: true`, TC-583) and FR-036-AC-1..5 (declarative lint rules, TC-584..588). v0.2 block model added 16 ACs (FR-019..022, TC-400..443). v0.3 adds 81 ACs — StR-005/006, US-011..013, FR-023..027 (incl. review-added FR-026-AC-8, FR-027-AC-9), NFR-015/016, plus the hardening re-review (NFR-003-AC-4, FR-024-AC-9, NFR-017, NFR-018) — covered by TC-455..507 (plus reused TC-456..459). The Miri ACs (NFR-012-AC-1..5) were **retired** (ADR 0006) and the compile-time **NFR-003-AC-5** (`forbid(unsafe_code)`, TC-582) added. PC (performance criteria) for US-011..013 are tracked as benches (TC-455..459, TC-469) and marked 🚧 pending implementation, consistent with the US-006..010 perf-bench convention. The v0.3 hardening re-review (loom NFR-017, TSAN/ASAN NFR-018) is recorded in spec.md §19. **v0.4 markdown-validation slice** adds 42 ACs — US-014 (author validates markdown), FR-029 (archetype input contract, recast by ADR 0004), FR-030 (required-section validation, superseded by FR-032/FR-033), FR-031 (unified archetype shape), FR-032 (`validate_document`), FR-033 (locator `assert` facet), FR-034 (assert field interpolation), FR-035 (per-level heading uniqueness) — covered by TC-518..553. FR-030's ACs are mapped to the FR-032/FR-033 TCs that subsume them (per its CR note). This slice also back-fills 7 ACs that a prior commit left out of the audit table — FR-013-AC-11..14, FR-028-AC-9/10, US-003-AC-4 — via TC-554..560. New v0.4 TCs are 🚧 pending implementation. @@ -974,7 +989,7 @@ NFR-006-AC-4, NFR-019-AC-1..2 — covered by TC-565..580. TC-561 is re-pointed o FR-033-AC-4 onto FR-033-AC-9 (the non-table `id_pattern` case); TC-562 covers both FR-033-AC-4 and FR-033-AC-9. -**Integrity check (grep-verified):** all **367 distinct file-defined ACs** (definition-anchored: bold `**-AC-N**` declarations) across `stakeholder/ usecase/ functional/ non-functional/` appear in the AC→TC audit table — **0 uncovered**. Note: `FR-900-AC-1/2` appearing inside FR-034-AC-1's example prose are NOT defined ACs and are excluded from the denominator (match `**…**:` definitions, not inline mentions). Retired ACs (marked `(RETIRED)`, un-bolded) are excluded by construction. Count: 316 (pre-removal) − 41 (retired) + 16 (back-fill) + 1 (FR-011-AC-20, CR-005 heading normalization) − 5 (NFR-012-AC-1..5 retired, ADR 0006) + 1 (NFR-003-AC-5, forbid(unsafe_code)) + 1 (FR-011-AC-21, CR-006 multiple:true) + 5 (FR-036-AC-1..5, declarative lint rules) + 1 (FR-010-AC-4, CR-007 escaped pipes) + 6 (FR-037-AC-1..6, OKF base concept schema) + 8 (FR-038-AC-1..8, OKF bundle validation) + 1 (FR-033-AC-10, CR-008 `matches` content assert) + 1 (FR-036-AC-6, CR-009 `section_body_pattern`) + 3 (FR-032-AC-11..13, composed type+object validation) + 3 (FR-026-AC-9..11, relative-path link edge source) + 10 (FR-039-AC-1..10, unlinked-reference detection incl. multi-token code-span skip) + 3 (FR-033-AC-11..13, CR-010 per-value enum/regex asserts) + 11 (FR-040-AC-1..11, object-axis typed edge vocabulary + cross-domain targets) + 4 (US-015-AC-1..4, author declares object relationship vocabulary) + 5 (FR-041-AC-1..5, authorable inverse edge verbs) + 10 (FR-042-AC-1..10, requirement-grammar check (EARS)) + 7 (FR-043-AC-1..7, module-supplied concrete lexicon) = **367**. +**Integrity check (grep-verified):** all **374 distinct file-defined ACs** (definition-anchored: bold `**-AC-N**` declarations) across `stakeholder/ usecase/ functional/ non-functional/` appear in the AC→TC audit table — **0 uncovered**. Note: `FR-900-AC-1/2` appearing inside FR-034-AC-1's example prose are NOT defined ACs and are excluded from the denominator (match `**…**:` definitions, not inline mentions). Retired ACs (marked `(RETIRED)`, un-bolded) are excluded by construction. Count: 316 (pre-removal) − 41 (retired) + 16 (back-fill) + 1 (FR-011-AC-20, CR-005 heading normalization) − 5 (NFR-012-AC-1..5 retired, ADR 0006) + 1 (NFR-003-AC-5, forbid(unsafe_code)) + 1 (FR-011-AC-21, CR-006 multiple:true) + 5 (FR-036-AC-1..5, declarative lint rules) + 1 (FR-010-AC-4, CR-007 escaped pipes) + 6 (FR-037-AC-1..6, OKF base concept schema) + 8 (FR-038-AC-1..8, OKF bundle validation) + 1 (FR-033-AC-10, CR-008 `matches` content assert) + 1 (FR-036-AC-6, CR-009 `section_body_pattern`) + 3 (FR-032-AC-11..13, composed type+object validation) + 3 (FR-026-AC-9..11, relative-path link edge source) + 10 (FR-039-AC-1..10, unlinked-reference detection incl. multi-token code-span skip) + 3 (FR-033-AC-11..13, CR-010 per-value enum/regex asserts) + 11 (FR-040-AC-1..11, object-axis typed edge vocabulary + cross-domain targets) + 4 (US-015-AC-1..4, author declares object relationship vocabulary) + 5 (FR-041-AC-1..5, authorable inverse edge verbs) + 10 (FR-042-AC-1..10, requirement-grammar check (EARS)) + 7 (FR-043-AC-1..7, module-supplied concrete lexicon) + 7 (FR-044-AC-1..7, project Ubiquitous-Language lexicon) = **374**. --- diff --git a/src/corpus/glossary.rs b/src/corpus/glossary.rs new file mode 100644 index 0000000..5c629ae --- /dev/null +++ b/src/corpus/glossary.rs @@ -0,0 +1,165 @@ +//! Project Ubiquitous-Language term harvesting (FR-044). +//! +//! A repository authors its own concrete vocabulary — a `Glossary` artifact's +//! `## Terms` table, or a `## Ubiquitous Language` section on a domain object. +//! These terms are harvested from a loaded [`Spec`] and composed with the merged +//! module lexicon ([`crate::Registry::lexicon_with`]) so a repo's domain terms +//! are accepted as concrete objects in its own EARS grammar check (FR-042/043). + +use crate::ast::QuireDocument; +use crate::corpus::Spec; +use crate::query::{self, ListPattern}; + +/// Harvest the repo's project glossary terms from a loaded `Spec`: the `Term` +/// (or `Name`) column of every `## Terms` table, and the bold term of every +/// `## Ubiquitous Language` bullet (and any `## Ubiquitous Language` table). +/// Deduplicated and sorted; a corpus with no glossary yields an empty vec. +pub fn glossary_terms(spec: &Spec) -> Vec { + let mut terms: Vec = Vec::new(); + for doc in &spec.inner.documents { + // `## Terms` table — a `Glossary` artifact. + push_table_terms(&doc.doc, "Terms", &mut terms); + // `## Ubiquitous Language` — bullet form (`- **Term** — …`) … + if let Some(section) = query::section(&doc.doc, "Ubiquitous Language") { + for item in + query::parse_bullet_list(§ion.content, Some(ListPattern::BoldDescription)) + { + push_term(&item.title, &mut terms); + } + } + // … and/or table form under the same heading. + push_table_terms(&doc.doc, "Ubiquitous Language", &mut terms); + } + terms.sort(); + terms.dedup(); + terms +} + +/// Push the `Term`/`Name` column of a named section's table into `out`. +fn push_table_terms(doc: &QuireDocument, heading: &str, out: &mut Vec) { + let Some(table) = query::table_from_section(doc, heading) else { + return; + }; + let Some(idx) = table.headers.iter().position(|h| { + let h = h.trim().to_ascii_lowercase(); + h == "term" || h == "name" + }) else { + return; + }; + for row in &table.rows { + if let Some(cell) = row.get(idx) { + push_term(cell, out); + } + } +} + +fn push_term(raw: &str, out: &mut Vec) { + let t = raw.trim(); + if !t.is_empty() { + out.push(t.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // TC-674 (FR-044-AC-1): harvest the Term column from a `## Terms` table. + // TC-675 (FR-044-AC-2): harvest the bold term from `## Ubiquitous Language`. + #[test] + fn tc674_675_harvest_terms_and_ubiquitous_language() { + let tmp = std::env::temp_dir().join(format!("ql-glossary-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write( + tmp.join("GLO-001.md"), + "---\nid: GLO-001\ntype: Glossary\n---\n# [GLO-001] G\n\n## Terms\n\n| Term | Definition |\n|------|------------|\n| Widget | a UI thing |\n| Sprocket | a gear |\n", + ) + .unwrap(); + std::fs::write( + tmp.join("domain.md"), + "---\nid: dom-1\ntype: domain\n---\n# Domain\n\n## Ubiquitous Language\n\n- **Place** — convert a draft order.\n- **Capture** — confirm payment.\n", + ) + .unwrap(); + let spec = Spec::from_path(&tmp); + let terms = glossary_terms(&spec); + assert!(terms.contains(&"Widget".to_string())); + assert!(terms.contains(&"Sprocket".to_string())); + assert!(terms.contains(&"Place".to_string())); + assert!(terms.contains(&"Capture".to_string())); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// Build a bundle report for a repo whose `Glossary` defines `widget` and + /// whose `FR-001` says "shall provide a widget", validated Strict (so the + /// EARS grammar runs). Shared fixture for TC-678 and TC-679. + fn glossary_bundle_report(tag: &str) -> crate::corpus::BundleReport { + let base = std::env::temp_dir().join(format!("ql-bundle-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let m = base.join("mod"); + std::fs::create_dir_all(m.join("schemas")).unwrap(); + std::fs::write( + m.join("manifest.yaml"), + "name: m\nartifact_types:\n- name: FR\n frontmatter_schema_ref: schemas/s.json\n grammar_ref: iso-spec-core\n- name: Glossary\n frontmatter_schema_ref: schemas/s.json\n", + ) + .unwrap(); + std::fs::write(m.join("schemas/s.json"), r#"{"type":"object"}"#).unwrap(); + let registry = crate::Registry::load_module(&m).expect("module loads"); + let docs = base.join("spec"); + std::fs::create_dir_all(&docs).unwrap(); + std::fs::write( + docs.join("GLO-001.md"), + "---\nid: GLO-001\ntype: Glossary\n---\n# G\n\n## Terms\n\n| Term | Definition |\n|------|------------|\n| widget | a UI thing |\n", + ) + .unwrap(); + std::fs::write( + docs.join("FR-001.md"), + "---\nid: FR-001\ntype: FR\n---\n# FR\n\n## Description\n\nThe system shall provide a widget.\n", + ) + .unwrap(); + let spec = Spec::from_path(&docs); + let report = crate::corpus::validate_bundle( + &spec, + ®istry, + crate::corpus::BundlePosture::Strict, + &docs, + ); + let _ = std::fs::remove_dir_all(&base); + report + } + + // TC-678 (FR-044-AC-5): validate_bundle harvests the Spec's project terms + // and applies the combined lexicon — the FR's `widget` is suppressed. + #[test] + fn tc678_validate_bundle_harvests_and_applies() { + let report = glossary_bundle_report("678"); + assert!(!report + .warnings + .iter() + .any(|w| w.message.contains("[ears:vague-response]"))); + } + + // TC-679 (FR-044-AC-6): the project-glossary suppression is advisory — + // grammar findings never become bundle errors. + #[test] + fn tc679_validate_bundle_grammar_is_advisory() { + let report = glossary_bundle_report("679"); + assert!(!report.errors.iter().any(|e| e.reason == "grammar")); + } + + // TC-680 (FR-044-AC-7): a repo with no glossary harvests nothing. + #[test] + fn tc680_no_glossary_empty() { + let tmp = std::env::temp_dir().join(format!("ql-noglossary-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write( + tmp.join("FR-001.md"), + "---\nid: FR-001\ntype: FR\n---\n# FR\n\n## Description\n\nThe system shall do a thing.\n", + ) + .unwrap(); + let spec = Spec::from_path(&tmp); + assert!(glossary_terms(&spec).is_empty()); + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/src/corpus/mod.rs b/src/corpus/mod.rs index 095a279..5633b88 100644 --- a/src/corpus/mod.rs +++ b/src/corpus/mod.rs @@ -15,6 +15,7 @@ //! - `resolve` — intra-spec reference resolution (FR-026; Task 030). //! - `query` — whole-spec query API (FR-027; Task 031). +pub mod glossary; pub mod query; pub mod resolve; pub mod spec; @@ -22,6 +23,7 @@ pub mod unlinked; pub mod validate; pub mod walk; +pub use glossary::glossary_terms; pub use resolve::{harvest_edges, Edge, Resolution}; pub use spec::Spec; pub use unlinked::{unlinked_references, UnlinkedFix, UnlinkedReason, UnlinkedReference}; diff --git a/src/corpus/validate.rs b/src/corpus/validate.rs index a9677e1..f2aec39 100644 --- a/src/corpus/validate.rs +++ b/src/corpus/validate.rs @@ -91,8 +91,14 @@ pub fn validate_bundle( ) -> BundleReport { let mut report = BundleReport::default(); + // FR-044: harvest the repo's project Ubiquitous-Language terms once and + // compose the combined (module ∪ project) lexicon the EARS grammar check + // consumes for every document in the bundle. + let project_terms = crate::corpus::glossary_terms(spec); + let lexicon = registry.lexicon_with(&project_terms); + for doc in &spec.inner.documents { - validate_concept(doc, registry, posture, &mut report); + validate_concept(doc, registry, posture, &lexicon, &mut report); } // Reference resolution: a dangling `ix://` ref is a hard error under @@ -215,6 +221,7 @@ fn validate_concept( doc: &LoadedDocument, registry: &Registry, posture: BundlePosture, + lexicon: &crate::grammar::GrammarLexicon, report: &mut BundleReport, ) { let fm = doc.doc.frontmatter.clone().unwrap_or_default(); @@ -252,8 +259,12 @@ fn validate_concept( // frontmatter `object:` archetype too. Object errors // merge into bundle errors; an unknown `object:` // degrades to a bundle warning. - let result = - crate::validate_document_in_registry(registry, archetype, &doc.doc.raw); + let result = crate::validate_document_in_registry_with_lexicon( + registry, + archetype, + &doc.doc.raw, + lexicon, + ); for err in result.errors { report.errors.push(BundleFinding { path: doc.path.clone(), diff --git a/src/grammar/ears.rs b/src/grammar/ears.rs index 0894d1d..ff17a3b 100644 --- a/src/grammar/ears.rs +++ b/src/grammar/ears.rs @@ -568,29 +568,42 @@ mod tests { ); } - // TC-669 (FR-043-AC-3): a lexicon term suppresses; removing it re-flags. + fn fr_doc(body: &str) -> String { + format!("---\ntype: FR\n---\n## Description\n\n{body}\n") + } + + fn vague_count(text: &str, lexicon: &GrammarLexicon) -> usize { + check("FR", &doc(text), 0, lexicon) + .iter() + .filter(|x| x.check == "vague-response") + .count() + } + + // TC-669 (FR-043-AC-3): a lexicon term suppresses; a different term re-flags. + #[test] + fn tc669_lexicon_term_suppresses() { + let d = fr_doc("The system shall support pagination."); + assert_eq!(vague_count(&d, &lex(&["pagination"])), 0); // term present → suppressed + assert_eq!(vague_count(&d, &lex(&["cursor"])), 1); // wrong term → still flagged + let ep = fr_doc("The system shall provide an endpoint for review runs."); + assert_eq!(vague_count(&ep, &lex(&["endpoint"])), 0); + } + // TC-670 (FR-043-AC-4): under an empty lexicon a bare domain noun flags — // proving no hardcoded concrete-noun list remains in the engine. #[test] - fn tc669_670_lexicon_drives_concrete() { - let fr = |body: &str| format!("---\ntype: FR\n---\n## Description\n\n{body}\n"); - let count = |text: &str, lexicon: &GrammarLexicon| { - check("FR", &doc(text), 0, lexicon) - .iter() - .filter(|x| x.check == "vague-response") - .count() - }; - let doc_text = fr("The system shall support pagination."); - // Empty lexicon → bare noun flags (no hardcoded list). - assert_eq!(count(&doc_text, &empty()), 1); - // Lexicon with the term → suppressed. - assert_eq!(count(&doc_text, &lex(&["pagination"])), 0); - // A different term does not suppress it. - assert_eq!(count(&doc_text, &lex(&["cursor"])), 1); - // The same for `provide an endpoint` once `endpoint` is a lexicon term. - let ep = fr("The system shall provide an endpoint for review runs."); - assert_eq!(count(&ep, &empty()), 1); - assert_eq!(count(&ep, &lex(&["endpoint"])), 0); + fn tc670_empty_lexicon_flags_bare_noun() { + assert_eq!( + vague_count(&fr_doc("The system shall support pagination."), &empty()), + 1 + ); + assert_eq!( + vague_count( + &fr_doc("The system shall provide an endpoint for review runs."), + &empty() + ), + 1 + ); } // TC-671 (FR-043-AC-5): backtick / mechanism / bound suppression does not diff --git a/src/lib.rs b/src/lib.rs index 9192af3..b81511f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,8 +51,9 @@ pub use registry::Registry; pub use validate::{apply_patch, validate, validate_all, validate_block}; // Markdown-default validation (FR-032 + FR-035). pub use validate_document::{ - validate_context, validate_document, validate_document_in_registry, ValidationError, - ValidationReason, ValidationResult, ValidationWarning, + validate_context, validate_document, validate_document_in_registry, + validate_document_in_registry_with_lexicon, ValidationError, ValidationReason, + ValidationResult, ValidationWarning, }; // Base concept frontmatter contract (OKF: required `type` + optional // `description`/`tags`), validated before archetype routing. diff --git a/src/registry.rs b/src/registry.rs index 4b6a18d..70df37e 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -306,6 +306,16 @@ impl Registry { &self.inner.lexicon_matcher } + /// Compose an ad-hoc `GrammarLexicon` (FR-044) from the merged module + /// lexicon keys plus `extra` project terms (a repo's harvested + /// Ubiquitous-Language vocabulary). Project terms are per-repo, so they are + /// never stored on the immutable `Registry` — this builds a fresh matcher. + pub fn lexicon_with(&self, extra: &[String]) -> crate::grammar::GrammarLexicon { + let module = self.inner.lexicon.keys().map(String::as_str); + let project = extra.iter().map(String::as_str); + crate::grammar::GrammarLexicon::from_terms(module.chain(project)) + } + /// Inverse-label → forward-verb index (FR-041). A declared `inverse:` /// label is an authorable verb (a derived view of its forward edge); /// this maps each such label to the forward verb that declared it. @@ -772,17 +782,34 @@ roles: .any(|d| matches!(d, Diagnostic::DuplicateEdgeType { name, .. } if name == "calls"))); } - // TC-667/668 (FR-043-AC-1/2): a `lexicon` term merges first-wins across - // modules, is readable via `Registry::lexicon()`, and a conflicting - // redeclaration emits one `DuplicateLexiconTerm`; the precompiled matcher - // recognises a merged term. + // TC-667 (FR-043-AC-1): a `lexicon` term loads, is readable via + // `Registry::lexicon()`, and the precompiled matcher recognises it. #[test] - fn tc667_668_lexicon_merge_and_accessor() { + fn tc667_lexicon_loads_and_accessor() { let p = tmpdir("lexicon-667"); + write_vocab_module( + &p.join("m"), + "m", + "lexicon:\n pagination:\n definition: page-splitting\n cursor:\n definition: position token\n", + ); + let r = Registry::load_from(&[&p]).expect("tolerant ok"); + assert!(r.lexicon().contains_key("pagination")); + assert!(r.lexicon().contains_key("cursor")); + assert!(r + .lexicon_matcher() + .contains_term("supports pagination today")); + assert!(!r.lexicon_matcher().contains_term("unrelated text")); + } + + // TC-668 (FR-043-AC-2): a term re-declared with a differing body across + // modules is first-wins + emits one `DuplicateLexiconTerm`. + #[test] + fn tc668_lexicon_merge_first_wins() { + let p = tmpdir("lexicon-668"); write_vocab_module( &p.join("m1"), "m1", - "lexicon:\n pagination:\n definition: page-splitting\n cursor:\n definition: position token\n", + "lexicon:\n pagination:\n definition: page-splitting\n", ); write_vocab_module( &p.join("m2"), @@ -791,14 +818,9 @@ roles: ); let r = Registry::load_from(&[&p]).expect("tolerant ok"); assert_eq!(r.lexicon()["pagination"].definition, "page-splitting"); // first-wins - assert!(r.lexicon().contains_key("cursor")); assert!(r.diagnostics().iter().any( |d| matches!(d, Diagnostic::DuplicateLexiconTerm { name, .. } if name == "pagination") )); - assert!(r - .lexicon_matcher() - .contains_term("supports pagination today")); - assert!(!r.lexicon_matcher().contains_term("unrelated text")); } // TC-672 (FR-043-AC-6): the registry-backed path applies the merged @@ -831,6 +853,53 @@ roles: .any(|w| w.message.contains("[ears:vague-response]"))); } + // TC-676 (FR-044-AC-3): `lexicon_with` composes module keys ∪ project terms. + #[test] + fn tc676_lexicon_with_combines_module_and_project() { + let p = tmpdir("lexicon-676"); + write_vocab_module( + &p.join("m"), + "m", + "lexicon:\n endpoint:\n definition: an HTTP path\n", + ); + let r = Registry::load_from(&[&p]).expect("tolerant ok"); + let lex = r.lexicon_with(&["widget".to_string()]); + assert!(lex.contains_term("provide an endpoint")); // module term + assert!(lex.contains_term("provide a widget")); // project term + assert!(!lex.contains_term("provide flexibility")); // neither + } + + // TC-677 (FR-044-AC-4): validate_document_in_registry_with_lexicon injects + // the supplied lexicon — a project term suppresses; module-only flags. + #[test] + fn tc677_with_lexicon_injection_suppresses_project_term() { + let p = tmpdir("lexicon-677"); + let m = p.join("m"); + fs::create_dir_all(m.join("schemas")).unwrap(); + fs::write( + m.join("manifest.yaml"), + "name: m\nartifact_types:\n- name: FR\n frontmatter_schema_ref: schemas/fr.schema.json\n grammar_ref: iso-spec-core\n", + ) + .unwrap(); + fs::write(m.join("schemas/fr.schema.json"), r#"{"type":"object"}"#).unwrap(); + let r = Registry::load_from(&[&p]).expect("tolerant ok"); + let fr = r.archetype("FR").expect("FR archetype"); + let doc = "---\ntype: FR\n---\n## Description\n\nThe system shall provide a widget.\n"; + // Module-only (empty) lexicon → `widget` is vague. + let mod_only = crate::validate_document_in_registry(&r, fr, doc); + assert!(mod_only + .warnings + .iter() + .any(|w| w.message.contains("[ears:vague-response]"))); + // Inject a lexicon with the project term → suppressed. + let lex = r.lexicon_with(&["widget".to_string()]); + let injected = crate::validate_document_in_registry_with_lexicon(&r, fr, doc, &lex); + assert!(!injected + .warnings + .iter() + .any(|w| w.message.contains("[ears:vague-response]"))); + } + // TC-650 (FR-040-AC-3): unknown verb/role → non-fatal diagnostic // (default load succeeds); load_strict escalates to an error. #[test] diff --git a/src/validate_document.rs b/src/validate_document.rs index aac7cbb..c8a43ef 100644 --- a/src/validate_document.rs +++ b/src/validate_document.rs @@ -249,6 +249,31 @@ pub fn validate_document_in_registry( registry: &crate::Registry, archetype: &CompiledArchetype, doc_text: &str, +) -> ValidationResult { + validate_in_registry_core(registry, archetype, doc_text, registry.lexicon_matcher()) +} + +/// As [`validate_document_in_registry`], but the EARS grammar check (FR-042) +/// runs against an **explicitly supplied** `GrammarLexicon` instead of the +/// registry's own (FR-044). The orchestrator composes `lexicon` from the merged +/// module lexicon plus the repo's harvested Ubiquitous-Language terms (see +/// [`crate::Registry::lexicon_with`] + [`crate::corpus::glossary_terms`]). +pub fn validate_document_in_registry_with_lexicon( + registry: &crate::Registry, + archetype: &CompiledArchetype, + doc_text: &str, + lexicon: &crate::grammar::GrammarLexicon, +) -> ValidationResult { + validate_in_registry_core(registry, archetype, doc_text, lexicon) +} + +/// Shared body of the two registry-backed validation entry points. The only +/// difference is the `GrammarLexicon` the grammar check consumes. +fn validate_in_registry_core( + registry: &crate::Registry, + archetype: &CompiledArchetype, + doc_text: &str, + lexicon: &crate::grammar::GrammarLexicon, ) -> ValidationResult { let doc = crate::parse_document(doc_text); let line_offset = body_line_offset(doc_text); @@ -330,12 +355,13 @@ pub fn validate_document_in_registry( } // ── Requirement-grammar layer (EARS, FR-042; advisory by default) ── - // Registry-backed path: pass the merged concrete lexicon (FR-043). + // The caller chose the lexicon: the module lexicon (FR-043) for the plain + // entry point, or that ∪ the repo's project glossary (FR-044). run_grammar( archetype, &doc, line_offset, - registry.lexicon_matcher(), + lexicon, &mut errors, &mut warnings, ); From fbe26258541030584608de67fa018654efe5b293 Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 10:22:34 -0700 Subject: [PATCH 08/11] feat(grammar): export glossary_terms for CLI harvest (FR-044) Co-Authored-By: Claude Opus 4.8 --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index b81511f..9691e6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,7 +77,7 @@ pub use lint::{lint_document, LintFinding, LintRule, LintSeverity}; pub use writeback::{update_block, update_section}; // Corpus: parallel repo walk (FR-024) + Spec corpus (FR-025); resolution/query in FR-026..027. pub use corpus::walk::{load_repo, load_repo_with, LoadedDocument, RepoLoad, WalkOptions}; -pub use corpus::{harvest_edges, Spec}; +pub use corpus::{glossary_terms, harvest_edges, Spec}; // Bundle validation postures (OKF): strict archetype-conformance vs. // permissive foreign-bundle reading, plus index-completeness. pub use corpus::{validate_bundle, validate_bundle_at, BundleFinding, BundlePosture, BundleReport}; From 6e060a793d7ac81415584f5fabc1aa3d9906a3a5 Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 15:03:40 -0700 Subject: [PATCH 09/11] test(grammar): split tc674_675 per TC + cover CON-2 malformed glossary (FR-044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up: tc674_675 bundled two TCs (the one-test-per-TC convention missed in the earlier split) — now tc674 (Terms table) + tc675 (Ubiquitous Language) via a shared fixture. Add malformed_glossary_harvests_ nothing for CON-2's malformed half (## Terms heading, no table → zero terms, no panic), which TC-680 (absent glossary) did not cover. Co-Authored-By: Claude Opus 4.8 --- src/corpus/glossary.rs | 45 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/src/corpus/glossary.rs b/src/corpus/glossary.rs index 5c629ae..5d51d25 100644 --- a/src/corpus/glossary.rs +++ b/src/corpus/glossary.rs @@ -64,11 +64,11 @@ fn push_term(raw: &str, out: &mut Vec) { mod tests { use super::*; - // TC-674 (FR-044-AC-1): harvest the Term column from a `## Terms` table. - // TC-675 (FR-044-AC-2): harvest the bold term from `## Ubiquitous Language`. - #[test] - fn tc674_675_harvest_terms_and_ubiquitous_language() { - let tmp = std::env::temp_dir().join(format!("ql-glossary-{}", std::process::id())); + /// Harvest from a repo holding a `Glossary` `## Terms` table (Widget, + /// Sprocket) and a `domain` `## Ubiquitous Language` (Place, Capture). + /// Shared fixture for TC-674 and TC-675. + fn harvest_fixture(tag: &str) -> Vec { + let tmp = std::env::temp_dir().join(format!("ql-glossary-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); std::fs::write( @@ -81,13 +81,25 @@ mod tests { "---\nid: dom-1\ntype: domain\n---\n# Domain\n\n## Ubiquitous Language\n\n- **Place** — convert a draft order.\n- **Capture** — confirm payment.\n", ) .unwrap(); - let spec = Spec::from_path(&tmp); - let terms = glossary_terms(&spec); + let terms = glossary_terms(&Spec::from_path(&tmp)); + let _ = std::fs::remove_dir_all(&tmp); + terms + } + + // TC-674 (FR-044-AC-1): harvest the Term column from a `## Terms` table. + #[test] + fn tc674_harvest_terms_table() { + let terms = harvest_fixture("674"); assert!(terms.contains(&"Widget".to_string())); assert!(terms.contains(&"Sprocket".to_string())); + } + + // TC-675 (FR-044-AC-2): harvest the bold term from `## Ubiquitous Language`. + #[test] + fn tc675_harvest_ubiquitous_language() { + let terms = harvest_fixture("675"); assert!(terms.contains(&"Place".to_string())); assert!(terms.contains(&"Capture".to_string())); - let _ = std::fs::remove_dir_all(&tmp); } /// Build a bundle report for a repo whose `Glossary` defines `widget` and @@ -162,4 +174,21 @@ mod tests { assert!(glossary_terms(&spec).is_empty()); let _ = std::fs::remove_dir_all(&tmp); } + + // CON-2 (malformed half): a `## Terms` heading with no table harvests zero + // terms and never panics (FR-044-AC-7's sibling constraint). + #[test] + fn malformed_glossary_harvests_nothing() { + let tmp = std::env::temp_dir().join(format!("ql-malformed-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write( + tmp.join("GLO-001.md"), + "---\nid: GLO-001\ntype: Glossary\n---\n# [GLO-001] G\n\n## Terms\n\nNo table here, just prose.\n", + ) + .unwrap(); + let spec = Spec::from_path(&tmp); + assert!(glossary_terms(&spec).is_empty()); + let _ = std::fs::remove_dir_all(&tmp); + } } From f3038ffe96bea1bfef9977622a8eb336e0a96e2d Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 15:11:40 -0700 Subject: [PATCH 10/11] perf(grammar): scope glossary harvest, no full-corpus parse (FR-044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI per-validate path built a whole Spec (parse + graph-resolve every file under --scope) just to read a few glossary tables. Add glossary_terms_from_path: enumerate *.md, raw-read each, and parse ONLY the docs whose text carries a `## Terms` / `## Ubiquitous Language` heading (cheap has_glossary_heading line scan). The Spec-based glossary_terms stays for the corpus path (validate_bundle), where the Spec is already loaded. Tests: has_glossary_heading_gates_parsing (the parse-skip gate) + glossary_terms_from_path_scopes_to_glossary_docs (one glossary among many plain FRs → exactly the glossary terms, parity with the Spec-based harvester). Co-Authored-By: Claude Opus 4.8 --- src/corpus/glossary.rs | 127 ++++++++++++++++++++++++++++++++++++----- src/corpus/mod.rs | 2 +- src/corpus/walk.rs | 2 +- src/lib.rs | 2 +- 4 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/corpus/glossary.rs b/src/corpus/glossary.rs index 5d51d25..9818d45 100644 --- a/src/corpus/glossary.rs +++ b/src/corpus/glossary.rs @@ -6,35 +6,85 @@ //! module lexicon ([`crate::Registry::lexicon_with`]) so a repo's domain terms //! are accepted as concrete objects in its own EARS grammar check (FR-042/043). +use std::path::Path; + use crate::ast::QuireDocument; +use crate::corpus::walk::{discover_files, WalkOptions}; use crate::corpus::Spec; use crate::query::{self, ListPattern}; -/// Harvest the repo's project glossary terms from a loaded `Spec`: the `Term` -/// (or `Name`) column of every `## Terms` table, and the bold term of every -/// `## Ubiquitous Language` bullet (and any `## Ubiquitous Language` table). -/// Deduplicated and sorted; a corpus with no glossary yields an empty vec. +/// Harvest the repo's project glossary terms from an **already-loaded** `Spec`: +/// the `Term` (or `Name`) column of every `## Terms` table, and the bold term +/// of every `## Ubiquitous Language` bullet (and any `## Ubiquitous Language` +/// table). Deduplicated and sorted; a corpus with no glossary yields an empty +/// vec. Use this on the corpus path (`validate_bundle`), where the `Spec` is +/// already in hand — it adds no I/O. On the per-document validate path, use +/// [`glossary_terms_from_path`], which never loads the whole corpus. pub fn glossary_terms(spec: &Spec) -> Vec { let mut terms: Vec = Vec::new(); for doc in &spec.inner.documents { - // `## Terms` table — a `Glossary` artifact. - push_table_terms(&doc.doc, "Terms", &mut terms); - // `## Ubiquitous Language` — bullet form (`- **Term** — …`) … - if let Some(section) = query::section(&doc.doc, "Ubiquitous Language") { - for item in - query::parse_bullet_list(§ion.content, Some(ListPattern::BoldDescription)) - { - push_term(&item.title, &mut terms); - } + collect_doc_terms(&doc.doc, &mut terms); + } + terms.sort(); + terms.dedup(); + terms +} + +/// Harvest project glossary terms by scanning `root`, parsing **only** the +/// documents that carry a glossary section — never the whole corpus (FR-044). +/// +/// `Spec::from_path` parses and graph-resolves every file under `root`; that is +/// far too much work when a repo's glossary lives in a handful of documents. +/// This enumerates `*.md` files, reads each file's raw text, and parses only +/// the ones whose text contains a `## Terms` or `## Ubiquitous Language` +/// heading (a cheap line scan — [`has_glossary_heading`]). Deduplicated and +/// sorted; a repo with no glossary yields an empty vec. +pub fn glossary_terms_from_path(root: &Path) -> Vec { + let opts = WalkOptions::default(); + let mut terms: Vec = Vec::new(); + for path in discover_files(root, &opts) { + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if !has_glossary_heading(&text) { + continue; // not a glossary-bearing doc — never parsed. } - // … and/or table form under the same heading. - push_table_terms(&doc.doc, "Ubiquitous Language", &mut terms); + collect_doc_terms(&crate::parse_document(&text), &mut terms); } terms.sort(); terms.dedup(); terms } +/// The cheap pre-filter that keeps [`glossary_terms_from_path`] from parsing +/// non-glossary documents: true when `text` has a heading (any level) whose +/// title is `Terms` or `Ubiquitous Language` (case-insensitive). +pub(crate) fn has_glossary_heading(text: &str) -> bool { + text.lines().any(|line| { + let trimmed = line.trim_start(); + if !trimmed.starts_with('#') { + return false; + } + let title = trimmed.trim_start_matches('#').trim(); + title.eq_ignore_ascii_case("Terms") || title.eq_ignore_ascii_case("Ubiquitous Language") + }) +} + +/// Collect a single document's glossary terms into `terms` (shared by the +/// `Spec`-based and path-based harvesters). +fn collect_doc_terms(doc: &QuireDocument, terms: &mut Vec) { + // `## Terms` table — a `Glossary` artifact. + push_table_terms(doc, "Terms", terms); + // `## Ubiquitous Language` — bullet form (`- **Term** — …`) … + if let Some(section) = query::section(doc, "Ubiquitous Language") { + for item in query::parse_bullet_list(§ion.content, Some(ListPattern::BoldDescription)) { + push_term(&item.title, terms); + } + } + // … and/or table form under the same heading. + push_table_terms(doc, "Ubiquitous Language", terms); +} + /// Push the `Term`/`Name` column of a named section's table into `out`. fn push_table_terms(doc: &QuireDocument, heading: &str, out: &mut Vec) { let Some(table) = query::table_from_section(doc, heading) else { @@ -102,6 +152,53 @@ mod tests { assert!(terms.contains(&"Capture".to_string())); } + // The pre-filter that scopes the path harvester: only docs with an actual + // `Terms` / `Ubiquitous Language` HEADING are parsed — not every doc that + // merely mentions the words. This is what keeps the per-validate path off a + // full-corpus parse. + #[test] + fn has_glossary_heading_gates_parsing() { + assert!(has_glossary_heading("## Terms\n")); + assert!(has_glossary_heading("# Title\n\n## Ubiquitous Language\n")); + assert!(has_glossary_heading("### terms\n")); // any level, case-insensitive + // Not glossary-bearing → never parsed: + assert!(!has_glossary_heading( + "## Description\n\nThe system shall do X.\n" + )); + assert!(!has_glossary_heading("These are the terms of service.\n")); // prose, no heading + assert!(!has_glossary_heading("| Term | Definition |\n")); // a table row, not a heading + } + + // `glossary_terms_from_path` harvests the glossary terms WITHOUT loading the + // whole corpus: a scope with one glossary doc among many plain FRs yields + // exactly the glossary terms, and matches the Spec-based harvester (parity). + // The plain FRs lack a glossary heading, so the gate above skips parsing + // them entirely. + #[test] + fn glossary_terms_from_path_scopes_to_glossary_docs() { + let dir = std::env::temp_dir().join(format!("ql-frompath-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("functional")).unwrap(); + std::fs::write( + dir.join("glossary.md"), + "---\nid: GLO-001\ntype: Glossary\n---\n# G\n\n## Terms\n\n| Term | Definition |\n|------|------------|\n| Sprocket | a streamed unit |\n", + ) + .unwrap(); + // Many non-glossary docs — none carry a glossary heading. + for i in 0..5 { + std::fs::write( + dir.join("functional").join(format!("FR-{i:03}.md")), + format!("---\nid: FR-{i:03}\ntype: FR\n---\n# FR\n\n## Description\n\nThe system shall provide a sprocket.\n"), + ) + .unwrap(); + } + let from_path = glossary_terms_from_path(&dir); + assert_eq!(from_path, vec!["Sprocket".to_string()]); + // Parity with the Spec-based harvester over the same tree. + assert_eq!(from_path, glossary_terms(&Spec::from_path(&dir))); + let _ = std::fs::remove_dir_all(&dir); + } + /// Build a bundle report for a repo whose `Glossary` defines `widget` and /// whose `FR-001` says "shall provide a widget", validated Strict (so the /// EARS grammar runs). Shared fixture for TC-678 and TC-679. diff --git a/src/corpus/mod.rs b/src/corpus/mod.rs index 5633b88..1e8be44 100644 --- a/src/corpus/mod.rs +++ b/src/corpus/mod.rs @@ -23,7 +23,7 @@ pub mod unlinked; pub mod validate; pub mod walk; -pub use glossary::glossary_terms; +pub use glossary::{glossary_terms, glossary_terms_from_path}; pub use resolve::{harvest_edges, Edge, Resolution}; pub use spec::Spec; pub use unlinked::{unlinked_references, UnlinkedFix, UnlinkedReason, UnlinkedReference}; diff --git a/src/corpus/walk.rs b/src/corpus/walk.rs index f3a9e10..6d54503 100644 --- a/src/corpus/walk.rs +++ b/src/corpus/walk.rs @@ -160,7 +160,7 @@ impl Outcome { } } -fn discover_files(root: &Path, opts: &WalkOptions) -> Vec { +pub(crate) fn discover_files(root: &Path, opts: &WalkOptions) -> Vec { let mut builder = WalkBuilder::new(root); builder .hidden(!opts.include_hidden) diff --git a/src/lib.rs b/src/lib.rs index 9691e6c..64a8cd2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,7 +77,7 @@ pub use lint::{lint_document, LintFinding, LintRule, LintSeverity}; pub use writeback::{update_block, update_section}; // Corpus: parallel repo walk (FR-024) + Spec corpus (FR-025); resolution/query in FR-026..027. pub use corpus::walk::{load_repo, load_repo_with, LoadedDocument, RepoLoad, WalkOptions}; -pub use corpus::{glossary_terms, harvest_edges, Spec}; +pub use corpus::{glossary_terms, glossary_terms_from_path, harvest_edges, Spec}; // Bundle validation postures (OKF): strict archetype-conformance vs. // permissive foreign-bundle reading, plus index-completeness. pub use corpus::{validate_bundle, validate_bundle_at, BundleFinding, BundlePosture, BundleReport}; From 3ea495a403a1dab5fa6946b4139c6972bfcdd673 Mon Sep 17 00:00:00 2001 From: Agent IX Date: Tue, 23 Jun 2026 15:24:00 -0700 Subject: [PATCH 11/11] chore(deny): allow AGPL-3.0-or-later for our own crate Completes the AGPL relicense (merged from main): cargo-deny rejected the crate's own AGPL-3.0-or-later license since the allow-list is permissive-only. Add a per-crate exception for quire-rs so the License Check passes WITHOUT permitting AGPL in third-party deps. Co-Authored-By: Claude Opus 4.8 --- deny.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/deny.toml b/deny.toml index ec10a9b..238e133 100644 --- a/deny.toml +++ b/deny.toml @@ -10,6 +10,12 @@ allow = [ "Unicode-3.0", "Zlib", ] +# Our own crate is AGPL-3.0-or-later (the project license); allow it here +# WITHOUT permitting AGPL in third-party dependencies — the allow-list above +# stays permissive-only. +exceptions = [ + { allow = ["AGPL-3.0-or-later"], crate = "quire-rs" }, +] confidence-threshold = 0.9 [bans]