specguard-ruby 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SpecGuard
6
+ module RSpec
7
+ # Relaxes PROTOCOL.md §1's permissive annotation syntax into strict JSON.
8
+ #
9
+ # §1 promises "the linter normalizes before validating" and lists three
10
+ # equivalent forms. This converts all of them:
11
+ #
12
+ # * unquoted keys `{entity:"Order"}` -> `{"entity":"Order"}`
13
+ # * single-quoted strings `{'entity':'Order'}` -> `{"entity":"Order"}`
14
+ # * arbitrary whitespace (`JSON.parse` already tolerates it)
15
+ #
16
+ # plus a trailing comma before `}`/`]` as a courtesy.
17
+ #
18
+ # == Why this is a character scanner, not a `gsub`
19
+ #
20
+ # It is a character-scanner rather than a regex substitution **so quoted
21
+ # content is never rewritten** — a `behavior` sentence containing `it's` or
22
+ # `{` is passed through untouched. Any `gsub`-then-`JSON.parse` port
23
+ # corrupts exactly those values; the examples under "quoted content is
24
+ # never rewritten" in `payload_normalizer_spec.rb` pin them.
25
+ #
26
+ # The payload is *never* `eval`'d. It is attacker-controllable text taken
27
+ # from a comment in someone's spec file, so it only ever reaches
28
+ # `JSON.parse`, which cannot execute anything.
29
+ #
30
+ # == The bare-word rule
31
+ #
32
+ # A bare word is quoted **only in key position**. A bare word used as a
33
+ # value (`{layer: request}`) is left alone so it still fails downstream —
34
+ # the protocol relaxes keys and quote style, never value quoting.
35
+ #
36
+ # Ported from open-test-intent's `bin/validate-intent`
37
+ # (`normalize_payload` / `_requote`).
38
+ module PayloadNormalizer
39
+ # Anchored at the scan position (`\G`), so it matches AT i rather than
40
+ # searching from it. Explicit ASCII ranges, per PROTOCOL.md §1 — a Unicode
41
+ # word class would make the accepted surface syntax depend on the regex
42
+ # engine, which is what one specification exists to prevent.
43
+ BARE_WORD = /\G[A-Za-z_$][A-Za-z0-9_$]*/.freeze
44
+
45
+ module_function
46
+
47
+ # @param raw [String] the object literal as written in the source
48
+ # @return [String] strict JSON, ready for `JSON.parse`
49
+ # @raise [ScanError] on an unterminated string literal
50
+ def normalize(raw)
51
+ out = +""
52
+ i = 0
53
+ length = raw.length
54
+
55
+ while i < length
56
+ char = raw[i]
57
+
58
+ # Already-strict double-quoted string: copy verbatim, contents and all.
59
+ if char == '"'
60
+ finish = AnnotationScanner.scan_string(raw, i, '"')
61
+ out << raw[i...finish]
62
+ i = finish
63
+ next
64
+ end
65
+
66
+ # Single-quoted string: re-emit its body as a JSON string.
67
+ if char == "'"
68
+ finish = AnnotationScanner.scan_string(raw, i, "'")
69
+ out << requote(raw[(i + 1)...(finish - 1)])
70
+ i = finish
71
+ next
72
+ end
73
+
74
+ if (match = BARE_WORD.match(raw, i))
75
+ word = match[0]
76
+ after = match.end(0)
77
+ out << (key_position?(raw, after) ? JSON.generate(word) : word)
78
+ i = after
79
+ next
80
+ end
81
+
82
+ # Trailing comma before a closing bracket: drop it.
83
+ if char == "," && closes_after?(raw, i + 1)
84
+ i += 1
85
+ next
86
+ end
87
+
88
+ out << char
89
+ i += 1
90
+ end
91
+
92
+ out
93
+ end
94
+
95
+ # Re-emits the body of a single-quoted string as a double-quoted JSON
96
+ # string, preserving every escape that JSON understands.
97
+ #
98
+ # @param body [String] the string's contents, without its surrounding quotes
99
+ # @return [String] a complete JSON string literal, quotes included
100
+ def requote(body)
101
+ out = +""
102
+ i = 0
103
+ length = body.length
104
+
105
+ while i < length
106
+ char = body[i]
107
+
108
+ if char == "\\"
109
+ nxt = i + 1 < length ? body[i + 1] : ""
110
+ # `\'` is meaningless in JSON — unescape it; keep every other escape.
111
+ out << (nxt == "'" ? "'" : char + nxt)
112
+ i += 2
113
+ next
114
+ end
115
+
116
+ out << (char == '"' ? '\"' : char)
117
+ i += 1
118
+ end
119
+
120
+ %("#{out}")
121
+ end
122
+
123
+ # True when the next non-space character after `pos` is a `:`, i.e. the
124
+ # bare word just scanned is a key rather than a value.
125
+ def key_position?(raw, pos)
126
+ probe = skip_space(raw, pos)
127
+ probe < raw.length && raw[probe] == ":"
128
+ end
129
+
130
+ # True when the next non-space character at or after `pos` closes a
131
+ # bracket, i.e. the comma just scanned was a trailing comma.
132
+ def closes_after?(raw, pos)
133
+ probe = skip_space(raw, pos)
134
+ probe < raw.length && (raw[probe] == "}" || raw[probe] == "]")
135
+ end
136
+
137
+ def skip_space(raw, pos)
138
+ pos += 1 while pos < raw.length && raw[pos].match?(/\s/)
139
+ pos
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SpecGuard
6
+ module RSpec
7
+ # Runs the discovery pipeline over source files and returns {Finding}s.
8
+ #
9
+ # Pipeline, per `@intent:` token:
10
+ #
11
+ # {AnnotationScanner} -> {PayloadNormalizer} -> `JSON.parse`
12
+ #
13
+ # This is the whole of the *input* half of the linter. It deliberately stops
14
+ # at "an annotation is a Hash": nothing here loads or applies the
15
+ # OpenTestIntent schema, so a Finding with an `intent` is merely
16
+ # syntactically sound, not valid.
17
+ module Scanner
18
+ module_function
19
+
20
+ # @param paths [Enumerable<String>]
21
+ # @return [Array<Finding>] in file-then-line order
22
+ def scan_files(paths)
23
+ paths.flat_map { |path| scan_file(path) }
24
+ end
25
+
26
+ # @param path [String]
27
+ # @return [Array<Finding>] one per `@intent:` token; empty when the file
28
+ # carries none. A file that cannot be read yields a single Finding at
29
+ # line 0 rather than raising — one bad file must not abort the run.
30
+ #
31
+ # RATIFIED DIFFERENCE from the binary, for a path that does not exist.
32
+ # `validate-intent` expands its arguments as glob PATTERNS, so a name
33
+ # matching nothing is a statement about the pattern —
34
+ # `error: no file(s) match '<path>'`, on stderr, before any file is
35
+ # opened. This linter does no globbing: explicit files are checked as
36
+ # given and `--changed` derives its list from git, so every argument
37
+ # here is a PATH, and an unopenable one is a read failure OF THAT PATH —
38
+ # reported on stdout with the errno the binary never had occasion to
39
+ # name. Adopting the binary's wording would mean giving this gem a
40
+ # globber first, and changing what `specguard-lint 'foo*_spec.rb'` means
41
+ # for everyone already using it.
42
+ #
43
+ # Both tools exit 1 and both name the file; that much is asserted in
44
+ # spec/specguard/rspec/validator_backend_spec.rb, along with the case
45
+ # that matters more — a missing file does not stop either tool checking
46
+ # the good files named beside it.
47
+ #
48
+ # A path that exists and is NOT A REGULAR FILE lands in the same rescue
49
+ # and is the same ratified difference one step further out: this rescue
50
+ # has an errno and reports it (`Is a directory @ io_fread - <path>`),
51
+ # while the binary's glob filters non-regular matches away and answers
52
+ # exactly as it does for a name matching nothing. Ruby tells the two
53
+ # apart and the backend cannot — asserted under "a path that is not a
54
+ # regular file" in that same spec.
55
+ def scan_file(path)
56
+ begin
57
+ text = File.read(path, encoding: "UTF-8")
58
+ rescue SystemCallError, IOError => e
59
+ return [Finding.new(file: path, line: 0, problem: "could not read file: #{e.message}",
60
+ kind: Finding::KIND_READ)]
61
+ end
62
+
63
+ scan_text(text, file: path)
64
+ end
65
+
66
+ # @param text [String] source of one file
67
+ # @param file [String] the path to record on each Finding
68
+ # @return [Array<Finding>]
69
+ def scan_text(text, file:)
70
+ # An invalid byte sequence would make every String operation below raise
71
+ # from deep inside the scanner. Report it as this file's one problem.
72
+ #
73
+ # RATIFIED DIFFERENCE from the binary, in the REASON TEXT only. Both
74
+ # refuse the file — PROTOCOL.md §1.1 makes UTF-8 part of what a JSON
75
+ # text IS, and neither side repairs the bytes — and both name the
76
+ # CONDITION rather than an offset: the binary says `input is not
77
+ # well-formed UTF-8 (PROTOCOL.md §1.1 requires it)`, this says
78
+ # `invalid UTF-8 byte sequence`. Neither wording is specified, so
79
+ # neither is wrong; what matters is that both classify it as a READ
80
+ # failure and neither substitutes U+FFFD and carries on.
81
+ #
82
+ # What is shared is asserted in spec/specguard/rspec/validator_backend_spec.rb,
83
+ # under "the not-well-formed-UTF-8 text is each backend's own": same
84
+ # classification, same file, same `FAIL <file> — could not read file: `
85
+ # prefix, a non-empty reason on both sides, and exit 1 — and the tails
86
+ # are asserted to still DIFFER, so converging them fails that file
87
+ # rather than leaving this comment stale.
88
+ unless text.valid_encoding?
89
+ return [Finding.new(file: file, line: 0, problem: "could not read file: invalid UTF-8 byte sequence",
90
+ kind: Finding::KIND_READ)]
91
+ end
92
+
93
+ AnnotationScanner.each_intent(text).map do |line_no, raw, problem|
94
+ if problem
95
+ Finding.new(file: file, line: line_no, problem: problem, kind: Finding::KIND_EXTRACTION)
96
+ else
97
+ parse(raw, file: file, line: line_no)
98
+ end
99
+ end
100
+ end
101
+
102
+ # RATIFIED DIFFERENCE from the binary. Two of them, and only the first is
103
+ # a difference in reason TEXT.
104
+ #
105
+ # == (1) The wording, when both parsers reject the payload
106
+ #
107
+ # `PayloadNormalizer` rescues PROTOCOL.md §1's permissive syntax on both
108
+ # sides, so a payload it can fix renders identically. What reaches
109
+ # `JSON.parse` still broken — a bare-word VALUE, a key that is not a key
110
+ # — is described by whichever JSON parser is doing the parsing: this
111
+ # interpolates Ruby's `JSON::ParserError#message`, while the binary uses
112
+ # its own. So `{bad_key}` is `expected object key, got 'bad_key}' at line
113
+ # 1 column 2` here and `expected a double-quoted property name (line 1,
114
+ # column 2)` there.
115
+ #
116
+ # Reproducing that tail would mean carrying a second parser's diagnostics
117
+ # in a gem whose entire reason for vendoring the schema is to owe
118
+ # open-test-intent nothing at runtime — the argument `scan_text` makes
119
+ # about the DECODER, applied verbatim to the PARSER. PROTOCOL.md
120
+ # specifies the accepted LANGUAGE, not the prose a validator refuses in,
121
+ # so two spellings of one refusal are both conformant.
122
+ #
123
+ # What is shared is asserted in
124
+ # spec/specguard/rspec/validator_backend_spec.rb: same classification,
125
+ # same file, same LINE (unlike a read failure, this one is line-scoped and
126
+ # both agree on the line and even the column), same
127
+ # `could not parse annotation: ` prefix, same counts, and exit 1. Only the
128
+ # tail is unpinned.
129
+ #
130
+ # == (2) THE ACCEPTANCE SET: where the two parsers take different input
131
+ #
132
+ # (1) is about how the two spell the same refusal. This is about payloads
133
+ # where they do not both refuse — a bigger difference, and the generator
134
+ # (1) was hiding.
135
+ #
136
+ # This USED to be a three-member set in which the binary was the
137
+ # permissive side, because its parser reproduced a foreign runtime's
138
+ # grammar that `PROTOCOL.md` had never specified. PROTOCOL.md §1.1 states
139
+ # the grammar now: an RFC 8259 JSON text, with the three points that RFC
140
+ # leaves open settled explicitly. The binary refuses the non-finite
141
+ # literals (§1.1(b)), unpaired surrogate escapes (§1.1(a)) and nesting
142
+ # past 100 (§1.1(c)), and `JSON.parse` refuses or limits all three too, so
143
+ # those three have CONVERGED.
144
+ #
145
+ # WHAT SURVIVES RUNS THE OTHER WAY: this parser is now the permissive one.
146
+ #
147
+ # Read that as scoped to PARSING, which is all this register has ever
148
+ # covered. It is not a statement that the two backends agree everywhere
149
+ # else: the stage BEFORE this one diverged too, and in the opposite
150
+ # direction. Until SPGD-512 the binary's `@intent:` payload search was
151
+ # unbounded where {AnnotationScanner#payload_brace} bounds it at the next
152
+ # token, so a malformed token followed by a well-formed one was captured
153
+ # as valid by the binary and reported no-payload by this gem — binary
154
+ # permissive, gem strict. The Go port of that bound closed it. The lesson
155
+ # worth keeping is that a divergence can live at extraction as easily as
156
+ # at parse, so "what survives" below is the parser's list, not the
157
+ # backends' list.
158
+ #
159
+ # * A LONE LOW surrogate escape (`\udc00`-`\udfff`). `JSON.parse`
160
+ # accepts it; §1.1(a) refuses it, because a surrogate escape must form
161
+ # a pair. Ruby refuses only the HIGH half, which is why the rule here
162
+ # is narrower than "surrogate escapes diverge".
163
+ # * The nesting BOUNDARY, by exactly one level and only for an EMPTY
164
+ # container. §1.1(c) refuses any container deeper than 100. Ruby
165
+ # checks the depth when it is about to parse a VALUE, so a container
166
+ # sitting at depth 101 with nothing in it is never checked and is
167
+ # accepted; put anything inside it and Ruby refuses too. Measured on
168
+ # json 2.21.2 and pinned, both halves, in
169
+ # `spec/specguard/rspec/validator_backend_spec.rb`.
170
+ #
171
+ # The surrogate one is not merely a verdict difference. What `JSON.parse`
172
+ # returns for `"\udc00"` is a String whose `valid_encoding?` is FALSE: it
173
+ # cannot be re-serialised and cannot cross the ingest transport, so a
174
+ # payload this parser calls valid is one the gem cannot send. That is the
175
+ # cost §1.1(a) exists to remove, and it is still paid on this path.
176
+ #
177
+ # It produces two shapes, depending on whether the payload this parser
178
+ # accepts then passes the schema. BOTH ARE REACHABLE, one per survivor:
179
+ #
180
+ # (v) NOT schema-valid — the CLASSIFICATION differs. The NESTING
181
+ # survivor lands here and only here. A container at depth 101 is
182
+ # a container, so it can never occupy a schema-legal slot: every
183
+ # value the schema permits is a string or an array of strings. So
184
+ # a payload deep enough to diverge is a payload the schema refuses,
185
+ # and the two tools refuse it for different stated reasons —
186
+ #
187
+ # binary: "kind": "parse" nesting is deeper than 100 levels
188
+ # (PROTOCOL.md §1.1(c))
189
+ # gem: "kind": "schema" preconditions[0]: expected type
190
+ # string, got array
191
+ #
192
+ # The VERDICT agrees (both fail, both exit 1); only the `kind` and
193
+ # the reason differ.
194
+ # (vi) schema-valid — the VERDICT differs. The SURROGATE survivor lands
195
+ # here: it lives inside a string, and a string is what the schema
196
+ # permits. This path reports nothing and exits 0; the backend
197
+ # reports a parse failure and exits 1.
198
+ #
199
+ # RATIFIED, and the reason is scope rather than preference. This gem's
200
+ # hand-rolled validation logic is slated for REMOVAL by the roadmap that
201
+ # owns the binary, not for repair; and closing the gap here would be a
202
+ # change to the DEFAULT path, which the slice that introduced
203
+ # `ValidatorBackend` explicitly holds fixed. Whoever removes this parser
204
+ # closes it by deletion.
205
+ #
206
+ # Asserted from both sides — what this parser accepts, the convergence,
207
+ # and both surviving divergences — in
208
+ # `spec/specguard/rspec/validator_backend_spec.rb` under "the JSON
209
+ # acceptance set".
210
+ #
211
+ # Note that `JSON::NestingError` is a subclass of `JSON::ParserError`, so
212
+ # a nesting refusal Ruby DOES make is already carried by the rescue below
213
+ # and needs no clause of its own. That is about the refusals the two
214
+ # share; it is not a claim that the classification always agrees, which
215
+ # at depth 101 it does not — see (v).
216
+ #
217
+ # @return [Finding]
218
+ def parse(raw, file:, line:)
219
+ intent = JSON.parse(PayloadNormalizer.normalize(raw))
220
+
221
+ # A bare `[...]` or scalar is syntactically fine JSON but is not an
222
+ # annotation. Reject it here so downstream stages can assume a Hash.
223
+ unless intent.is_a?(Hash)
224
+ return Finding.new(file: file, line: line, kind: Finding::KIND_PARSE,
225
+ problem: "annotation payload is not an object")
226
+ end
227
+
228
+ Finding.new(file: file, line: line, intent: intent)
229
+ rescue ScanError, JSON::ParserError => e
230
+ Finding.new(file: file, line: line, kind: Finding::KIND_PARSE,
231
+ problem: "could not parse annotation: #{e.message}")
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/yatfa-ai/open-test-intent/schema-v1.0/schemas/open-test-intent.v1.json",
4
+ "title": "OpenTestIntent v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "entity": { "type": "string", "minLength": 2 },
9
+ "action": { "type": "string", "minLength": 2 },
10
+ "behavior": { "type": "string", "minLength": 15 },
11
+ "layer": { "type": "string", "enum": ["unit", "integration", "request", "system"] },
12
+ "preconditions": { "type": "array", "items": { "type": "string" } }
13
+ },
14
+ "required": ["entity", "action", "behavior", "layer"]
15
+ }