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.
- package/bin/create-forge.js +6 -0
- package/package.json +1 -1
- package/template/.claude/hooks/forge-release-fold.sh +458 -0
- package/template/.claude/hooks/tests/forge-release-fold.test.sh +773 -0
- package/template/.claude/settings.json +1 -1
- package/template/.claude/skills/forge/SKILL.md +4 -2
- package/template/.claude/skills/reviewing/SKILL.md +1 -1
- package/template/.claude/skills/upgrading/SKILL.md +18 -0
- package/template/.claude/skills/verifying/SKILL.md +9 -2
- package/template/.forge/FORGE.md +8 -3
- package/template/.forge/checks/forge-check-lint.sh +383 -0
- package/template/.forge/checks/forge-hold-check.sh +259 -0
- package/template/.forge/checks/tests/forge-check-lint.test.sh +597 -0
- package/template/.forge/checks/tests/forge-hold-check.test.sh +404 -0
- package/template/.forge/gitignore +6 -1
- package/template/.forge/migrations/0.56.0-merged-validated-fold.md +74 -0
- package/template/.forge/migrations/0.58.2-merge-floor-checks-relocation.md +61 -0
- package/template/.forge/templates/hold-config.yml +34 -0
- package/template/.forge/templates/project.yml +51 -21
- package/template/.forge/templates/state/index.yml +51 -24
|
@@ -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 ]
|