specguard-ruby 0.3.26 → 0.3.28

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 788caaa70678a741f5ddb2aa3379d033369905ee89a7164f37527f0c64ecf3a5
4
- data.tar.gz: 8c03300935002043fd6097d3779057ed0dec439b13108bdc440ced0fee71af95
3
+ metadata.gz: 58f8188803b68968be3289f01834d0f128f239b10d31162d34eeaaf55b6daefe
4
+ data.tar.gz: 273ce5b5eb675b12b302e23c3b24e025d81e60e77b77476ba9c7401fd7742600
5
5
  SHA512:
6
- metadata.gz: 138c404bb1691827932705550d146c5d34c908ef5c3581ef6f1aa6d210de46f8ba0ef349f81486bab4340ead74588858031012732f26a46e7df691ce4fce78ce
7
- data.tar.gz: 26e5764b84b90f120caea35853e9205b1e1142bf3f3709b761f164acc7f294b2cda5aa6dcc6f89354e03f7bc750a6d3268050f1ef25961db606b6b1584e4bff8
6
+ metadata.gz: fcc5a85ecf595eeb62aae5446387ff74802579f97991c71b9b7a61014499c954d6fcc546171440dd3fde330b06ad11cc6fb2717b5f8f569aee884d51534c1f61
7
+ data.tar.gz: 52ed4889c3074cb474b9a192603414ba11644c753ad6dc2a3d946579663bf0432bcd533702f36568d9c81259a4eaf3e7735102a5374379fbabe0a6b4f24b76e5
data/README.md CHANGED
@@ -48,8 +48,9 @@ bundle exec ruby -rminitest/specguard_plugin -e 'Minitest.extensions << "specgua
48
48
 
49
49
  Validates `# @intent:` annotations in changed (or all) `*_spec.rb` files against the OpenTestIntent
50
50
  JSON Schema. Exits `1` on a malformed annotation — or a well-formed but unreachable one (stacked
51
- above another comment-form `@intent:` line, so the one-line lookback never claims it, or trailing
52
- on a `describe`/`context` group line, which no example's extraction can ever reach) — and
51
+ above another comment-form `@intent:` line, so the one-line lookback never claims it; separated
52
+ from its `it` by one blank or ordinary comment line, which puts it out of the lookback's reach; or
53
+ trailing on a `describe`/`context` group line, which no example's extraction can ever reach) — and
53
54
  **never** fails on a *missing* one (adoption is opt-in and gradual).
54
55
 
55
56
  ```bash
@@ -335,8 +336,8 @@ Every way the backend can fail — the binary is missing, will not execute, exit
335
336
  is not a verdict, or emits output that is not a report — is **exit 2**, the linter's "could not do
336
337
  my job" code. It never becomes exit 1, which means "an annotation is malformed" — or well-formed
337
338
  but unreachable: stacked above another comment-form `@intent:` line, so the one-line lookback
338
- never claims it, or trailing on a `describe`/`context` group line, which no example's extraction
339
- can ever reach — and nothing else.
339
+ never claims it, separated from its `it` by one blank or ordinary comment line, or trailing on a
340
+ `describe`/`context` group line, which no example's extraction can ever reach — and nothing else.
340
341
 
341
342
  ## The formatter — `SpecGuard::RSpecFormatter`
342
343
 
@@ -126,10 +126,11 @@ module SpecGuard
126
126
  # "Lint, don't require": a missing annotation is never an error.
127
127
  EXIT_OK = 0
128
128
  # One or more annotations are malformed — or well-formed but
129
- # unreachable (SPGD-900/SPGD-1510: stacked above another comment-form
130
- # `@intent:` line so the one-line lookback never claims it, or trailing
131
- # on an example-group line no example's extraction can reach — a dead
132
- # contract either way, and still a failure). The only code produced by
129
+ # unreachable (SPGD-900/SPGD-1510/SPGD-1554: stacked above another
130
+ # comment-form `@intent:` line so the one-line lookback never claims it,
131
+ # separated from its example by one blank/comment line, or trailing on
132
+ # an example-group line no example's extraction can reach — a dead
133
+ # contract in every case, and still a failure). The only code produced by
133
134
  # inspecting content, and the only path that reaches it is a failed
134
135
  # {Linter::Result}.
135
136
  EXIT_MALFORMED = 1
@@ -167,12 +168,13 @@ module SpecGuard
167
168
 
168
169
  results = backend.check(selection.files)
169
170
 
170
- # The structural pass (SPGD-900, SPGD-1510): annotations that are
171
- # valid in isolation but can never be extracted — stacked consecutive
172
- # comment-form `@intent:` lines, where the one-line lookback
173
- # (SPGD-12 §2) claims only the line just above the example, and
174
- # `@intent:` tokens trailing on an example-group line, which no
175
- # extraction rule can ever reach. These are Linter::Results like any
171
+ # The structural pass (SPGD-900, SPGD-1510, SPGD-1554): annotations
172
+ # that are valid in isolation but can never be extracted — stacked
173
+ # consecutive comment-form `@intent:` lines, where the one-line
174
+ # lookback (SPGD-12 §2) claims only the line just above the example,
175
+ # comment-form runs separated from their example by one blank/comment
176
+ # line, and `@intent:` tokens trailing on an example-group line, which
177
+ # no extraction rule can ever reach. These are Linter::Results like any
176
178
  # other, so both renderers and the exit code pick them up with no
177
179
  # second path to keep in step.
178
180
  results += unreachable_results(selection.files)
@@ -480,15 +482,15 @@ module SpecGuard
480
482
  #
481
483
  # The boundary is the set-difference the partition above already
482
484
  # supports: a file counts as covered when it yielded any line-scoped
483
- # result (an annotation, valid or not — the SPGD-900/SPGD-1510
485
+ # result (an annotation, valid or not — the SPGD-900/SPGD-1510/SPGD-1554
484
486
  # unreachable findings are line-scoped too and require an annotation to
485
487
  # exist, so they cover their file) or when it could not be read at all
486
488
  # (KIND_READ → `unread`). An unread file is NOT a zero-annotation file:
487
489
  # it already gets its own summary clause and FAIL line, and calling it
488
490
  # annotation-free would be the same overstatement `summary_line` refuses
489
491
  # to make. Unreachable findings need an annotation to exist — a stacked
490
- # comment-form pair, or an `@intent:` token on a group line — impossible
491
- # in a file with none, so `annotations ∪ unread` already covers every
492
+ # comment-form pair, a separated comment-form run, or an `@intent:`
493
+ # token on a group line — impossible in a file with none, so `annotations ∪ unread` already covers every
492
494
  # non-bare file and no third subtraction is needed.
493
495
  #
494
496
  # It is a NOTE on stderr, never a warning and never an exit code: "lint,
@@ -49,7 +49,7 @@ module SpecGuard
49
49
  KIND_SCHEMA = :schema
50
50
 
51
51
  # The annotation is well-formed in isolation but can never be EXTRACTED.
52
- # Two shapes, both dead the moment they are written:
52
+ # Three shapes, all dead the moment they are written:
53
53
  #
54
54
  # * a comment-form `@intent:` stacked above another comment-form
55
55
  # `@intent:` line, so the one-line lookback (SPGD-12 §2 —
@@ -58,9 +58,12 @@ module SpecGuard
58
58
  # * an `@intent:` trailing on an example-group line
59
59
  # ({Scanner::GROUP_LINE} — SPGD-1510): a group line is no example's
60
60
  # own and is not comment-only, so no extraction rule can ever reach
61
- # it, and the example beneath it silently ingests unannotated.
61
+ # it, and the example beneath it silently ingests unannotated;
62
+ # * a comment-form `@intent:` run separated from its example by ONE
63
+ # blank or ordinary comment line (SPGD-1554): the one-line lookback
64
+ # reads only that interleave, so the whole run is out of reach.
62
65
  #
63
- # Either way the contract is dead metadata: counted by the linter,
66
+ # Whichever shape, the contract is dead metadata: counted by the linter,
64
67
  # discarded by extraction. Produced by {Scanner}'s structural pass, not
65
68
  # by {AnnotationScanner} — each annotation is scanned in isolation
66
69
  # there, which is exactly why these defects are invisible to it.
@@ -40,13 +40,15 @@ module SpecGuard
40
40
  # `Finding::KIND_UNREACHABLE` — an annotation that is well-formed in
41
41
  # isolation but can never be extracted, so the one-line lookback
42
42
  # (SPGD-12 §2) never claims it — is also reported as a failure, and
43
- # therefore also exits 1. Two shapes reach this kind: a comment-form
43
+ # therefore also exits 1. Three shapes reach this kind: a comment-form
44
44
  # `@intent:` stacked above another comment-form `@intent:` line (SPGD-900),
45
- # and an `@intent:` trailing on an example-group line, which is no
46
- # example's own and not comment-only (SPGD-1510). Nothing about either is
47
- # malformed; the contract is dead metadata, counted by the linter and
48
- # discarded by extraction, and the structural pass that flags them
49
- # (SPGD-900, SPGD-1510) reports it loudly rather than letting it pass as
45
+ # an `@intent:` trailing on an example-group line, which is no
46
+ # example's own and not comment-only (SPGD-1510), and a comment-form
47
+ # `@intent:` run separated from its example by one blank or ordinary
48
+ # comment line (SPGD-1554). Nothing about any of them is malformed; the
49
+ # contract is dead metadata, counted by the linter and discarded by
50
+ # extraction, and the structural pass that flags them (SPGD-900,
51
+ # SPGD-1510, SPGD-1554) reports it loudly rather than letting it pass as
50
52
  # clean.
51
53
  module Linter
52
54
  # One annotation's verdict. `problem` is set when discovery could not
@@ -261,7 +261,7 @@ module SpecGuard
261
261
  # ({AnnotationLookup}, SPGD-12 §2) is positional: an annotation reaches
262
262
  # an example only through its own line (the trailing form, and only when
263
263
  # that line IS the example's) or through the comment-form line
264
- # IMMEDIATELY ABOVE it. Two shapes fall outside both and are dead the
264
+ # IMMEDIATELY ABOVE it. Three shapes fall outside both and are dead the
265
265
  # moment they are written — silently discarded by extraction while this
266
266
  # pipeline counts them as valid annotations and the run exits 0:
267
267
  #
@@ -276,8 +276,14 @@ module SpecGuard
276
276
  # ever claim it — the example beneath it silently ingests as
277
277
  # unannotated. A describe insertion that swallowed a newline is how
278
278
  # the shape is born.
279
- #
280
- # Both are reported loudly, not skipped — the same stance
279
+ # * the SEPARATED annotation (SPGD-1554, ported from specguard-ts
280
+ # SPGD-1550): a comment-form `@intent:` run followed by exactly ONE
281
+ # blank line or ordinary (non-`@intent:`) comment line and THEN an
282
+ # example. The one-line lookback reads only the line directly above
283
+ # the example, which is the interleave — so the annotation is out of
284
+ # reach and every line of the run is dead.
285
+ #
286
+ # All three are reported loudly, not skipped — the same stance
281
287
  # {AnnotationScanner} takes for NO_PAYLOAD.
282
288
  #
283
289
  # A comment-form annotation line is a comment-only line
@@ -323,6 +329,13 @@ module SpecGuard
323
329
  "claim it. Move it onto its example's `it` line, or to the comment line directly " \
324
330
  "above that `it`"
325
331
 
332
+ UNREACHABLE_SEPARATED_ANNOTATION =
333
+ "unreachable annotation: this @intent: is separated from its example by an " \
334
+ "intervening comment or blank line, and the one-line lookback (SPGD-12 §2) claims " \
335
+ "only the line directly above the example — so this annotation is silently " \
336
+ "discarded at extraction. Move it directly above the example (or merge it into " \
337
+ "the line that is)"
338
+
326
339
  # A comment-only line carrying an `@intent:` token — the only form an
327
340
  # example on the NEXT line may claim ({AnnotationLookup::COMMENT_LINE}).
328
341
  COMMENT_INTENT_LINE = /\A\s*#.*@intent:/
@@ -337,20 +350,34 @@ module SpecGuard
337
350
  # spec/fixtures/): there is no lookback there to silently discard
338
351
  # anything, so there is nothing to report. The defects this pass exists
339
352
  # for are annotations an author believed were attached to a real
340
- # example: stacked lines above it (SPGD-897), and the trailing form on
341
- # a group line directly above it (SPGD-1510).
353
+ # example: stacked lines above it (SPGD-897), the trailing form on
354
+ # a group line directly above it (SPGD-1510), and a comment-form run
355
+ # separated from it by one blank/comment line (SPGD-1554).
342
356
  EXAMPLE_LINE = /\A\s*(?:it|specify)\b/
343
357
 
358
+ # A blank (whitespace-only) line — one of the two interleaves the
359
+ # separated pass reads. `\n` is whitespace, so a `text.lines` element
360
+ # that is only its newline matches.
361
+ BLANK_LINE = /\A\s*\z/
362
+
363
+ # A comment-only line (`#`-leading). The separated pass reads it as the
364
+ # interleave only when it carries no `@intent:` token (a comment-form
365
+ # `@intent:` line is part of the run itself, never an interleave).
366
+ COMMENT_ONLY_LINE = /\A\s*#/
367
+
344
368
  # @param text [String] source of one file
345
369
  # @param file [String] path to record on each Finding
346
370
  # @return [Array<Finding>] the structural findings for one file, in line
347
371
  # order: one per comment-form `@intent:` line in a stacked run (see
348
- # {stacked_findings_in_text}) plus one per group line carrying the
349
- # trailing form (see {group_line_findings_in_text}). The two passes
350
- # are disjoint by construction — one reads only comment-only lines,
351
- # the other only lines that are not — so the merge cannot double-flag
352
- # a line, and the sort restores the file-then-line order
353
- # {unreachable_findings} promises.
372
+ # {stacked_findings_in_text}), one per line of a separated run (see
373
+ # {separated_findings_in_text}) plus one per group line carrying the
374
+ # trailing form (see {group_line_findings_in_text}). The three passes
375
+ # are disjoint by construction: the stacked pass needs an example
376
+ # line directly after the run, the separated pass needs a blank or
377
+ # comment-only line there (one slot cannot be both), and the group
378
+ # pass reads only lines that are not comment-only — so the merge
379
+ # cannot double-flag a line, and the sort restores the file-then-line
380
+ # order {unreachable_findings} promises.
354
381
  def unreachable_findings_in_text(text, file:)
355
382
  # A file that is not valid UTF-8 is reported once, loudly, by the
356
383
  # backend as a read failure; nothing positional can be said about it,
@@ -358,6 +385,7 @@ module SpecGuard
358
385
  return [] unless text.valid_encoding?
359
386
 
360
387
  (stacked_findings_in_text(text, file: file) +
388
+ separated_findings_in_text(text, file: file) +
361
389
  group_line_findings_in_text(text, file: file)).sort_by(&:line)
362
390
  end
363
391
 
@@ -399,6 +427,52 @@ module SpecGuard
399
427
  findings
400
428
  end
401
429
 
430
+ # @param text [String] source of one file
431
+ # @param file [String] path to record on each Finding
432
+ # @return [Array<Finding>] one per comment-form `@intent:` line of a
433
+ # SEPARATED run — a maximal run of consecutive comment-form `@intent:`
434
+ # lines whose following line (the interleave) is blank or a comment
435
+ # without an `@intent:` token AND whose line after that is an example
436
+ # ({EXAMPLE_LINE}). The one-line lookback reads only the interleave,
437
+ # so NOTHING in the run is claimed: every line is flagged, the run's
438
+ # last line included — the discriminating difference from
439
+ # {stacked_findings_in_text}, which spares the last line. The window
440
+ # is exactly one line: a second interleave, a code interleave, a
441
+ # `describe` target or a run at end of file is not flagged.
442
+ def separated_findings_in_text(text, file:)
443
+ # Same UTF-8 contract as the stacked pass above.
444
+ return [] unless text.valid_encoding?
445
+
446
+ lines = text.lines
447
+ i = 0
448
+ findings = []
449
+
450
+ while i < lines.length
451
+ unless COMMENT_INTENT_LINE.match?(lines[i])
452
+ i += 1
453
+ next
454
+ end
455
+
456
+ run_start = i
457
+ i += 1 while i < lines.length && COMMENT_INTENT_LINE.match?(lines[i])
458
+ run_end = i # exclusive; lines[run_end] is the interleave candidate
459
+
460
+ between = lines[run_end]
461
+ example = lines[run_end + 1]
462
+ next if between.nil? || example.nil?
463
+ next unless BLANK_LINE.match?(between) ||
464
+ (COMMENT_ONLY_LINE.match?(between) && !COMMENT_INTENT_LINE.match?(between))
465
+ next unless EXAMPLE_LINE.match?(example)
466
+
467
+ (run_start...run_end).each do |j|
468
+ findings << Finding.new(file: file, line: j + 1, problem: UNREACHABLE_SEPARATED_ANNOTATION,
469
+ kind: Finding::KIND_UNREACHABLE)
470
+ end
471
+ end
472
+
473
+ findings
474
+ end
475
+
402
476
  # RSpec's example-group keywords, at the start of a line and optionally
403
477
  # `RSpec.`-prefixed. A line matching this hosts a GROUP, not an example
404
478
  # — and {AnnotationLookup}'s extraction can claim an annotation only
@@ -450,9 +524,9 @@ module SpecGuard
450
524
  # @return [Array<Finding>] one per example-group line carrying an
451
525
  # `@intent:` token, for lines that define no example of their own
452
526
  # ({EXAMPLE_ON_LINE}). Comment-only lines are left to
453
- # {stacked_findings_in_text}: a comment above a group is a different
454
- # shape, deliberately out of scope here (SPGD-1510 flags the group
455
- # line ITSELF).
527
+ # {stacked_findings_in_text} and {separated_findings_in_text}: a
528
+ # comment above a group is a different shape, deliberately out of
529
+ # scope here (SPGD-1510 flags the group line ITSELF).
456
530
  def group_line_findings_in_text(text, file:)
457
531
  # Same UTF-8 contract as the stacked pass above.
458
532
  return [] unless text.valid_encoding?
@@ -7,5 +7,5 @@
7
7
  # framework-scoped version constant a lie, so the number moved here and the
8
8
  # rspec module now points at it for back-compat.
9
9
  module SpecGuard
10
- VERSION = "0.3.26"
10
+ VERSION = "0.3.28"
11
11
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: specguard-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.26
4
+ version: 0.3.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - specguard Agent
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-26 00:00:00.000000000 Z
11
+ date: 2026-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json