Class: SpecGuard::RSpec::AnnotationLookup
- Inherits:
-
Object
- Object
- SpecGuard::RSpec::AnnotationLookup
- Defined in:
- lib/specguard/rspec/annotation_lookup.rb
Overview
The lookback rule (SPGD-12 §2)
example.metadata[:line_number] is the line the it is on. An annotation
counts as this example's when it sits on that line (the trailing form) or
on the line immediately above it (the preceding-comment form):
# @intent: { entity: "Order", ... } <- line above
it "restores stock on refund" do <- metadata[:line_number]
it "surfaces the decline reason" do # @intent: { ... } <- same line
One line of lookback, no more. Multi-line annotations are unsupported in v1 (PROTOCOL.md §1 requires an annotation to fit on one line), so a wider window could only ever attach an annotation to the wrong example.
Only the comment form is inherited by the line below
The two forms are not symmetric, and the difference is not cosmetic. A comment-only line hosts no example, so an annotation written there has exactly one possible claimant — the line under it. An annotation written in the trailing form is on a line that is an example's, so it has two, and one-liner specs make the second one ordinary rather than exotic:
it { is_expected.to eq(1) } # @intent: { entity: "Order", ... }
it { is_expected.to be_positive }
Lookback that did not distinguish the forms would report both of those
as annotated, carrying the same intent — so the platform would store the
second example's declared purpose as something nobody declared, and count
it in annotated_specs_count. That is worse than reporting it
unannotated: a missing annotation is a visible gap, an invented one is
confident false telemetry, and it inflates the very ratio status exists
to keep honest.
So a Finding is claimable by the line below it only when its own line is comment-only (COMMENT_LINE). Deciding that needs the source line, which is why #build_index reads the file itself and calls Scanner.scan_text rather than Scanner.scan_file — the read is the same single read either way, and the alternative was a second one per file purely to re-derive what the first one already had in hand.
Why a malformed annotation is reported as unannotated
AnnotationScanner states, correctly, that a typo'd annotation "must fail loudly rather than silently counting as unannotated". That is the linter's stance, and the linter already acts on it: it prints the file:line and exits 1. It cannot bind the formatter, which is under the never-block-CI contract and has no way to be loud that is not also a way to be in someone's build output.
The alternative is worse than it looks. Ingest::Payload is
all-or-nothing — it collects errors globally and valid? requires the
list to be empty — so shipping one schema-invalid intent does not lose
that annotation, it loses the whole run: a 400, and no telemetry for
any of the other twenty thousand examples. Downgrading one annotation to
unannotated costs one row's metadata. The example's name, duration and
outcome still ship either way.
So every one of these produces the same answer — nil, meaning
unannotated:
* no `@intent:` on either candidate line;
* an `@intent:` on the line above that is *not* a comment-only line —
the trailing form belongs to its own example and lends itself to
nobody;
* an `@intent:` whose payload could not be captured or parsed
({Finding#problem?} — `KIND_EXTRACTION` / `KIND_PARSE`);
* a file that could not be read at all (`KIND_READ`);
* a syntactically fine annotation the schema rejects;
* the schema itself failing to load.
Cost
SpecGuard::RSpec::AnnotationScanner.each_intent walks every line of the file it is handed. Scanning per example would make a 20,000-example suite read and walk its spec files 20,000 times, on the critical path of somebody's test run. So each file is read and scanned at most once and reduced to an Index: O(files), not O(examples). Schema validation is memoized on the same principle — per distinct annotation, not per example carrying it.
Defined Under Namespace
Classes: Index
Constant Summary collapse
- FIRST_REAL_LINE =
Findings at line 0 are Finding::KIND_READ — "this file was never read", not "there is an annotation on line 0". Keeping the sentinel out of the index is what makes Index#finding_for's unconditional
line - 1lookback safe for an example on line 1. 1- COMMENT_LINE =
A line whose only content is a comment: the preceding-comment form, and the only form an example on the next line may claim. Leading whitespace is allowed because annotations are indented with the examples they describe.
/\A\s*#/- EMPTY_INDEX =
The answer for a file nothing could be read out of. Its own constant so the pessimistic pre-scan cache in #index_for and the unreadable-file path cannot drift apart.
Index.new({}.freeze, {}.freeze).freeze
Instance Method Summary collapse
-
#initialize(schema_path: SCHEMA_PATH) ⇒ AnnotationLookup
constructor
A new instance of AnnotationLookup.
-
#intent_for(file:, line:) ⇒ Hash?
The intent to attach to one example, or nil when it is unannotated.
Constructor Details
#initialize(schema_path: SCHEMA_PATH) ⇒ AnnotationLookup
Returns a new instance of AnnotationLookup.
143 144 145 146 147 |
# File 'lib/specguard/rspec/annotation_lookup.rb', line 143 def initialize(schema_path: SCHEMA_PATH) @schema_path = schema_path @indexes = {} @verdicts = {} end |
Instance Method Details
#intent_for(file:, line:) ⇒ Hash?
The intent to attach to one example, or nil when it is unannotated.
Nothing here is rescued: a caller that cannot survive an exception must
say so itself, and the formatter does — it wraps this in the same
never_fail_the_run envelope as everything else, so a blow-up costs the
example its annotation and not the suite its exit code. The memoization
below is deliberately written so that a failure is cached too, which is
what keeps "warns once" from becoming "warns once but rescans the file
for every one of the remaining examples".
162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/specguard/rspec/annotation_lookup.rb', line 162 def intent_for(file:, line:) finding = finding_for(file: file, line: line) # {Finding#extracted?} is the discovery layer's own word for "this # yielded a Hash" — and, pointedly, "not a claim that it is valid". # Everything it excludes (KIND_EXTRACTION, KIND_PARSE, KIND_READ) is an # annotation the linter fails the build over and this half must not. return nil unless finding&.extracted? validated(finding.intent) end |