specguard-ruby 0.3.27 → 0.3.29

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: 8b3732299a2d530c1fc5621ea498170dd62bbfb544b5dbb3732b40db8c42485e
4
- data.tar.gz: 7aeb4da4307f6a2aceebeace51a4a2ee7144bbc7119bf27d0075a1e1d8a6aa40
3
+ metadata.gz: 887fc6d71732b1947cc48c34f27c68ac5b9b71c729f604cf95c41e508cc8d6af
4
+ data.tar.gz: e2f20dbc68a7324cc34381e204bc8ac0f5db262002d86ffc3bd24c8c2cfb3663
5
5
  SHA512:
6
- metadata.gz: de7f31eb5b14f44d922a5826c58b2e85970ec1113863802a78c120b840d22b1569b6ec2326763b0952c7040e7ce98564296721f675ccfdd72fc9fff117ad2e98
7
- data.tar.gz: 98b801cde5eb671bb76830009f56a9b3578cc934e1814fa01a588df5f34f840fa27080abd0ec3403e5bbc5affec2b441c3d962c710c9aeb64072c16f484a17e2
6
+ metadata.gz: e3d18c2b2d19c8d25b0c5a9277847767996ff27be06f7f5802b052c7a4dab7f20e7244214a339fc0a4bc0343103572ad842bd381f95ccd6efa670422da445edc
7
+ data.tar.gz: 9a2b14e04e71e6e957134e29ad38f11fdaad0aede17893907a048d9e6414d93affb7844f4a99e630194f5095c0bc945e271800871f39f71f325ed8b4805492ae
data/README.md CHANGED
@@ -48,8 +48,10 @@ 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;
53
+ directly above an `it` that carries its own trailing `@intent:`, which extraction prefers, so the
54
+ comment is shadowed; or trailing on a `describe`/`context` group line, which no example's extraction can ever reach) — and
53
55
  **never** fails on a *missing* one (adoption is opt-in and gradual).
54
56
 
55
57
  ```bash
@@ -335,8 +337,8 @@ Every way the backend can fail — the binary is missing, will not execute, exit
335
337
  is not a verdict, or emits output that is not a report — is **exit 2**, the linter's "could not do
336
338
  my job" code. It never becomes exit 1, which means "an annotation is malformed" — or well-formed
337
339
  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.
340
+ never claims it, separated from its `it` by one blank or ordinary comment line, shadowed by its
341
+ `it`'s own trailing `@intent:`, or trailing on a `describe`/`context` group line, which no example's extraction can ever reach — and nothing else.
340
342
 
341
343
  ## The formatter — `SpecGuard::RSpecFormatter`
342
344
 
@@ -126,10 +126,12 @@ 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/SPGD-1560: stacked above
130
+ # another comment-form `@intent:` line so the one-line lookback never
131
+ # claims it, separated from its example by one blank/comment line,
132
+ # shadowed by its example's own trailing `@intent:`, or trailing on
133
+ # an example-group line no example's extraction can reach — a dead
134
+ # contract in every case, and still a failure). The only code produced by
133
135
  # inspecting content, and the only path that reaches it is a failed
134
136
  # {Linter::Result}.
135
137
  EXIT_MALFORMED = 1
@@ -167,12 +169,15 @@ module SpecGuard
167
169
 
168
170
  results = backend.check(selection.files)
169
171
 
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
172
+ # The structural pass (SPGD-900, SPGD-1510, SPGD-1554, SPGD-1560): annotations
173
+ # that are valid in isolation but can never be extracted — stacked
174
+ # consecutive comment-form `@intent:` lines, where the one-line
175
+ # lookback (SPGD-12 §2) claims only the line just above the example,
176
+ # comment-form runs separated from their example by one blank/comment
177
+ # line, comment-form lines shadowed by their example's own trailing
178
+ # `@intent:` (extraction is own-line-first), and `@intent:` tokens
179
+ # trailing on an example-group line, which no extraction rule can ever
180
+ # reach. These are Linter::Results like any
176
181
  # other, so both renderers and the exit code pick them up with no
177
182
  # second path to keep in step.
178
183
  results += unreachable_results(selection.files)
@@ -480,15 +485,15 @@ module SpecGuard
480
485
  #
481
486
  # The boundary is the set-difference the partition above already
482
487
  # supports: a file counts as covered when it yielded any line-scoped
483
- # result (an annotation, valid or not — the SPGD-900/SPGD-1510
488
+ # result (an annotation, valid or not — the SPGD-900/SPGD-1510/SPGD-1554/SPGD-1560
484
489
  # unreachable findings are line-scoped too and require an annotation to
485
490
  # exist, so they cover their file) or when it could not be read at all
486
491
  # (KIND_READ → `unread`). An unread file is NOT a zero-annotation file:
487
492
  # it already gets its own summary clause and FAIL line, and calling it
488
493
  # annotation-free would be the same overstatement `summary_line` refuses
489
494
  # 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
495
+ # comment-form pair, a separated comment-form run, a shadowed comment-form
496
+ # run, or an `@intent:` token on a group line — impossible in a file with none, so `annotations ∪ unread` already covers every
492
497
  # non-bare file and no third subtraction is needed.
493
498
  #
494
499
  # 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
+ # Four 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,15 @@ 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;
65
+ # * a comment-form `@intent:` directly above an example whose OWN line
66
+ # also carries a trailing `@intent:` (SPGD-1560): extraction is
67
+ # own-line-first, so the example's own annotation shadows the comment.
62
68
  #
63
- # Either way the contract is dead metadata: counted by the linter,
69
+ # Whichever shape, the contract is dead metadata: counted by the linter,
64
70
  # discarded by extraction. Produced by {Scanner}'s structural pass, not
65
71
  # by {AnnotationScanner} — each annotation is scanned in isolation
66
72
  # there, which is exactly why these defects are invisible to it.
@@ -40,13 +40,17 @@ 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. Four 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), and a comment-form `@intent:` directly above an
49
+ # example whose own line carries a trailing `@intent:`, which own-line-first
50
+ # extraction prefers (SPGD-1560). Nothing about any of them is malformed; the
51
+ # contract is dead metadata, counted by the linter and discarded by
52
+ # extraction, and the structural pass that flags them (SPGD-900,
53
+ # SPGD-1510, SPGD-1554, SPGD-1560) reports it loudly rather than letting it pass as
50
54
  # clean.
51
55
  module Linter
52
56
  # 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. Four 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,22 @@ 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
+ # * the SHADOWED annotation (SPGD-1560, ported from specguard-ts
287
+ # SPGD-1556): a comment-form `@intent:` run directly above an example
288
+ # whose OWN line also carries a trailing `@intent: {`. Extraction is
289
+ # own-line-first ({AnnotationLookup::Index#intent_for} returns the
290
+ # example's own annotation before it ever consults the line above),
291
+ # so the comment is shadowed and its LAST line is dead. (Any earlier
292
+ # lines of the run are the stacked arm's.)
293
+ #
294
+ # All four are reported loudly, not skipped — the same stance
281
295
  # {AnnotationScanner} takes for NO_PAYLOAD.
282
296
  #
283
297
  # A comment-form annotation line is a comment-only line
@@ -323,6 +337,19 @@ module SpecGuard
323
337
  "claim it. Move it onto its example's `it` line, or to the comment line directly " \
324
338
  "above that `it`"
325
339
 
340
+ UNREACHABLE_SEPARATED_ANNOTATION =
341
+ "unreachable annotation: this @intent: is separated from its example by an " \
342
+ "intervening comment or blank line, and the one-line lookback (SPGD-12 §2) claims " \
343
+ "only the line directly above the example — so this annotation is silently " \
344
+ "discarded at extraction. Move it directly above the example (or merge it into " \
345
+ "the line that is)"
346
+
347
+ UNREACHABLE_SHADOWED_ANNOTATION =
348
+ "unreachable annotation: the example directly below carries its own trailing @intent:, " \
349
+ "and extraction is own-line-first (SPGD-12 §2), so this comment-form annotation is " \
350
+ "shadowed and silently discarded. Delete this comment, or merge it into the trailing " \
351
+ "payload"
352
+
326
353
  # A comment-only line carrying an `@intent:` token — the only form an
327
354
  # example on the NEXT line may claim ({AnnotationLookup::COMMENT_LINE}).
328
355
  COMMENT_INTENT_LINE = /\A\s*#.*@intent:/
@@ -337,18 +364,36 @@ module SpecGuard
337
364
  # spec/fixtures/): there is no lookback there to silently discard
338
365
  # anything, so there is nothing to report. The defects this pass exists
339
366
  # 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).
367
+ # example: stacked lines above it (SPGD-897), the trailing form on
368
+ # a group line directly above it (SPGD-1510), and a comment-form run
369
+ # separated from it by one blank/comment line (SPGD-1554).
342
370
  EXAMPLE_LINE = /\A\s*(?:it|specify)\b/
343
371
 
372
+ # A blank (whitespace-only) line — one of the two interleaves the
373
+ # separated pass reads. `\n` is whitespace, so a `text.lines` element
374
+ # that is only its newline matches.
375
+ BLANK_LINE = /\A\s*\z/
376
+
377
+ # A comment-only line (`#`-leading). The separated pass reads it as the
378
+ # interleave only when it carries no `@intent:` token (a comment-form
379
+ # `@intent:` line is part of the run itself, never an interleave).
380
+ COMMENT_ONLY_LINE = /\A\s*#/
381
+
344
382
  # @param text [String] source of one file
345
383
  # @param file [String] path to record on each Finding
346
384
  # @return [Array<Finding>] the structural findings for one file, in line
347
385
  # 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
386
+ # {stacked_findings_in_text}), one per line of a separated run (see
387
+ # {separated_findings_in_text}), one per run whose example carries its own
388
+ # trailing annotation (see {own_line_shadow_findings_in_text}) plus one
389
+ # per group line carrying the trailing form (see
390
+ # {group_line_findings_in_text}). The four passes are disjoint by
391
+ # construction: the stacked pass flags all but the LAST line of a run
392
+ # when an example directly follows, the shadow pass flags ONLY that last
393
+ # line (and only when the example also carries `@intent: {`), the
394
+ # separated pass needs a blank or comment-only line after the run (one
395
+ # slot cannot be both that and an example), and the group pass reads
396
+ # only lines that are not comment-only — so the merge cannot double-flag
352
397
  # a line, and the sort restores the file-then-line order
353
398
  # {unreachable_findings} promises.
354
399
  def unreachable_findings_in_text(text, file:)
@@ -358,6 +403,8 @@ module SpecGuard
358
403
  return [] unless text.valid_encoding?
359
404
 
360
405
  (stacked_findings_in_text(text, file: file) +
406
+ separated_findings_in_text(text, file: file) +
407
+ own_line_shadow_findings_in_text(text, file: file) +
361
408
  group_line_findings_in_text(text, file: file)).sort_by(&:line)
362
409
  end
363
410
 
@@ -399,6 +446,90 @@ module SpecGuard
399
446
  findings
400
447
  end
401
448
 
449
+ # @param text [String] source of one file
450
+ # @param file [String] path to record on each Finding
451
+ # @return [Array<Finding>] one per comment-form `@intent:` line of a
452
+ # SEPARATED run — a maximal run of consecutive comment-form `@intent:`
453
+ # lines whose following line (the interleave) is blank or a comment
454
+ # without an `@intent:` token AND whose line after that is an example
455
+ # ({EXAMPLE_LINE}). The one-line lookback reads only the interleave,
456
+ # so NOTHING in the run is claimed: every line is flagged, the run's
457
+ # last line included — the discriminating difference from
458
+ # {stacked_findings_in_text}, which spares the last line. The window
459
+ # is exactly one line: a second interleave, a code interleave, a
460
+ # `describe` target or a run at end of file is not flagged.
461
+ def separated_findings_in_text(text, file:)
462
+ # Same UTF-8 contract as the stacked pass above.
463
+ return [] unless text.valid_encoding?
464
+
465
+ lines = text.lines
466
+ i = 0
467
+ findings = []
468
+
469
+ while i < lines.length
470
+ unless COMMENT_INTENT_LINE.match?(lines[i])
471
+ i += 1
472
+ next
473
+ end
474
+
475
+ run_start = i
476
+ i += 1 while i < lines.length && COMMENT_INTENT_LINE.match?(lines[i])
477
+ run_end = i # exclusive; lines[run_end] is the interleave candidate
478
+
479
+ between = lines[run_end]
480
+ example = lines[run_end + 1]
481
+ next if between.nil? || example.nil?
482
+ next unless BLANK_LINE.match?(between) ||
483
+ (COMMENT_ONLY_LINE.match?(between) && !COMMENT_INTENT_LINE.match?(between))
484
+ next unless EXAMPLE_LINE.match?(example)
485
+
486
+ (run_start...run_end).each do |j|
487
+ findings << Finding.new(file: file, line: j + 1, problem: UNREACHABLE_SEPARATED_ANNOTATION,
488
+ kind: Finding::KIND_UNREACHABLE)
489
+ end
490
+ end
491
+
492
+ findings
493
+ end
494
+
495
+ # @param text [String] source of one file
496
+ # @param file [String] path to record on each Finding
497
+ # @return [Array<Finding>] one per maximal run of comment-form
498
+ # `@intent:` lines whose following line is an example
499
+ # ({EXAMPLE_LINE}) that ALSO carries a trailing `@intent: {`
500
+ # ({INTENT_WITH_PAYLOAD}) — reported on the run's LAST line, the one
501
+ # the lookback would otherwise claim. Extraction is own-line-first
502
+ # (SPGD-12 §2), so the example's own annotation always wins and that
503
+ # comment is dead. Earlier lines of a longer run are the stacked arm's
504
+ # ({stacked_findings_in_text}); each dead line is flagged exactly once.
505
+ def own_line_shadow_findings_in_text(text, file:)
506
+ # Same UTF-8 contract as the stacked pass above.
507
+ return [] unless text.valid_encoding?
508
+
509
+ lines = text.lines
510
+ i = 0
511
+ findings = []
512
+
513
+ while i < lines.length
514
+ unless COMMENT_INTENT_LINE.match?(lines[i])
515
+ i += 1
516
+ next
517
+ end
518
+
519
+ i += 1 while i < lines.length && COMMENT_INTENT_LINE.match?(lines[i])
520
+ run_end = i # exclusive; lines[run_end] is the line after the run
521
+
522
+ example = lines[run_end]
523
+ next if example.nil?
524
+ next unless EXAMPLE_LINE.match?(example) && INTENT_WITH_PAYLOAD.match?(example)
525
+
526
+ findings << Finding.new(file: file, line: run_end, problem: UNREACHABLE_SHADOWED_ANNOTATION,
527
+ kind: Finding::KIND_UNREACHABLE)
528
+ end
529
+
530
+ findings
531
+ end
532
+
402
533
  # RSpec's example-group keywords, at the start of a line and optionally
403
534
  # `RSpec.`-prefixed. A line matching this hosts a GROUP, not an example
404
535
  # — and {AnnotationLookup}'s extraction can claim an annotation only
@@ -450,9 +581,9 @@ module SpecGuard
450
581
  # @return [Array<Finding>] one per example-group line carrying an
451
582
  # `@intent:` token, for lines that define no example of their own
452
583
  # ({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).
584
+ # {stacked_findings_in_text} and {separated_findings_in_text}: a
585
+ # comment above a group is a different shape, deliberately out of
586
+ # scope here (SPGD-1510 flags the group line ITSELF).
456
587
  def group_line_findings_in_text(text, file:)
457
588
  # Same UTF-8 contract as the stacked pass above.
458
589
  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.27"
10
+ VERSION = "0.3.29"
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.27
4
+ version: 0.3.29
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-27 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json