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] 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-042-requirement-grammar-check.md b/spec/functional/FR-042-requirement-grammar-check.md new file mode 100644 index 0000000..6c063a8 --- /dev/null +++ b/spec/functional/FR-042-requirement-grammar-check.md @@ -0,0 +1,98 @@ +--- +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`). 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`), 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`/ + `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/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/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 d11c26b..87fbf69 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -40,3 +40,7 @@ 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) +* [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 885de49..6494805 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -98,6 +98,9 @@ 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 | +| 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 @@ -339,6 +342,30 @@ 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-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-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 | 🚧 | @@ -855,6 +882,30 @@ 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 | +| 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 | +| 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 @@ -920,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: 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: 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. @@ -938,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 **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 **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/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/corpus/glossary.rs b/src/corpus/glossary.rs new file mode 100644 index 0000000..9818d45 --- /dev/null +++ b/src/corpus/glossary.rs @@ -0,0 +1,291 @@ +//! 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 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 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 { + 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. + } + 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 { + 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::*; + + /// 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( + 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 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())); + } + + // 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. + 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); + } + + // 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); + } +} diff --git a/src/corpus/mod.rs b/src/corpus/mod.rs index 095a279..1e8be44 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, 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/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/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/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 new file mode 100644 index 0000000..ff17a3b --- /dev/null +++ b/src/grammar/ears.rs @@ -0,0 +1,707 @@ +//! 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, GrammarLexicon, 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, + lexicon: &GrammarLexicon, +) -> 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, lexicon, &mut findings); + } + } + findings +} + +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()); + + 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, lexicon) { + 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() +} + +/// 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)?; + 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 / backtick qualifier → concrete + } + if re_vague_quality().is_match(remainder) { + return Some(verb); // abstract quality/manner object → vague + } + if lexicon.contains_term(remainder) { + return None; // a module-declared concrete term → verifiable + } + Some(verb) // bare / generic response → vague +} + +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` 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("**", "") +} + +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") + }) +} + +/// 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") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc(text: &str) -> crate::ast::QuireDocument { + 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() { + 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, &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, &empty()); + 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, &empty()); + assert_eq!(f.iter().filter(|x| x.check == "missing-subject").count(), 0); + } + + // 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() { + // Empty lexicon: only the engine-generic signals (mechanism/bound/ + // backtick) suppress; bare/abstract objects flag. + let count = |text: &str| { + check("FR", &doc(text), 0, &empty()) + .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); + + // 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." + )), + 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 + ); + } + + 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 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 + // 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); + } + + // 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() { + 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, &empty()); + 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, &empty()); + 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, &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, &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, &empty()); + 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, &empty()).is_empty()); + } +} diff --git a/src/grammar/mod.rs b/src/grammar/mod.rs new file mode 100644 index 0000000..a7f729b --- /dev/null +++ b/src/grammar/mod.rs @@ -0,0 +1,160 @@ +//! 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 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 { + // 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({})\b", forms.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)) + } +} + +/// 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(); + 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 +/// 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, + lexicon: &GrammarLexicon, +) -> Vec { + match grammar_ref { + "iso-spec-core" => ears::check(archetype, doc, line_offset, lexicon), + _ => Vec::new(), + } +} diff --git a/src/lib.rs b/src/lib.rs index d83ecc0..64a8cd2 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; @@ -50,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. @@ -69,12 +71,13 @@ 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, 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}; // 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, 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}; 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 5d54d54..6a9afba 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,72 @@ 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] +#[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(|| -> PyResult> { + let doc = crate::parse_document(&text); + 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) => { + 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()); + Ok(crate::grammar::check_document_grammar( + &gref, + &arch, + &doc, + line_offset, + lexicon, + )) + })?; + + 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/registry.rs b/src/registry.rs index f72fcad..70df37e 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,29 @@ 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 + } + + /// 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. @@ -750,6 +782,124 @@ roles: .any(|d| matches!(d, Diagnostic::DuplicateEdgeType { name, .. } if name == "calls"))); } + // TC-667 (FR-043-AC-1): a `lexicon` term loads, is readable via + // `Registry::lexicon()`, and the precompiled matcher recognises it. + #[test] + 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", + ); + 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.diagnostics().iter().any( + |d| matches!(d, Diagnostic::DuplicateLexiconTerm { name, .. } if name == "pagination") + )); + } + + // 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-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 90a630a..c8a43ef 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,71 @@ 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(); + // 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) +} + +/// 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`). `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, + lexicon, + ) { + 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` @@ -184,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); @@ -264,6 +354,18 @@ pub fn validate_document_in_registry( } } + // ── Requirement-grammar layer (EARS, FR-042; advisory by default) ── + // 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, + lexicon, + &mut errors, + &mut warnings, + ); + ValidationResult::new(errors, warnings) } @@ -312,7 +414,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 +881,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/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 cf0eaf5..9e3e522 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,55 @@ 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_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( - "---\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 +479,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'