id: linter.ears_syntax kind: intent statement: “WHEN a spec file’s frontmatter has passed, THE linter SHALL reject it if the intent statement does not match one of the five EARS patterns or if any row id joins two capabilities.”
Linter: EARS Syntax Check
Runs after linter.frontmatter only — unlike the other checks, it doesn’t
need the reference graph or model, since it operates purely on the
statement string and the shape of id cells. Corresponds to
testability-implementability-evaluator’s EARS-compliance and
NLP-anti-pattern steps, narrowed to what can be checked structurally rather
than by judging prose quality.
Purpose
Runs after linter.frontmatter only — unlike the other checks, it doesn’t
need the reference graph or model, since it operates purely on the
statement string and the shape of id cells. Corresponds to
testability-implementability-evaluator’s EARS-compliance and
NLP-anti-pattern steps, narrowed to what can be checked structurally rather
than by judging prose quality.
Constraints
| id | kind | expr | traces_to | satisfies |
|–––––––––––|———–|–––––––––––––––––––––––––––––––––––––––––––––––––––––––|––––––––––––––––––––––––––|
| ears_pattern_match | invariant | statement matches one of: Ubiquitous / Event-Driven / State-Driven / Unwanted-Behavior / Optional-Feature | [[linter.ears_syntax]] | |
| has_shall | invariant | statement contains an imperative "SHALL" (or "SHALL NOT") | [[linter.ears_syntax]] | |
| no_conjoined_id | invariant | ∀ row.id: id does not encode two capabilities joined by "and"/"or" (checked on the id token, not prose) — the top-level rule this row re-owns from specodelic.md’s higher-altitude one_capability_per_row restatement | [[linter.ears_syntax]]| |
| no_universal_in_id | invariant | ∀ row.id: id does not contain "all"/"every"/"any"/"always"/"never" as a token — applies to every table row id (Intent, Constraint, State, Transition, Property), not only Constraint rows; the top-level rule this row re-owns from specodelic.md’s higher-altitude one_capability_per_row restatement | [[linter.ears_syntax]]| |
| pattern_failure | effect | linter.ears_syntax.pattern_failure(detail) | [[linter.ears_syntax]] | [[errors.envelope_error_kind]] ∧ [[errors.exit_code_mapping]] ∧ [[errors.remediation_hint_present]] |
| id_check_failure | effect | linter.ears_syntax.id_check_failure(detail) | [[linter.ears_syntax]] | [[errors.envelope_error_kind]] ∧ [[errors.exit_code_mapping]] ∧ [[errors.remediation_hint_present]] |
Model
States
uncheckedpattern_matchingid_checkingpassedpattern_failed(emits:[[linter.ears_syntax.pattern_failure]])id_check_failed(emits:[[linter.ears_syntax.id_check_failure]])
Transitions
| id | from | to | guard |
|---|---|---|---|
| begin | unchecked | pattern_matching | file passed linter.frontmatter |
| pattern_ok | pattern_matching | id_checking | [[linter.ears_syntax.ears_pattern_match]] ∧ [[linter.ears_syntax.has_shall]] |
| pattern_fail | pattern_matching | pattern_failed | ¬([[linter.ears_syntax.ears_pattern_match]] ∧ [[linter.ears_syntax.has_shall]]) |
| accept | id_checking | passed | [[linter.ears_syntax.no_conjoined_id]] ∧ [[linter.ears_syntax.no_universal_in_id]] |
| reject | id_checking | id_check_failed | ¬([[linter.ears_syntax.no_conjoined_id]] ∧ [[linter.ears_syntax.no_universal_in_id]]) |
Properties
| id | kind | derives_from | generator | predicate |
|---|---|---|---|---|
| non_ears_rejected | unit | [[linter.ears_syntax.ears_pattern_match]] | statement_not_matching_any_ears_pattern() | check(file) == failed |
| missing_shall_rejected | unit | [[linter.ears_syntax.has_shall]] | statement_matching_pattern_but_missing("SHALL") | check(file) == failed |
| conjoined_id_rejected | unit | [[linter.ears_syntax.no_conjoined_id]] | row_with(id: "order.cancel_and_refund") | check(file) == failed |
| universal_token_rejected | unit | [[linter.ears_syntax.no_universal_in_id]] | row_with(id: "order.always_validate") | check(file) == failed |
| valid_ears_passes | unit | [[linter.ears_syntax.ears_pattern_match]] | arbitrary_ears_compliant_statement() | check(file) == passed |
| pattern_failure_label_asserted | unit | [[linter.ears_syntax.pattern_failure]] | pattern_failure_raised() | error_label == "linter.ears_syntax.pattern_failure" — renaming the label touches the error Constraint, this property, and its note together (EDGE-002) |
| id_check_failure_label_asserted | unit | [[linter.ears_syntax.id_check_failure]] | id_check_failure_raised() | error_label == "linter.ears_syntax.id_check_failure" — renaming the label touches the error Constraint, this property, and its note together (EDGE-002) |
Notes
This check deliberately does not attempt weak-phrase detection
(“adequate,” “as appropriate”), passive-voice detection, or non-specific
temporals (“immediately,” “quickly”) — those are exactly the prose-quality
judgments specodelic.md’s prose_untouched invariant puts out of the
linter’s scope, and are correctly testability-implementability-evaluator’s
job to run separately over the compiled statement field, not this
schema’s job to structurally forbid. The line drawn here: EARS pattern
conformance is a grammar fact (does the sentence have the right shape),
weak-phrase/temporal/passive-voice detection is a judgment about word
choice within a grammatically valid sentence — the former belongs in the
linter, the latter doesn’t.
no_conjoined_id/no_universal_in_id check the id token, not the
prose statement — e.g. rejecting id: "cancel_and_refund" structurally,
while a compound statement in prose still isn’t caught (correctly, since
that would require judging prose). Scope decision of record (2026-09-30,
specodelic-cxq): the check covers every table row id — Intent,
Constraint, State, Transition, and Property rows alike. A property named
law_cases_unexecuted or a transition named scan_effects carries the same
compound/universal-token problem a constraint id does, so the corpus’s
six such rows were renamed in the same change rather than carving the
row kinds out of the rule. This is narrower than
specification-evaluation-diagnostician’s “Compound Requirements” finding,
which also reads sentence structure — worth flagging as a known gap this
checker doesn’t close, rather than one it silently claims to.
No new specodelic.md gaps surfaced this time — this is the first check
whose constraints all trace cleanly to invariants already on the list.
Requirements
Requirement: Property coverage mirror
Every property row SHALL be verified by exactly one dedicated scenario;
ah sync SHALL derive one contract per VERIFIES link.
Scenario: non_ears_rejected
- WHEN
statement_not_matching_any_ears_pattern() - THEN
check(file) == failed - VERIFIES [[linter.ears_syntax.non_ears_rejected]]
Scenario: missing_shall_rejected
- WHEN
statement_matching_pattern_but_missing("SHALL") - THEN
check(file) == failed - VERIFIES [[linter.ears_syntax.missing_shall_rejected]]
Scenario: conjoined_id_rejected
- WHEN
row_with(id: "order.cancel_and_refund") - THEN
check(file) == failed - VERIFIES [[linter.ears_syntax.conjoined_id_rejected]]
Scenario: universal_token_rejected
- WHEN
row_with(id: "order.always_validate") - THEN
check(file) == failed - VERIFIES [[linter.ears_syntax.universal_token_rejected]]
Scenario: valid_ears_passes
- WHEN
arbitrary_ears_compliant_statement() - THEN
check(file) == passed - VERIFIES [[linter.ears_syntax.valid_ears_passes]]
Scenario: pattern_failure_label_asserted
- WHEN
pattern_failure_raised() - THEN
error_label == "linter.ears_syntax.pattern_failure"— renaming the label touches the error Constraint, this property, and its note together (EDGE-002) - VERIFIES [[linter.ears_syntax.pattern_failure_label_asserted]]
Scenario: id_check_failure_label_asserted
- WHEN
id_check_failure_raised() - THEN
error_label == "linter.ears_syntax.id_check_failure"— renaming the label touches the error Constraint, this property, and its note together (EDGE-002) - VERIFIES [[linter.ears_syntax.id_check_failure_label_asserted]]