Module: SpecGuard::RSpec::Scanner
- Defined in:
- lib/specguard/rspec/scanner.rb
Overview
Runs the discovery pipeline over source files and returns Findings.
Pipeline, per @intent: token:
{AnnotationScanner} -> {PayloadNormalizer} -> `JSON.parse`
This is the whole of the input half of the linter. It deliberately stops
at "an annotation is a Hash": nothing here loads or applies the
OpenTestIntent schema, so a Finding with an intent is merely
syntactically sound, not valid.
Class Method Summary collapse
-
.parse(raw, file:, line:) ⇒ Finding
RATIFIED DIFFERENCE from the reference tool.
-
.scan_file(path) ⇒ Array<Finding>
One per
@intent:token; empty when the file carries none. -
.scan_files(paths) ⇒ Array<Finding>
In file-then-line order.
- .scan_text(text, file:) ⇒ Array<Finding>
Class Method Details
.parse(raw, file:, line:) ⇒ Finding
RATIFIED DIFFERENCE from the reference tool. Two of them, and only the first is a difference in reason TEXT — an earlier revision of this comment claimed there was just the one, and it was wrong in a way worth recording, because the mistake was structural rather than careless: it described a symptom instead of a cause.
(1) The wording, when both parsers reject the payload
PayloadNormalizer rescues PROTOCOL.md §1's permissive syntax on both
sides, so a payload it can fix renders identically. What reaches
JSON.parse still broken — a bare-word VALUE, a key that is not a key
— is described by whichever JSON parser is doing the parsing: this
interpolates Ruby's JSON::ParserError#message, while
bin/validate-intent lets CPython describe it and the Go port
reproduces CPython's wording deliberately (cmd/validate-intent/ pyjson.go). So {bad_key} is expected object key, got 'bad_key}' at line 1 column 2 here and Expecting property name enclosed in double quotes: line 1 column 2 (char 1) there.
Reproducing that tail would mean porting CPython's JSON diagnostics
into a gem whose entire reason for vendoring the schema is to owe
open-test-intent nothing at runtime — the argument scan_text makes
about CPython's DECODER classification, applied verbatim to its PARSER.
What is shared is asserted in tests/parity/run_ruby_parity.sh under
"ratified difference (c)": same classification, same file, same LINE
(unlike a read failure, this one is line-scoped and both agree on the
line and even the column), same could not parse annotation: prefix,
same counts, and exit 1. Only the tail is unpinned.
(2) THE ACCEPTANCE SET: the payloads only ONE parser rejects
(1) is about how the two spell the same refusal. This is about the
payloads where they do not both refuse — a bigger difference, and the
generator (1) was hiding. CPython's grammar is strictly the more
permissive of the two, so there is a set of payloads json.loads
accepts and JSON.parse does not, and every member of it diverges.
The set was DERIVED rather than guessed, because guessing is what
produced the "only the tail differs" claim: 89,108 documents — the
port's own pyjson_fuzz_test.go seed corpus and token alphabet, a
generated well-formed corpus, and every one of the 65,536 single
\uXXXX escapes — through both parsers. Widening the sweep did not add
a member. It has exactly THREE:
1. the non-finite literals `NaN` / `Infinity` / `-Infinity`. CPython
accepts them and the port accepts them ON PURPOSE — pyjson.go's
"WHAT IT BUYS BEYOND THE PROSE" records that an earlier Go
decoder rejecting them "made Go classify such a document as a
parse failure where Python reported a schema violation", and that
retiring that exclusion was the point of the file. The same
divergence it closed between Go and Python is the one that reopens
here between Ruby and Go.
2. a HIGH surrogate escape (`\ud800`-`\udbff`) not followed by a LOW
one. Ruby raises `incomplete surrogate pair` when NOTHING is
escaped after it — last in the string, or followed by a literal
character. Any following `\uXXXX` escape rescues it, low surrogate
or not. A lone LOW surrogate is accepted by both — the rule is
narrower than "surrogate escapes diverge".
3. (RETIRED — `json` narrowed to match) container nesting past Ruby's
`max_nesting: 100` default, which used to diverge. CPython
has no document-depth limit short of its recursion limit.
Each produces one of two shapes, depending on whether the payload the port successfully parses then passes the schema:
(v) NOT schema-valid — the CLASSIFICATION differs. This path reports
KIND_PARSE and renders one em-dash line; the backend reports
KIND_SCHEMA and renders a `-> ` line per violation. File, line,
counts and exit code still agree.
(vi) schema-valid — the VERDICT differs. This path reports the
annotation malformed and exits 1; the backend reports nothing
and exits 0. Only the file agrees.
(vi) is reachable through member 2 alone, and provably so: a
schema-valid payload must put the offending syntax in a value the
schema permits, and every such value is a string or an array of strings
(SpecGuard::RSpec::SCHEMA_PATH). A number and a container cannot
occupy a string slot; a surrogate escape lives inside one.
RATIFIED, and the reason is scope, not preference. allow_nan: true
would close member 1 — an option on the JSON.parse call below, as
max_nesting: false was for the since-retired member 3 — but member 2 has no
option and would need CPython's string decoder ported into this gem,
which is the trade scan_text and (1) above have each already
declined. Closing one of two would leave the set inconsistent, and
either one is a change to the DEFAULT path, which the slice
that introduced ValidatorBackend explicitly holds fixed. Whether the
gem should adopt CPython's acceptance grammar wholesale is a question
about this gem's default behaviour, and it is deliberately left open
rather than settled here.
Asserted from both sides — the enumeration itself, the boundary, and
both shapes — in spec/specguard/rspec/validator_backend_spec.rb under
"the JSON acceptance-set difference", and against the real binary in
tests/parity/run_ruby_parity.sh under "ratified difference (d)" and
section 8b's entries (v) and (vi).
Note that JSON::NestingError is a subclass of JSON::ParserError, so
a nesting refusal is already carried by the rescue below; it needs no
clause of its own, and adding one would suggest the classification
differs when it does not.
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/specguard/rspec/scanner.rb', line 200 def parse(raw, file:, line:) intent = JSON.parse(PayloadNormalizer.normalize(raw)) # A bare `[...]` or scalar is syntactically fine JSON but is not an # annotation. Reject it here so downstream stages can assume a Hash. unless intent.is_a?(Hash) return Finding.new(file: file, line: line, kind: Finding::KIND_PARSE, problem: "annotation payload is not an object") end Finding.new(file: file, line: line, intent: intent) rescue ScanError, JSON::ParserError => e Finding.new(file: file, line: line, kind: Finding::KIND_PARSE, problem: "could not parse annotation: #{e.}") end |
.scan_file(path) ⇒ Array<Finding>
Returns one per @intent: token; empty when the file
carries none. A file that cannot be read yields a single Finding at
line 0 rather than raising — one bad file must not abort the run.
RATIFIED DIFFERENCE from the reference tool, for a path that does not
exist. bin/validate-intent expands its arguments as glob PATTERNS
(bin/validate-intent:493), so a name matching nothing is a
statement about the pattern — error: no file(s) match '<path>', on
stderr, before any file is opened. This linter does no globbing:
explicit files are checked as given and --changed derives its list
from git, so every argument here is a PATH, and an unopenable one is
a read failure OF THAT PATH — reported on stdout with the errno the
reference cannot name. Adopting the reference's wording would mean
giving this gem a globber first and changing what
specguard-lint 'foo*_spec.rb' means for everyone already using it.
Both tools exit 1 and both name the file; that much is asserted, in
open-test-intent's tests/parity/run_ruby_parity.sh under "ratified
difference (b)", along with the case that matters more — a missing
file does not stop either tool checking the good files named beside
it.
48 49 50 51 52 53 54 55 56 57 |
# File 'lib/specguard/rspec/scanner.rb', line 48 def scan_file(path) begin text = File.read(path, encoding: "UTF-8") rescue SystemCallError, IOError => e return [Finding.new(file: path, line: 0, problem: "could not read file: #{e.}", kind: Finding::KIND_READ)] end scan_text(text, file: path) end |
.scan_files(paths) ⇒ Array<Finding>
Returns in file-then-line order.
22 23 24 |
# File 'lib/specguard/rspec/scanner.rb', line 22 def scan_files(paths) paths.flat_map { |path| scan_file(path) } end |
.scan_text(text, file:) ⇒ Array<Finding>
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/specguard/rspec/scanner.rb', line 62 def scan_text(text, file:) # An invalid byte sequence would make every String operation below raise # from deep inside the scanner. Report it as this file's one problem. # # RATIFIED DIFFERENCE from the reference tool, in the REASON TEXT only. # `bin/validate-intent` lets CPython describe the failure and the Go # port reproduces that description deliberately # (`cmd/validate-intent/fileio.go:69`), so both say `'utf-8' codec # can't decode byte 0xe9 in position 117: invalid continuation byte` # where this says `invalid UTF-8 byte sequence`. Reproducing it would # mean porting CPython's decoder-error classification into a gem whose # whole reason for vendoring the schema is to owe open-test-intent # nothing at runtime — and open-test-intent's own harness already # declines to compare that tail between Python and Go (run_parity.sh, # excluded group 1, "Non-UTF-8 input — the PROSE only"). # # What is shared is asserted in `tests/parity/run_ruby_parity.sh` under # "ratified difference (a)": same classification, same file, same # `FAIL <file> — could not read file: ` prefix, a non-empty reason on # both sides, and exit 1. unless text.valid_encoding? return [Finding.new(file: file, line: 0, problem: "could not read file: invalid UTF-8 byte sequence", kind: Finding::KIND_READ)] end AnnotationScanner.each_intent(text).map do |line_no, raw, problem| if problem Finding.new(file: file, line: line_no, problem: problem, kind: Finding::KIND_EXTRACTION) else parse(raw, file: file, line: line_no) end end end |