forge-orkes 0.55.0 → 0.58.2

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,597 @@
1
+ #!/usr/bin/env sh
2
+ # Fixture-repo test suite for .forge/checks/forge-check-lint.sh (m-31, plan 54-01).
3
+ #
4
+ # Contract under test (plan-01 "Locked interface" — do not re-derive):
5
+ # forge-check-lint.sh <base>...<head>
6
+ # stdout: lint=clean
7
+ # lint=fail + finding=<rule>:<file>[:<line>] lines
8
+ # lint=justified justify="<trailer line>"
9
+ # + "finding=<rule>:<file>[:<line>] justified" lines
10
+ # rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker
11
+ # exit: 0 = clean or justified; 1 = fail; 2 = bad invocation.
12
+ # Range MUST be three-dot BASE...HEAD; scanned against merge-base(BASE, HEAD).
13
+ #
14
+ # Targets: CI definition files (.github/workflows/*.yml|yaml, .gitlab-ci.yml) —
15
+ # check-definition UNITS (a run: scalar / run: | block / script: list) whose
16
+ # lines intersect the diff's ADDED lines; test files (*_test.*, *.test.*,
17
+ # test_*.*, *.spec.*) — added/removed lines. Nothing else (honest limit).
18
+ # Rules: (a) cannot-fail — every command in the unit is true/:/exit 0/echo-only
19
+ # → NEVER justifiable (M23 rule a verbatim);
20
+ # (c) existence-only — every command is a bare file-existence test;
21
+ # (b) silent-fail — every command is silent conjuncts (grep -q/diff -q/
22
+ # test/[) with no || diagnostic branch (checked after c);
23
+ # deleted-assertion — removed assertion line in a gating test (base-side
24
+ # hold-config gating_tests: globs when present, else EVERY test file);
25
+ # feature-removal — a deleted-assertion whose test correlates with a whole
26
+ # same-stem source FILE deletion in the same diff → reclassified from
27
+ # test-gaming to "feature removal — confirm intended". CLASSIFIER not
28
+ # allowlist: still fires, still needs the operator act, only the label
29
+ # changes (partial source shrink keeps the stricter deleted-assertion);
30
+ # skip-marker — added .only(/.skip(/it.skip/describe.only/xit(/xdescribe(/
31
+ # @pytest.mark.skip/t.Skip() line.
32
+ # Justification: a "Lint-Justify: <one line>" trailer in ANY commit message of
33
+ # merge-base..head (pure git). Lifts b/c/deleted-assertion/feature-removal/
34
+ # skip-marker → exit 0 lint=justified. NEVER lifts (a).
35
+ #
36
+ # Offline by construction: remote-less mktemp fixture repos (NFR-032).
37
+ #
38
+ # Usage:
39
+ # ./.forge/checks/tests/forge-check-lint.test.sh # all cases
40
+ # ./.forge/checks/tests/forge-check-lint.test.sh cannot-fail # one tag
41
+ # Filter tags: cannot-fail silent-fail existence-only deleted-assertion feature-removal skip-marker justify docs-only invocation
42
+ #
43
+ # RED-phase friendly: missing helper → every selected case FAILS, suite exits
44
+ # non-zero, no abort. Exits 0 iff every selected case passes. Self-cleans.
45
+ set -u
46
+
47
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
48
+ HELPER="$SCRIPT_DIR/../forge-check-lint.sh"
49
+
50
+ [ -x "$HELPER" ] || printf 'NOTE: %s missing or not executable — RED phase, every selected case below should FAIL\n' "$HELPER" >&2
51
+
52
+ ROOT="$(mktemp -d)"
53
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
54
+
55
+ GIT_CONFIG_GLOBAL=/dev/null
56
+ GIT_CONFIG_SYSTEM=/dev/null
57
+ export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
58
+
59
+ PASSED=0
60
+ FAILED=0
61
+ SELECTED=0
62
+ FILTER="${1:-}"
63
+
64
+ pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
65
+ fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
66
+ check() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi }
67
+ check_contains() { case "$2" in *"$3"*) pass "$1" ;; *) fail "$1 (got '$2', must contain '$3')" ;; esac }
68
+ check_absent() { case "$2" in *"$3"*) fail "$1 (got '$2', must NOT contain '$3')" ;; *) pass "$1" ;; esac }
69
+
70
+ running() {
71
+ if [ -z "$FILTER" ]; then SELECTED=$((SELECTED + 1)); return 0; fi
72
+ for _t in "$@"; do
73
+ if [ "$_t" = "$FILTER" ]; then SELECTED=$((SELECTED + 1)); return 0; fi
74
+ done
75
+ return 1
76
+ }
77
+
78
+ run_lint() { # cwd, helper-args...
79
+ rl_cwd="$1"; shift
80
+ if [ -x "$HELPER" ]; then
81
+ if OUT="$(cd "$rl_cwd" && "$HELPER" "$@" 2>/dev/null)"; then RC=0; else RC=$?; fi
82
+ else
83
+ OUT="(helper missing or not executable: $HELPER)"
84
+ RC=127
85
+ fi
86
+ }
87
+
88
+ new_repo() {
89
+ d="$ROOT/repo.$1"
90
+ mkdir -p "$d"
91
+ git -C "$d" init -q
92
+ git -C "$d" symbolic-ref HEAD refs/heads/main
93
+ git -C "$d" config user.email t@example.com
94
+ git -C "$d" config user.name tester
95
+ git -C "$d" commit --allow-empty -qm init
96
+ printf '%s\n' "$d"
97
+ }
98
+
99
+ put() { # repo, path, content [, extra -m message]
100
+ p_dir=$(dirname "$1/$2")
101
+ mkdir -p "$p_dir"
102
+ printf '%s\n' "$3" > "$1/$2"
103
+ git -C "$1" add "$2"
104
+ if [ $# -ge 4 ]; then
105
+ git -C "$1" commit -qm "edit $2" -m "$4"
106
+ else
107
+ git -C "$1" commit -qm "edit $2"
108
+ fi
109
+ }
110
+
111
+ pr() { git -C "$1" checkout -qb pr main; }
112
+
113
+ WF=.github/workflows/ci.yml
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # cannot-fail — acceptance 5: seeded exit-0 fails; trailer does NOT lift; genuine check passes
117
+ # ---------------------------------------------------------------------------
118
+ if running cannot-fail; then
119
+ R=$(new_repo cf)
120
+ put "$R" "$WF" 'name: ci
121
+ jobs:
122
+ gate:
123
+ steps:
124
+ - run: npm test'
125
+ pr "$R"
126
+ put "$R" "$WF" 'name: ci
127
+ jobs:
128
+ gate:
129
+ steps:
130
+ - run: npm test
131
+ - run: exit 0'
132
+ run_lint "$R" 'main...pr'
133
+ check "cannot-fail: seeded exit 0 step fails (exit 1)" "$RC" "1"
134
+ check_contains "cannot-fail: verdict" "$OUT" "lint=fail"
135
+ check_contains "cannot-fail: finding names rule+file" "$OUT" "finding=cannot-fail:$WF"
136
+
137
+ # Trailer must NOT lift rule (a)
138
+ R=$(new_repo cf-trailer)
139
+ put "$R" "$WF" 'jobs: {}'
140
+ pr "$R"
141
+ put "$R" "$WF" 'jobs:
142
+ gate:
143
+ steps:
144
+ - run: true' 'Lint-Justify: placeholder gate while migrating'
145
+ run_lint "$R" 'main...pr'
146
+ check "cannot-fail: Lint-Justify does NOT lift rule a (exit 1)" "$RC" "1"
147
+ check_absent "cannot-fail: no justified verdict for rule a" "$OUT" "lint=justified"
148
+
149
+ # echo-only block unit fires; mixed echo+real block is clean
150
+ R=$(new_repo cf-block)
151
+ put "$R" "$WF" 'jobs: {}'
152
+ pr "$R"
153
+ put "$R" "$WF" 'jobs:
154
+ gate:
155
+ steps:
156
+ - run: |
157
+ echo "checking"
158
+ echo "done"'
159
+ run_lint "$R" 'main...pr'
160
+ check "cannot-fail: echo-only run block fires (exit 1)" "$RC" "1"
161
+ check_contains "cannot-fail: block finding" "$OUT" "finding=cannot-fail:$WF"
162
+
163
+ R=$(new_repo cf-mixed)
164
+ put "$R" "$WF" 'jobs: {}'
165
+ pr "$R"
166
+ put "$R" "$WF" 'jobs:
167
+ gate:
168
+ steps:
169
+ - run: |
170
+ echo "checking"
171
+ npm test'
172
+ run_lint "$R" 'main...pr'
173
+ check "cannot-fail neg: echo + real command block is clean (exit 0)" "$RC" "0"
174
+ check "cannot-fail neg: verdict" "$OUT" "lint=clean"
175
+
176
+ # Genuine can-fail single-line check passes
177
+ R=$(new_repo cf-neg)
178
+ put "$R" "$WF" 'jobs: {}'
179
+ pr "$R"
180
+ put "$R" "$WF" 'jobs:
181
+ gate:
182
+ steps:
183
+ - run: npx tsc --noEmit'
184
+ run_lint "$R" 'main...pr'
185
+ check "cannot-fail neg: genuine check passes (exit 0)" "$RC" "0"
186
+
187
+ # Evasion controls (R075 review finding): a cosmetic trailing comment or
188
+ # separator must NOT slip an enumerated cannot-fail command past rule (a).
189
+ R=$(new_repo cf-comment)
190
+ put "$R" "$WF" 'jobs: {}'
191
+ pr "$R"
192
+ put "$R" "$WF" 'jobs:
193
+ gate:
194
+ steps:
195
+ - run: exit 0 # placeholder while migrating'
196
+ run_lint "$R" 'main...pr'
197
+ check "cannot-fail: trailing # comment cannot evade (exit 1)" "$RC" "1"
198
+ check_contains "cannot-fail: comment-evasion finding" "$OUT" "finding=cannot-fail:$WF"
199
+
200
+ R=$(new_repo cf-semi)
201
+ put "$R" "$WF" 'jobs: {}'
202
+ pr "$R"
203
+ put "$R" "$WF" 'jobs:
204
+ gate:
205
+ steps:
206
+ - run: exit 0 ;'
207
+ run_lint "$R" 'main...pr'
208
+ check "cannot-fail: trailing separator cannot evade (exit 1)" "$RC" "1"
209
+
210
+ R=$(new_repo cf-true-comment)
211
+ put "$R" "$WF" 'jobs: {}'
212
+ pr "$R"
213
+ put "$R" "$WF" 'jobs:
214
+ gate:
215
+ steps:
216
+ - run: true # TODO real check'
217
+ run_lint "$R" 'main...pr'
218
+ check "cannot-fail: true with trailing comment cannot evade (exit 1)" "$RC" "1"
219
+
220
+ # A REAL command that merely carries a trailing comment stays clean (the strip
221
+ # is for classification only — it must not turn a genuine check into a finding).
222
+ R=$(new_repo cf-real-comment)
223
+ put "$R" "$WF" 'jobs: {}'
224
+ pr "$R"
225
+ put "$R" "$WF" 'jobs:
226
+ gate:
227
+ steps:
228
+ - run: npx tsc --noEmit # strict mode'
229
+ run_lint "$R" 'main...pr'
230
+ check "cannot-fail neg: real command + trailing comment stays clean (exit 0)" "$RC" "0"
231
+
232
+ # Security-review variants (per-segment + whitespace/zero tolerant): each is a
233
+ # unit where every command always exits 0, so each IS rule (a) and must fire.
234
+ for cmd in 'echo x && exit 0' 'true && true' ': ; true' 'exit 0' 'exit 00'; do
235
+ R=$(new_repo "cf-v$(printf '%s' "$cmd" | tr -cd 'a-z0-9')")
236
+ put "$R" "$WF" 'jobs: {}'
237
+ pr "$R"
238
+ printf 'jobs:\n gate:\n steps:\n - run: %s\n' "$cmd" > "$R/$WF"
239
+ git -C "$R" add "$WF"; git -C "$R" commit -qm "variant"
240
+ run_lint "$R" 'main...pr'
241
+ check "cannot-fail: '$cmd' cannot evade (exit 1)" "$RC" "1"
242
+ done
243
+
244
+ # Block scalar with explicit-indent header (|2) must open a block and classify
245
+ # its echo-only body as cannot-fail — not read '|2' as a bogus inline command.
246
+ R=$(new_repo cf-blockindent)
247
+ put "$R" "$WF" 'jobs: {}'
248
+ pr "$R"
249
+ printf 'jobs:\n gate:\n steps:\n - run: |2\n echo checking\n echo done\n' > "$R/$WF"
250
+ git -C "$R" add "$WF"; git -C "$R" commit -qm "block indent"
251
+ run_lint "$R" 'main...pr'
252
+ check "cannot-fail: |2 explicit-indent block body classified (exit 1)" "$RC" "1"
253
+
254
+ # GitLab script list: exit-0-only list fires
255
+ R=$(new_repo cf-gitlab)
256
+ put "$R" .gitlab-ci.yml 'stages: [test]'
257
+ pr "$R"
258
+ put "$R" .gitlab-ci.yml 'stages: [test]
259
+ gate:
260
+ script:
261
+ - exit 0'
262
+ run_lint "$R" 'main...pr'
263
+ check "cannot-fail: gitlab exit-0 script fires (exit 1)" "$RC" "1"
264
+ check_contains "cannot-fail: gitlab finding" "$OUT" "finding=cannot-fail:.gitlab-ci.yml"
265
+
266
+ # Pre-existing exit-0 step NOT touched by the diff → not reported (lint the diff, not the world)
267
+ R=$(new_repo cf-preexist)
268
+ put "$R" "$WF" 'jobs:
269
+ legacy:
270
+ steps:
271
+ - run: exit 0'
272
+ pr "$R"
273
+ put "$R" README.md 'docs change'
274
+ run_lint "$R" 'main...pr'
275
+ check "cannot-fail neg: pre-existing untouched exit-0 not reported (exit 0)" "$RC" "0"
276
+ fi
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # silent-fail — all-silent conjuncts, no || diagnostic
280
+ # ---------------------------------------------------------------------------
281
+ if running silent-fail; then
282
+ R=$(new_repo sf)
283
+ put "$R" "$WF" 'jobs: {}'
284
+ pr "$R"
285
+ put "$R" "$WF" 'jobs:
286
+ gate:
287
+ steps:
288
+ - run: grep -q VERSION package.json && test -f dist/app.js'
289
+ run_lint "$R" 'main...pr'
290
+ check "silent-fail: silent conjuncts without diagnostics fail (exit 1)" "$RC" "1"
291
+ check_contains "silent-fail: finding" "$OUT" "finding=silent-fail:$WF"
292
+
293
+ R=$(new_repo sf-neg)
294
+ put "$R" "$WF" 'jobs: {}'
295
+ pr "$R"
296
+ put "$R" "$WF" 'jobs:
297
+ gate:
298
+ steps:
299
+ - run: grep -q VERSION package.json || { echo "VERSION missing from package.json"; exit 1; }'
300
+ run_lint "$R" 'main...pr'
301
+ check "silent-fail neg: || diagnostic branch is clean (exit 0)" "$RC" "0"
302
+ fi
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # existence-only — bare file-existence check as a gate
306
+ # ---------------------------------------------------------------------------
307
+ if running existence-only; then
308
+ R=$(new_repo eo)
309
+ put "$R" "$WF" 'jobs: {}'
310
+ pr "$R"
311
+ put "$R" "$WF" 'jobs:
312
+ gate:
313
+ steps:
314
+ - run: test -f dist/app.js'
315
+ run_lint "$R" 'main...pr'
316
+ check "existence-only: bare test -f gate fails (exit 1)" "$RC" "1"
317
+ check_contains "existence-only: finding" "$OUT" "finding=existence-only:$WF"
318
+ fi
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # deleted-assertion — removed assertion in a gating test
322
+ # ---------------------------------------------------------------------------
323
+ if running deleted-assertion; then
324
+ R=$(new_repo da)
325
+ put "$R" tests/app.test.js 'describe("app", () => {
326
+ it("adds", () => {
327
+ expect(add(1, 2)).toBe(3)
328
+ expect(add(0, 0)).toBe(0)
329
+ })
330
+ })'
331
+ pr "$R"
332
+ put "$R" tests/app.test.js 'describe("app", () => {
333
+ it("adds", () => {
334
+ expect(add(1, 2)).toBe(3)
335
+ })
336
+ })'
337
+ run_lint "$R" 'main...pr'
338
+ check "deleted-assertion: removed expect() fails (exit 1)" "$RC" "1"
339
+ check_contains "deleted-assertion: finding names file" "$OUT" "finding=deleted-assertion:tests/app.test.js"
340
+
341
+ # Added honest test passes untouched
342
+ R=$(new_repo da-neg)
343
+ put "$R" src/app.js 'x'
344
+ pr "$R"
345
+ put "$R" tests/new.test.js 'it("works", () => { expect(1).toBe(1) })'
346
+ run_lint "$R" 'main...pr'
347
+ check "deleted-assertion neg: added honest test is clean (exit 0)" "$RC" "0"
348
+ check "deleted-assertion neg: verdict" "$OUT" "lint=clean"
349
+
350
+ # Base-side gating_tests globs NARROW the scope
351
+ R=$(new_repo da-gating)
352
+ put "$R" .forge/hold-config.yml 'version: 1
353
+ operators: []
354
+ irreversible_paths: []
355
+ deploy_paths: []
356
+ gating_tests:
357
+ - "tests/e2e/*"'
358
+ put "$R" tests/unit/u.test.js 'it("u", () => { expect(1).toBe(1) })'
359
+ put "$R" tests/e2e/g.test.js 'it("g", () => { expect(2).toBe(2) })'
360
+ pr "$R"
361
+ put "$R" tests/unit/u.test.js 'it("u", () => { })'
362
+ run_lint "$R" 'main...pr'
363
+ check "deleted-assertion: outside gating_tests globs is clean (exit 0)" "$RC" "0"
364
+ git -C "$R" checkout -q main
365
+ git -C "$R" branch -qD pr
366
+ pr "$R"
367
+ put "$R" tests/e2e/g.test.js 'it("g", () => { })'
368
+ run_lint "$R" 'main...pr'
369
+ check "deleted-assertion: inside gating_tests globs fires (exit 1)" "$RC" "1"
370
+ check_contains "deleted-assertion: gating finding" "$OUT" "finding=deleted-assertion:tests/e2e/g.test.js"
371
+ fi
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # feature-removal — field finding (m-31 amendment): a shrinking test correlated
375
+ # with a whole same-stem SOURCE FILE deletion is reclassified from test-gaming to
376
+ # "feature removal — confirm intended". Classifier, not allowlist: the finding
377
+ # still fires and still requires the recorded operator act.
378
+ # ---------------------------------------------------------------------------
379
+ if running feature-removal; then
380
+ # Correlated source+test deletion → labelled feature-removal, NOT deleted-assertion
381
+ R=$(new_repo fr)
382
+ put "$R" src/widget.js 'export function widget() { return 42 }'
383
+ put "$R" tests/widget.test.js 'describe("widget", () => {
384
+ it("returns 42", () => {
385
+ expect(widget()).toBe(42)
386
+ expect(typeof widget()).toBe("number")
387
+ })
388
+ })'
389
+ pr "$R"
390
+ git -C "$R" rm -q src/widget.js tests/widget.test.js
391
+ git -C "$R" commit -qm "remove widget feature"
392
+ run_lint "$R" 'main...pr'
393
+ check "feature-removal: correlated source+test deletion fires (exit 1)" "$RC" "1"
394
+ check_contains "feature-removal: labelled feature-removal" "$OUT" "finding=feature-removal:tests/widget.test.js"
395
+ check_absent "feature-removal: NOT mislabelled test-gaming" "$OUT" "finding=deleted-assertion:tests/widget.test.js"
396
+
397
+ # Classifier, not allowlist: still requires the operator act — trailer lifts it
398
+ R=$(new_repo fr-justify)
399
+ put "$R" src/widget.js 'export function widget() { return 42 }'
400
+ put "$R" tests/widget.test.js 'describe("widget", () => {
401
+ it("returns 42", () => {
402
+ expect(widget()).toBe(42)
403
+ expect(typeof widget()).toBe("number")
404
+ })
405
+ })'
406
+ pr "$R"
407
+ git -C "$R" rm -q src/widget.js tests/widget.test.js
408
+ git -C "$R" commit -qm "remove widget feature" -m 'Lint-Justify: widget retired per roadmap #77'
409
+ run_lint "$R" 'main...pr'
410
+ check "feature-removal: still needs operator act — trailer lifts (exit 0)" "$RC" "0"
411
+ check_contains "feature-removal: justified verdict" "$OUT" "lint=justified"
412
+ check_contains "feature-removal: finding listed as justified" "$OUT" "finding=feature-removal:tests/widget.test.js justified"
413
+
414
+ # Discriminator: assertion loss with source INTACT stays test-gaming (stricter)
415
+ R=$(new_repo fr-neg)
416
+ put "$R" src/widget.js 'export function widget() { return 42 }'
417
+ put "$R" tests/widget.test.js 'describe("widget", () => {
418
+ it("returns 42", () => {
419
+ expect(widget()).toBe(42)
420
+ expect(typeof widget()).toBe("number")
421
+ })
422
+ })'
423
+ pr "$R"
424
+ put "$R" tests/widget.test.js 'describe("widget", () => {
425
+ it("returns 42", () => {
426
+ expect(widget()).toBe(42)
427
+ })
428
+ })'
429
+ run_lint "$R" 'main...pr'
430
+ check "feature-removal neg: source intact stays test-gaming (exit 1)" "$RC" "1"
431
+ check_contains "feature-removal neg: labelled deleted-assertion" "$OUT" "finding=deleted-assertion:tests/widget.test.js"
432
+ check_absent "feature-removal neg: not feature-removal" "$OUT" "finding=feature-removal"
433
+
434
+ # Bright line: a same-stem source SHRINK (not a whole-file deletion) does not
435
+ # downgrade — the assertion loss keeps the stricter deleted-assertion label.
436
+ R=$(new_repo fr-partial)
437
+ put "$R" src/widget.js 'export function widget() { return 42 }
438
+ export function extra() { return 1 }'
439
+ put "$R" tests/widget.test.js 'describe("widget", () => {
440
+ it("returns 42", () => {
441
+ expect(widget()).toBe(42)
442
+ expect(typeof widget()).toBe("number")
443
+ })
444
+ })'
445
+ pr "$R"
446
+ put "$R" src/widget.js 'export function widget() { return 42 }'
447
+ put "$R" tests/widget.test.js 'describe("widget", () => {
448
+ it("returns 42", () => {
449
+ expect(widget()).toBe(42)
450
+ })
451
+ })'
452
+ run_lint "$R" 'main...pr'
453
+ check "feature-removal partial: source shrink (not deletion) stays test-gaming (exit 1)" "$RC" "1"
454
+ check_contains "feature-removal partial: labelled deleted-assertion" "$OUT" "finding=deleted-assertion:tests/widget.test.js"
455
+ check_absent "feature-removal partial: not feature-removal" "$OUT" "finding=feature-removal"
456
+
457
+ # Unrelated source deletion (different stem) does NOT downgrade the label
458
+ R=$(new_repo fr-unrelated)
459
+ put "$R" src/other.js 'export function other() { return 7 }'
460
+ put "$R" tests/widget.test.js 'describe("widget", () => {
461
+ it("returns 42", () => {
462
+ expect(widget()).toBe(42)
463
+ expect(typeof widget()).toBe("number")
464
+ })
465
+ })'
466
+ pr "$R"
467
+ git -C "$R" rm -q src/other.js
468
+ put "$R" tests/widget.test.js 'describe("widget", () => {
469
+ it("returns 42", () => {
470
+ expect(widget()).toBe(42)
471
+ })
472
+ })'
473
+ run_lint "$R" 'main...pr'
474
+ check "feature-removal unrelated: different-stem deletion stays test-gaming (exit 1)" "$RC" "1"
475
+ check_contains "feature-removal unrelated: labelled deleted-assertion" "$OUT" "finding=deleted-assertion:tests/widget.test.js"
476
+ check_absent "feature-removal unrelated: not feature-removal" "$OUT" "finding=feature-removal"
477
+ fi
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # skip-marker — added .only/.skip/xit/@pytest.mark.skip/t.Skip
481
+ # ---------------------------------------------------------------------------
482
+ if running skip-marker; then
483
+ R=$(new_repo sm)
484
+ put "$R" tests/app.test.js 'it("a", () => { expect(1).toBe(1) })'
485
+ pr "$R"
486
+ put "$R" tests/app.test.js 'it.skip("a", () => { expect(1).toBe(1) })'
487
+ run_lint "$R" 'main...pr'
488
+ check "skip-marker: added it.skip fails (exit 1)" "$RC" "1"
489
+ check_contains "skip-marker: finding" "$OUT" "finding=skip-marker:tests/app.test.js"
490
+
491
+ R=$(new_repo sm-py)
492
+ put "$R" test_app.py 'def test_a():
493
+ assert 1 == 1'
494
+ pr "$R"
495
+ put "$R" test_app.py 'import pytest
496
+
497
+ @pytest.mark.skip
498
+ def test_a():
499
+ assert 1 == 1'
500
+ run_lint "$R" 'main...pr'
501
+ check "skip-marker: added @pytest.mark.skip fails (exit 1)" "$RC" "1"
502
+
503
+ R=$(new_repo sm-only)
504
+ put "$R" tests/b.spec.ts 'describe("b", () => { it("x", () => { expect(1).toBe(1) }) })'
505
+ pr "$R"
506
+ put "$R" tests/b.spec.ts 'describe.only("b", () => { it("x", () => { expect(1).toBe(1) }) })'
507
+ run_lint "$R" 'main...pr'
508
+ check "skip-marker: added describe.only fails (exit 1)" "$RC" "1"
509
+ fi
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # justify — Lint-Justify trailer lifts b/c/deleted-assertion/skip-marker
513
+ # ---------------------------------------------------------------------------
514
+ if running justify; then
515
+ R=$(new_repo j)
516
+ put "$R" tests/app.test.js 'it("a", () => { expect(1).toBe(1) })'
517
+ pr "$R"
518
+ put "$R" tests/app.test.js 'it.skip("a", () => { expect(1).toBe(1) })' 'Lint-Justify: flaky upstream API, quarantined in #42'
519
+ run_lint "$R" 'main...pr'
520
+ check "justify: trailer lifts skip-marker (exit 0)" "$RC" "0"
521
+ check_contains "justify: verdict echoes justification" "$OUT" 'lint=justified justify="flaky upstream API, quarantined in #42"'
522
+ check_contains "justify: finding still listed as justified" "$OUT" "finding=skip-marker:tests/app.test.js"
523
+
524
+ R=$(new_repo j-eo)
525
+ put "$R" "$WF" 'jobs: {}'
526
+ pr "$R"
527
+ put "$R" "$WF" 'jobs:
528
+ gate:
529
+ steps:
530
+ - run: test -f dist/app.js' 'Lint-Justify: artifact presence is the contract here'
531
+ run_lint "$R" 'main...pr'
532
+ check "justify: trailer lifts existence-only (exit 0)" "$RC" "0"
533
+
534
+ # A double-quote in the trailer must not produce malformed justify="..." output
535
+ # (security-review C6): embedded quotes are neutralised, verdict stays parseable.
536
+ R=$(new_repo j-quote)
537
+ put "$R" tests/app.test.js 'it("a", () => { expect(1).toBe(1) })'
538
+ pr "$R"
539
+ put "$R" tests/app.test.js 'it.skip("a", () => { expect(1).toBe(1) })' 'Lint-Justify: see "flaky" ticket #42'
540
+ run_lint "$R" 'main...pr'
541
+ check "justify: embedded-quote trailer still lifts (exit 0)" "$RC" "0"
542
+ check_absent "justify: no raw double-quote leaks into output" "$OUT" 'see "flaky"'
543
+ check_contains "justify: verdict remains parseable" "$OUT" "lint=justified"
544
+
545
+ # No trailer → same diff fails (control)
546
+ R=$(new_repo j-neg)
547
+ put "$R" "$WF" 'jobs: {}'
548
+ pr "$R"
549
+ put "$R" "$WF" 'jobs:
550
+ gate:
551
+ steps:
552
+ - run: test -f dist/app.js'
553
+ run_lint "$R" 'main...pr'
554
+ check "justify neg: same diff without trailer fails (exit 1)" "$RC" "1"
555
+
556
+ # Trailer + an (a) finding in the same PR → still fail (a is absolute)
557
+ R=$(new_repo j-mixed)
558
+ put "$R" "$WF" 'jobs: {}'
559
+ pr "$R"
560
+ put "$R" "$WF" 'jobs:
561
+ gate:
562
+ steps:
563
+ - run: exit 0
564
+ - run: test -f dist/app.js' 'Lint-Justify: transitional'
565
+ run_lint "$R" 'main...pr'
566
+ check "justify: trailer never saves a PR carrying rule a (exit 1)" "$RC" "1"
567
+ fi
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # docs-only — acceptance 6: a status on every PR
571
+ # ---------------------------------------------------------------------------
572
+ if running docs-only; then
573
+ R=$(new_repo docs)
574
+ put "$R" README.md 'hello'
575
+ pr "$R"
576
+ put "$R" README.md 'hello world'
577
+ run_lint "$R" 'main...pr'
578
+ check "docs-only: exit 0" "$RC" "0"
579
+ check "docs-only: exactly lint=clean" "$OUT" "lint=clean"
580
+ fi
581
+
582
+ # ---------------------------------------------------------------------------
583
+ # invocation — exit 2, never a false verdict
584
+ # ---------------------------------------------------------------------------
585
+ if running invocation; then
586
+ R=$(new_repo inv)
587
+ run_lint "$R"
588
+ check "invocation: missing range exits 2" "$RC" "2"
589
+ run_lint "$R" 'main..pr'
590
+ check "invocation: two-dot range rejected (exit 2)" "$RC" "2"
591
+ run_lint "$R" 'nosuch...refs'
592
+ check "invocation: unresolvable refs exit 2" "$RC" "2"
593
+ fi
594
+
595
+ # ---------------------------------------------------------------------------
596
+ printf '\n%d selected case block(s): %d passed, %d failed\n' "$SELECTED" "$PASSED" "$FAILED"
597
+ [ "$FAILED" -eq 0 ] && [ "$SELECTED" -gt 0 ]