forge-orkes 0.70.0 → 0.72.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/package.json +1 -1
- package/template/.claude/hooks/forge-model-outcome.sh +5 -3
- package/template/.claude/hooks/forge-reserve.sh +127 -15
- package/template/.claude/hooks/forge-slice-notify.sh +12 -3
- package/template/.claude/hooks/forge-slice-runner.sh +40 -19
- package/template/.claude/skills/jarvis/SKILL.md +2 -0
- package/template/.claude/skills/notion-integration/SKILL.md +1 -0
- package/template/.claude/skills/planning/SKILL.md +3 -3
- package/template/.forge/FORGE.md +2 -2
- package/template/.forge/checks/forge-board-render.sh +21 -18
- package/template/.forge/checks/forge-check-lint.sh +36 -2
- package/template/.forge/checks/forge-hold-check.sh +121 -2
- package/template/.forge/checks/forge-jarvis-dispatch.sh +236 -12
- package/template/.forge/checks/forge-jarvis.sh +28 -0
- package/template/.forge/checks/forge-roadmap-check.sh +106 -22
- package/template/.forge/checks/tests/forge-check-lint.test.sh +126 -4
- package/template/.forge/checks/tests/forge-hold-check.test.sh +166 -1
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +211 -0
- package/template/.forge/checks/tests/forge-jarvis.test.sh +78 -4
- package/template/.forge/checks/tests/forge-reserve.test.sh +235 -0
- package/template/.forge/checks/tests/forge-roadmap-check.test.sh +487 -0
- package/template/.forge/templates/hold-config.yml +15 -0
- package/template/.forge/templates/project.yml +10 -0
- package/template/.forge/templates/waive-hold.yml +63 -0
|
@@ -20,12 +20,18 @@
|
|
|
20
20
|
# exit: 0 = ok=clean; 1 = one or more findings; 2 = bad invocation (missing/unreadable file)
|
|
21
21
|
#
|
|
22
22
|
# Required fields (confirmed against the live file — zero false positives today):
|
|
23
|
-
# milestone entry: id, name, phases (non-empty
|
|
23
|
+
# milestone entry: id, name, phases (non-empty — an empty list, `phases: []` or a
|
|
24
|
+
# block-style key with no items, raises missing-field:milestone:<id>:phases)
|
|
24
25
|
# phase entry: id, name, goal, requirements, dependencies, success_criteria,
|
|
25
26
|
# estimated_hours, status
|
|
26
27
|
# milestone_dir is DELIBERATELY not required — 17/65 live phase entries predate
|
|
27
28
|
# the nested-layout convention. Validated only when present (bad-milestone-dir).
|
|
28
29
|
#
|
|
30
|
+
# A milestone's phases: list is parsed in all three shapes YAML allows here:
|
|
31
|
+
# one-line flow ([1, 2]), wrapped flow ([1,\n 2]), and block style (- 1 items).
|
|
32
|
+
# Trailing "# comment" text on id/milestone_dir/phases lines is stripped before
|
|
33
|
+
# capture (mirrors forge-check-lint.sh strip_comment).
|
|
34
|
+
#
|
|
29
35
|
# Pure POSIX sh + awk. No git-repo dependency (reads the file directly, unlike the
|
|
30
36
|
# diff-scoped forge-hold-check.sh/forge-check-lint.sh). Collects every finding in
|
|
31
37
|
# one pass before deciding exit code — a single run surfaces everything wrong.
|
|
@@ -51,6 +57,10 @@ function unquote(s) {
|
|
|
51
57
|
gsub(/"/, "", s)
|
|
52
58
|
return s
|
|
53
59
|
}
|
|
60
|
+
function strip_comment(s) {
|
|
61
|
+
sub(/[ \t]#.*$/, "", s)
|
|
62
|
+
return s
|
|
63
|
+
}
|
|
54
64
|
function bare_num(s, n) {
|
|
55
65
|
n = unquote(trim(s))
|
|
56
66
|
if (n ~ /^m-/) n = substr(n, 3)
|
|
@@ -70,6 +80,7 @@ function finish_entry() {
|
|
|
70
80
|
ph_mdir[ph_count] = cur_mdir
|
|
71
81
|
}
|
|
72
82
|
entry_open = 0
|
|
83
|
+
ph_collect = 0
|
|
73
84
|
cur_id = ""; cur_fields = ""; cur_mdir = ""; cur_phaselist = ""
|
|
74
85
|
}
|
|
75
86
|
|
|
@@ -107,13 +118,14 @@ block == "waves" && $0 ~ /^ [0-9]+:/ {
|
|
|
107
118
|
entry_type = (block == "milestones" ? "milestone" : "phase")
|
|
108
119
|
val = $0
|
|
109
120
|
sub(/^ - id:[ \t]*/, "", val)
|
|
110
|
-
cur_id = unquote(trim(val))
|
|
121
|
+
cur_id = unquote(trim(strip_comment(val)))
|
|
111
122
|
cur_fields = " id "
|
|
112
123
|
next
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
# 6-space field line inside an open entry
|
|
116
127
|
(block == "milestones" || block == "phases") && entry_open && $0 ~ /^ [A-Za-z_]+:/ {
|
|
128
|
+
ph_collect = 0
|
|
117
129
|
line = $0
|
|
118
130
|
sub(/^ /, "", line)
|
|
119
131
|
match(line, /^[A-Za-z_]+/)
|
|
@@ -122,17 +134,45 @@ block == "waves" && $0 ~ /^ [0-9]+:/ {
|
|
|
122
134
|
if (field == "milestone_dir") {
|
|
123
135
|
val = line
|
|
124
136
|
sub(/^milestone_dir:[ \t]*/, "", val)
|
|
125
|
-
cur_mdir = unquote(trim(val))
|
|
137
|
+
cur_mdir = unquote(trim(strip_comment(val)))
|
|
126
138
|
}
|
|
127
139
|
if (field == "phases" && entry_type == "milestone") {
|
|
128
|
-
rest = line
|
|
140
|
+
rest = strip_comment(line)
|
|
141
|
+
sub(/^phases:[ \t]*/, "", rest)
|
|
129
142
|
if (match(rest, /\[[^]]*\]/)) {
|
|
130
143
|
cur_phaselist = substr(rest, RSTART + 1, RLENGTH - 2)
|
|
144
|
+
} else if (rest ~ /^\[/) {
|
|
145
|
+
cur_phaselist = substr(rest, 2)
|
|
146
|
+
ph_collect = 1
|
|
147
|
+
} else if (rest == "") {
|
|
148
|
+
ph_collect = 2
|
|
131
149
|
}
|
|
132
150
|
}
|
|
133
151
|
next
|
|
134
152
|
}
|
|
135
153
|
|
|
154
|
+
# wrapped flow list continuation: collect until the ] that closes phases: [...
|
|
155
|
+
# (a new 6-space field line ends collection via the rule above — failsafe for
|
|
156
|
+
# a bracket that never closes)
|
|
157
|
+
block == "milestones" && entry_open && ph_collect == 1 && $0 ~ /^ / {
|
|
158
|
+
line = strip_comment($0)
|
|
159
|
+
if (match(line, /\]/)) {
|
|
160
|
+
cur_phaselist = cur_phaselist " " substr(line, 1, RSTART - 1)
|
|
161
|
+
ph_collect = 0
|
|
162
|
+
} else {
|
|
163
|
+
cur_phaselist = cur_phaselist " " line
|
|
164
|
+
}
|
|
165
|
+
next
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# block-style list item under a bare "phases:" key: " - 4"
|
|
169
|
+
block == "milestones" && entry_open && ph_collect == 2 && $0 ~ /^ - / {
|
|
170
|
+
line = strip_comment($0)
|
|
171
|
+
sub(/^ - [ \t]*/, "", line)
|
|
172
|
+
cur_phaselist = cur_phaselist " " unquote(trim(line))
|
|
173
|
+
next
|
|
174
|
+
}
|
|
175
|
+
|
|
136
176
|
# any other 4-space line inside milestones:/phases: (not "- id:") -> malformed
|
|
137
177
|
# dedent: closes the current entry without opening a new one.
|
|
138
178
|
(block == "milestones" || block == "phases") && $0 ~ /^ [A-Za-z_-]/ {
|
|
@@ -140,13 +180,8 @@ block == "waves" && $0 ~ /^ [0-9]+:/ {
|
|
|
140
180
|
next
|
|
141
181
|
}
|
|
142
182
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
required_ms = "name phases"
|
|
147
|
-
required_ph = "name goal requirements dependencies success_criteria estimated_hours status"
|
|
148
|
-
|
|
149
|
-
# 1. required fields
|
|
183
|
+
# 1. required fields (+ non-empty phases list on milestones)
|
|
184
|
+
function check_fields( i, j, n, reqs, cnt, toks) {
|
|
150
185
|
for (i = 1; i <= ms_count; i++) {
|
|
151
186
|
n = split(required_ms, reqs, " ")
|
|
152
187
|
for (j = 1; j <= n; j++) {
|
|
@@ -155,6 +190,15 @@ END {
|
|
|
155
190
|
found++
|
|
156
191
|
}
|
|
157
192
|
}
|
|
193
|
+
if (index(ms_fields[i], " phases ") != 0) {
|
|
194
|
+
cnt = 0
|
|
195
|
+
n = split(ms_phaselist[i], toks, /[ ,]+/)
|
|
196
|
+
for (j = 1; j <= n; j++) if (trim(toks[j]) != "") cnt++
|
|
197
|
+
if (cnt == 0) {
|
|
198
|
+
print "finding=missing-field:milestone:" ms_id[i] ":phases"
|
|
199
|
+
found++
|
|
200
|
+
}
|
|
201
|
+
}
|
|
158
202
|
}
|
|
159
203
|
for (i = 1; i <= ph_count; i++) {
|
|
160
204
|
n = split(required_ph, reqs, " ")
|
|
@@ -165,39 +209,60 @@ END {
|
|
|
165
209
|
}
|
|
166
210
|
}
|
|
167
211
|
}
|
|
212
|
+
}
|
|
168
213
|
|
|
169
|
-
|
|
214
|
+
# phase-id bookkeeping consumed by check_dups/check_refs/check_orphans:
|
|
215
|
+
# dup_count (occurrences), dup_list (mdir detail per occurrence), phase_exists (set)
|
|
216
|
+
function collect_phase_index( i, id) {
|
|
170
217
|
for (i = 1; i <= ph_count; i++) {
|
|
171
218
|
id = ph_id[i]
|
|
172
|
-
phase_seen[id] = phase_seen[id] "" (phase_seen[id] == "" ? "" : ",")
|
|
173
219
|
dup_list[id] = (dup_list[id] == "" ? "" : dup_list[id] ",") (ph_mdir[i] == "" ? "(none)" : ph_mdir[i])
|
|
174
220
|
dup_count[id]++
|
|
175
221
|
phase_exists[id] = 1
|
|
176
222
|
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# pre-pass: claimed_by[] must be fully populated before check_orphans reads it
|
|
226
|
+
function collect_claimed( i, j, m, claimed, pid) {
|
|
227
|
+
for (i = 1; i <= ms_count; i++) {
|
|
228
|
+
m = split(ms_phaselist[i], claimed, /[ ,]+/)
|
|
229
|
+
for (j = 1; j <= m; j++) {
|
|
230
|
+
pid = trim(claimed[j])
|
|
231
|
+
if (pid != "") claimed_by[pid] = 1
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
177
235
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
236
|
+
# 2. duplicate phase ids — first-seen order over ph_id[] (stable across awk
|
|
237
|
+
# impls, unlike for-in), one report per id via dup_reported[]
|
|
238
|
+
function check_dups( i, id) {
|
|
239
|
+
for (i = 1; i <= ph_count; i++) {
|
|
240
|
+
id = ph_id[i]
|
|
241
|
+
if (dup_count[id] > 1 && !(id in dup_reported)) {
|
|
242
|
+
dup_reported[id] = 1
|
|
181
243
|
print "finding=duplicate-phase-id:" id ":" dup_list[id]
|
|
182
244
|
found++
|
|
183
245
|
}
|
|
184
246
|
}
|
|
247
|
+
}
|
|
185
248
|
|
|
186
|
-
|
|
249
|
+
# 3. dangling-phase-ref (milestone claims a phase id with no phase entry)
|
|
250
|
+
function check_refs( i, j, m, claimed, pid) {
|
|
187
251
|
for (i = 1; i <= ms_count; i++) {
|
|
188
252
|
m = split(ms_phaselist[i], claimed, /[ ,]+/)
|
|
189
253
|
for (j = 1; j <= m; j++) {
|
|
190
254
|
pid = trim(claimed[j])
|
|
191
255
|
if (pid == "") continue
|
|
192
|
-
claimed_by[pid] = 1
|
|
193
256
|
if (!(pid in phase_exists)) {
|
|
194
257
|
print "finding=dangling-phase-ref:milestone:" ms_id[i] ":phase:" pid
|
|
195
258
|
found++
|
|
196
259
|
}
|
|
197
260
|
}
|
|
198
261
|
}
|
|
262
|
+
}
|
|
199
263
|
|
|
200
|
-
|
|
264
|
+
# orphan-phase (phase entry claimed by zero milestones; reads claimed_by[])
|
|
265
|
+
function check_orphans( i, id) {
|
|
201
266
|
for (i = 1; i <= ph_count; i++) {
|
|
202
267
|
id = ph_id[i]
|
|
203
268
|
if (!(id in claimed_by)) {
|
|
@@ -205,8 +270,10 @@ END {
|
|
|
205
270
|
found++
|
|
206
271
|
}
|
|
207
272
|
}
|
|
273
|
+
}
|
|
208
274
|
|
|
209
|
-
|
|
275
|
+
# 4. bad-milestone-dir (milestone_dir prefix must resolve to a real milestone id)
|
|
276
|
+
function check_mdirs( i, prefix, numeral) {
|
|
210
277
|
for (i = 1; i <= ms_count; i++) {
|
|
211
278
|
ms_numeral[bare_num(ms_id[i])] = 1
|
|
212
279
|
}
|
|
@@ -222,8 +289,10 @@ END {
|
|
|
222
289
|
}
|
|
223
290
|
}
|
|
224
291
|
}
|
|
292
|
+
}
|
|
225
293
|
|
|
226
|
-
|
|
294
|
+
# 5. dangling-wave-ref
|
|
295
|
+
function check_waves( i, j, m, wids, pid) {
|
|
227
296
|
for (i = 1; i <= wave_count; i++) {
|
|
228
297
|
m = split(wave_ids[i], wids, /[ ,]+/)
|
|
229
298
|
for (j = 1; j <= m; j++) {
|
|
@@ -235,6 +304,22 @@ END {
|
|
|
235
304
|
}
|
|
236
305
|
}
|
|
237
306
|
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
END {
|
|
310
|
+
finish_entry()
|
|
311
|
+
|
|
312
|
+
required_ms = "name phases"
|
|
313
|
+
required_ph = "name goal requirements dependencies success_criteria estimated_hours status"
|
|
314
|
+
|
|
315
|
+
check_fields()
|
|
316
|
+
collect_phase_index()
|
|
317
|
+
collect_claimed()
|
|
318
|
+
check_dups()
|
|
319
|
+
check_refs()
|
|
320
|
+
check_orphans()
|
|
321
|
+
check_mdirs()
|
|
322
|
+
check_waves()
|
|
238
323
|
|
|
239
324
|
if (found + 0 == 0) {
|
|
240
325
|
print "ok=clean"
|
|
@@ -243,4 +328,3 @@ END {
|
|
|
243
328
|
exit 1
|
|
244
329
|
}
|
|
245
330
|
' "$FILE"
|
|
246
|
-
exit $?
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# lint=fail + finding=<rule>:<file>[:<line>] lines
|
|
8
8
|
# lint=justified justify="<trailer line>"
|
|
9
9
|
# + "finding=<rule>:<file>[:<line>] justified" lines
|
|
10
|
-
# rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker
|
|
10
|
+
# rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker|driven-by-missing
|
|
11
11
|
# exit: 0 = clean or justified; 1 = fail; 2 = bad invocation.
|
|
12
12
|
# Range MUST be three-dot BASE...HEAD; scanned against merge-base(BASE, HEAD).
|
|
13
13
|
#
|
|
@@ -28,17 +28,24 @@
|
|
|
28
28
|
# allowlist: still fires, still needs the operator act, only the label
|
|
29
29
|
# changes (partial source shrink keeps the stricter deleted-assertion);
|
|
30
30
|
# skip-marker — added .only(/.skip(/it.skip/describe.only/xit(/xdescribe(/
|
|
31
|
-
# @pytest.mark.skip/t.Skip() line
|
|
31
|
+
# @pytest.mark.skip/t.Skip() line;
|
|
32
|
+
# driven-by-missing — CONFIG-GATED, default OFF: when the BASE-side
|
|
33
|
+
# .forge/project.yml carries attribution.driven_by: true, every
|
|
34
|
+
# non-merge commit of merge-base..head must carry a
|
|
35
|
+
# Driven-by: <email> trailer (optional trailing session ref);
|
|
36
|
+
# finding's file slot = the offending commit's short SHA. Absent/
|
|
37
|
+
# false config = rule fully off. Base-side read: a PR cannot
|
|
38
|
+
# disable it for itself.
|
|
32
39
|
# Justification: a "Lint-Justify: <one line>" trailer in ANY commit message of
|
|
33
40
|
# merge-base..head (pure git). Lifts b/c/deleted-assertion/feature-removal/
|
|
34
|
-
# skip-marker → exit 0 lint=justified. NEVER lifts (a).
|
|
41
|
+
# skip-marker/driven-by-missing → exit 0 lint=justified. NEVER lifts (a).
|
|
35
42
|
#
|
|
36
43
|
# Offline by construction: remote-less mktemp fixture repos (NFR-032).
|
|
37
44
|
#
|
|
38
45
|
# Usage:
|
|
39
46
|
# ./.forge/checks/tests/forge-check-lint.test.sh # all cases
|
|
40
47
|
# ./.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
|
|
48
|
+
# Filter tags: cannot-fail silent-fail existence-only deleted-assertion feature-removal skip-marker driven-by waiver-boundary justify docs-only invocation
|
|
42
49
|
#
|
|
43
50
|
# RED-phase friendly: missing helper → every selected case FAILS, suite exits
|
|
44
51
|
# non-zero, no abort. Exits 0 iff every selected case passes. Self-cleans.
|
|
@@ -566,6 +573,121 @@ if running justify; then
|
|
|
566
573
|
check "justify: trailer never saves a PR carrying rule a (exit 1)" "$RC" "1"
|
|
567
574
|
fi
|
|
568
575
|
|
|
576
|
+
# ---------------------------------------------------------------------------
|
|
577
|
+
# driven-by — the config-gated Driven-by trailer rule (hold-waiver patch W2).
|
|
578
|
+
# Default OFF; base-side project.yml attribution.driven_by: true turns it on.
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
if running driven-by; then
|
|
581
|
+
PROJ_ON='attribution:
|
|
582
|
+
driven_by: true'
|
|
583
|
+
|
|
584
|
+
# OFF (default): no config → a trailer-less commit is clean (zero behavior change)
|
|
585
|
+
R=$(new_repo db-off)
|
|
586
|
+
put "$R" src/app.js 'x'
|
|
587
|
+
pr "$R"
|
|
588
|
+
put "$R" src/app.js 'y'
|
|
589
|
+
run_lint "$R" 'main...pr'
|
|
590
|
+
check "driven-by OFF: no config, trailer-less commit is clean (exit 0)" "$RC" "0"
|
|
591
|
+
check_absent "driven-by OFF: no driven-by finding" "$OUT" "driven-by-missing"
|
|
592
|
+
|
|
593
|
+
# OFF (explicit false): rule stays off
|
|
594
|
+
R=$(new_repo db-false)
|
|
595
|
+
put "$R" .forge/project.yml 'attribution:
|
|
596
|
+
driven_by: false'
|
|
597
|
+
pr "$R"
|
|
598
|
+
put "$R" src/app.js 'y'
|
|
599
|
+
run_lint "$R" 'main...pr'
|
|
600
|
+
check "driven-by OFF: driven_by false stays off (exit 0)" "$RC" "0"
|
|
601
|
+
|
|
602
|
+
# ON: a commit missing the trailer fails, finding names its short SHA
|
|
603
|
+
R=$(new_repo db-on)
|
|
604
|
+
put "$R" .forge/project.yml "$PROJ_ON"
|
|
605
|
+
pr "$R"
|
|
606
|
+
put "$R" src/app.js 'y'
|
|
607
|
+
DB_SHA="$(git -C "$R" rev-parse --short pr)"
|
|
608
|
+
run_lint "$R" 'main...pr'
|
|
609
|
+
check "driven-by ON: missing trailer fails (exit 1)" "$RC" "1"
|
|
610
|
+
check_contains "driven-by ON: verdict" "$OUT" "lint=fail"
|
|
611
|
+
check_contains "driven-by ON: finding names the commit" "$OUT" "finding=driven-by-missing:$DB_SHA"
|
|
612
|
+
|
|
613
|
+
# ON: every commit carrying the trailer is clean (optional session ref allowed)
|
|
614
|
+
R=$(new_repo db-ok)
|
|
615
|
+
put "$R" .forge/project.yml "$PROJ_ON"
|
|
616
|
+
pr "$R"
|
|
617
|
+
put "$R" src/app.js 'y' 'Driven-by: zayne@zayne.co.za (session cse_01X)'
|
|
618
|
+
put "$R" src/app.js 'z' 'Driven-by: zayne@zayne.co.za'
|
|
619
|
+
run_lint "$R" 'main...pr'
|
|
620
|
+
check "driven-by ON: trailered commits are clean (exit 0)" "$RC" "0"
|
|
621
|
+
check "driven-by ON: verdict clean" "$OUT" "lint=clean"
|
|
622
|
+
|
|
623
|
+
# ON: a malformed trailer (no email shape) still fails
|
|
624
|
+
R=$(new_repo db-malformed)
|
|
625
|
+
put "$R" .forge/project.yml "$PROJ_ON"
|
|
626
|
+
pr "$R"
|
|
627
|
+
put "$R" src/app.js 'y' 'Driven-by: somebody'
|
|
628
|
+
run_lint "$R" 'main...pr'
|
|
629
|
+
check "driven-by ON: trailer without an email fails (exit 1)" "$RC" "1"
|
|
630
|
+
check_contains "driven-by ON: malformed finding" "$OUT" "finding=driven-by-missing:"
|
|
631
|
+
|
|
632
|
+
# ON, mixed: one trailered + one bare commit → only the bare one is named
|
|
633
|
+
R=$(new_repo db-mixed)
|
|
634
|
+
put "$R" .forge/project.yml "$PROJ_ON"
|
|
635
|
+
pr "$R"
|
|
636
|
+
put "$R" src/app.js 'y' 'Driven-by: zayne@zayne.co.za'
|
|
637
|
+
put "$R" src/other.js 'w'
|
|
638
|
+
DB_BARE="$(git -C "$R" rev-parse --short pr)"
|
|
639
|
+
DB_GOOD="$(git -C "$R" rev-parse --short pr~1)"
|
|
640
|
+
run_lint "$R" 'main...pr'
|
|
641
|
+
check "driven-by ON mixed: bare commit fails (exit 1)" "$RC" "1"
|
|
642
|
+
check_contains "driven-by ON mixed: bare commit named" "$OUT" "finding=driven-by-missing:$DB_BARE"
|
|
643
|
+
check_absent "driven-by ON mixed: trailered commit not named" "$OUT" "finding=driven-by-missing:$DB_GOOD"
|
|
644
|
+
|
|
645
|
+
# Base-side gate: a PR that deletes the config cannot switch the rule off for itself
|
|
646
|
+
R=$(new_repo db-evade)
|
|
647
|
+
put "$R" .forge/project.yml "$PROJ_ON"
|
|
648
|
+
pr "$R"
|
|
649
|
+
git -C "$R" rm -q .forge/project.yml
|
|
650
|
+
git -C "$R" commit -qm "drop attribution config"
|
|
651
|
+
run_lint "$R" 'main...pr'
|
|
652
|
+
check "driven-by ON: head-side config removal cannot evade (exit 1)" "$RC" "1"
|
|
653
|
+
check_contains "driven-by ON: evasion finding" "$OUT" "finding=driven-by-missing:"
|
|
654
|
+
|
|
655
|
+
# Lint-Justify lifts it (same operator-act channel as the other liftable rules)
|
|
656
|
+
R=$(new_repo db-justify)
|
|
657
|
+
put "$R" .forge/project.yml "$PROJ_ON"
|
|
658
|
+
pr "$R"
|
|
659
|
+
put "$R" src/app.js 'y' 'Lint-Justify: host-authored dependabot commit, attribution n/a'
|
|
660
|
+
run_lint "$R" 'main...pr'
|
|
661
|
+
check "driven-by ON: Lint-Justify lifts (exit 0)" "$RC" "0"
|
|
662
|
+
check_contains "driven-by ON: justified verdict" "$OUT" "lint=justified"
|
|
663
|
+
check_contains "driven-by ON: finding listed as justified" "$OUT" "finding=driven-by-missing:"
|
|
664
|
+
fi
|
|
665
|
+
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
# waiver-boundary — the hold check's run-waiver binding must have ZERO effect
|
|
668
|
+
# here: the waiver applies to the hold check only, never the lint.
|
|
669
|
+
# ---------------------------------------------------------------------------
|
|
670
|
+
if running waiver-boundary; then
|
|
671
|
+
R=$(new_repo wb)
|
|
672
|
+
put "$R" .forge/hold-config.yml 'version: 1
|
|
673
|
+
operators: []
|
|
674
|
+
irreversible_paths: []
|
|
675
|
+
deploy_paths: []
|
|
676
|
+
gating_tests: []
|
|
677
|
+
waiver:
|
|
678
|
+
workflow: waive-hold.yml
|
|
679
|
+
ref: main'
|
|
680
|
+
put "$R" "$WF" 'jobs: {}'
|
|
681
|
+
pr "$R"
|
|
682
|
+
put "$R" "$WF" 'jobs:
|
|
683
|
+
gate:
|
|
684
|
+
steps:
|
|
685
|
+
- run: exit 0'
|
|
686
|
+
run_lint "$R" 'main...pr'
|
|
687
|
+
check "waiver-boundary: hold waiver binding never lifts a lint finding (exit 1)" "$RC" "1"
|
|
688
|
+
check_contains "waiver-boundary: finding intact" "$OUT" "finding=cannot-fail:$WF"
|
|
689
|
+
fi
|
|
690
|
+
|
|
569
691
|
# ---------------------------------------------------------------------------
|
|
570
692
|
# docs-only — acceptance 6: a status on every PR
|
|
571
693
|
# ---------------------------------------------------------------------------
|
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
# suites) (+config deploy_paths, gating_tests)
|
|
26
26
|
# Waiver: any --approvals login ∈ base-side config operators: → waived. Absent
|
|
27
27
|
# config / empty operators → NO waiver possible (raise-only safe default).
|
|
28
|
+
# Run-waiver (hold-waiver patch): OPTIONAL base-side `waiver: {workflow, ref}`
|
|
29
|
+
# binding — when a hold fired and no operator approval waives it, a host-queried
|
|
30
|
+
# run of that workflow waives iff ALL of: workflow path matches; run ref ==
|
|
31
|
+
# repo default branch (== configured ref); conclusion == success; display_title
|
|
32
|
+
# carries the PR head SHA. Output: hold=waived waiver_run=<url>. FAIL-CLOSED on
|
|
33
|
+
# every leg (unconfigured / gh missing / API unreachable / no match / wrong ref
|
|
34
|
+
# / config-ref drift / wrong path / non-success / superseded SHA → hold stands);
|
|
35
|
+
# unconfigured behavior is byte-identical to the pre-waiver check (no gh call).
|
|
36
|
+
# gh is env-overridable (FORGE_HOLD_GH) — fixtures stub it and stay offline.
|
|
28
37
|
#
|
|
29
38
|
# Builds throwaway git repos in mktemp dirs with NO remote configured anywhere —
|
|
30
39
|
# offline by construction (NFR-032); any network dependency in the script breaks here.
|
|
@@ -32,7 +41,7 @@
|
|
|
32
41
|
# Usage:
|
|
33
42
|
# ./.forge/checks/tests/forge-hold-check.test.sh # all cases
|
|
34
43
|
# ./.forge/checks/tests/forge-hold-check.test.sh pain6 # one tag
|
|
35
|
-
# Filter tags: irreversible structural pain6 waiver self-protect docs-only invocation multi
|
|
44
|
+
# Filter tags: irreversible structural pain6 waiver run-waiver self-protect docs-only invocation multi
|
|
36
45
|
#
|
|
37
46
|
# RED-phase friendly (TDD): a missing/non-executable helper does NOT abort the
|
|
38
47
|
# suite — every selected case FAILS with "(helper missing ...)" as the got-value
|
|
@@ -382,6 +391,162 @@ if running multi; then
|
|
|
382
391
|
check_contains "multi: protected named" "$OUT" "fact=protected:.github/workflows/deploy.yml"
|
|
383
392
|
fi
|
|
384
393
|
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
# run-waiver — the Environment-gated waiver run channel: pass-on-match plus a
|
|
396
|
+
# neg-control per fail-closed leg. gh is stubbed offline via FORGE_HOLD_GH.
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
if running run-waiver; then
|
|
399
|
+
GH_STUB="$ROOT/gh-stub"
|
|
400
|
+
cat > "$GH_STUB" <<'STUB'
|
|
401
|
+
#!/bin/sh
|
|
402
|
+
# Offline gh stand-in for the run-waiver channel. Argv shapes the check uses:
|
|
403
|
+
# gh api repos/{owner}/{repo} --jq .default_branch
|
|
404
|
+
# gh api "repos/{owner}/{repo}/actions/workflows/<wf>/runs?..." --jq <projection>
|
|
405
|
+
# Driven by env: STUB_DEFAULT_BRANCH (empty/unset => API failure, exit 1),
|
|
406
|
+
# STUB_RUNS_FILE (TSV: conclusion, head_branch, path, html_url, display_title;
|
|
407
|
+
# unset/missing => API failure), STUB_LOG (append argv per call when set).
|
|
408
|
+
[ -n "${STUB_LOG:-}" ] && printf '%s\n' "$*" >> "$STUB_LOG"
|
|
409
|
+
case "${2:-}" in
|
|
410
|
+
'repos/{owner}/{repo}')
|
|
411
|
+
[ -n "${STUB_DEFAULT_BRANCH:-}" ] || exit 1
|
|
412
|
+
printf '%s\n' "$STUB_DEFAULT_BRANCH" ;;
|
|
413
|
+
'repos/{owner}/{repo}/actions/workflows/'*)
|
|
414
|
+
[ -n "${STUB_RUNS_FILE:-}" ] && [ -f "$STUB_RUNS_FILE" ] || exit 1
|
|
415
|
+
cat "$STUB_RUNS_FILE" ;;
|
|
416
|
+
*) exit 1 ;;
|
|
417
|
+
esac
|
|
418
|
+
STUB
|
|
419
|
+
chmod +x "$GH_STUB"
|
|
420
|
+
|
|
421
|
+
CONFIG_WAIVER='version: 1
|
|
422
|
+
operators: []
|
|
423
|
+
irreversible_paths: []
|
|
424
|
+
deploy_paths: []
|
|
425
|
+
gating_tests: []
|
|
426
|
+
waiver:
|
|
427
|
+
workflow: waive-hold.yml
|
|
428
|
+
ref: main'
|
|
429
|
+
|
|
430
|
+
WURL='https://host.example/actions/runs/101'
|
|
431
|
+
WPATH='.github/workflows/waive-hold.yml'
|
|
432
|
+
|
|
433
|
+
new_waiver_repo() { # name, config → repo whose pr branch carries a migration (hold fires)
|
|
434
|
+
nw_d=$(new_repo "$1")
|
|
435
|
+
put "$nw_d" .forge/hold-config.yml "$2"
|
|
436
|
+
pr "$nw_d"
|
|
437
|
+
put "$nw_d" app/migrations/010_waiver.py 'alter table'
|
|
438
|
+
printf '%s\n' "$nw_d"
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
# pass-on-match: approved success run on the default branch, title carries head SHA
|
|
442
|
+
R=$(new_waiver_repo rw-pass "$CONFIG_WAIVER")
|
|
443
|
+
SHA=$(git -C "$R" rev-parse pr)
|
|
444
|
+
printf 'success\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.pass"
|
|
445
|
+
export FORGE_HOLD_GH="$GH_STUB" STUB_DEFAULT_BRANCH=main STUB_RUNS_FILE="$ROOT/runs.pass"
|
|
446
|
+
run_hold "$R" 'main...pr'
|
|
447
|
+
check "run-waiver: approved matching run waives (exit 0)" "$RC" "0"
|
|
448
|
+
check_contains "run-waiver: verdict annotates the run URL" "$OUT" "hold=waived waiver_run=$WURL"
|
|
449
|
+
check_contains "run-waiver: lists what it waived" "$OUT" "fact=irreversible:app/migrations/010_waiver.py waived"
|
|
450
|
+
|
|
451
|
+
# neg fail-closed leg: UNCONFIGURED — no waiver block: no gh call, and the
|
|
452
|
+
# blocked output is byte-identical to a run with no stub env at all.
|
|
453
|
+
R=$(new_waiver_repo rw-unconf "$CONFIG_OPS")
|
|
454
|
+
export STUB_LOG="$ROOT/gh.unconf.log"
|
|
455
|
+
: > "$STUB_LOG"
|
|
456
|
+
run_hold "$R" 'main...pr'
|
|
457
|
+
UNCONF_OUT="$OUT"
|
|
458
|
+
check "run-waiver neg (unconfigured): hold stands (exit 1)" "$RC" "1"
|
|
459
|
+
check "run-waiver neg (unconfigured): gh never invoked" "$(cat "$STUB_LOG")" ""
|
|
460
|
+
unset FORGE_HOLD_GH STUB_DEFAULT_BRANCH STUB_RUNS_FILE STUB_LOG
|
|
461
|
+
run_hold "$R" 'main...pr'
|
|
462
|
+
check "run-waiver neg (unconfigured): output byte-identical to pre-waiver check" "$OUT" "$UNCONF_OUT"
|
|
463
|
+
|
|
464
|
+
# neg fail-closed leg: gh MISSING (no host CLI at all)
|
|
465
|
+
R=$(new_waiver_repo rw-nogh "$CONFIG_WAIVER")
|
|
466
|
+
export FORGE_HOLD_GH="$ROOT/definitely-no-such-gh"
|
|
467
|
+
run_hold "$R" 'main...pr'
|
|
468
|
+
check "run-waiver neg (gh missing): hold stands (exit 1)" "$RC" "1"
|
|
469
|
+
check_contains "run-waiver neg (gh missing): blocked verdict" "$OUT" "hold=blocked"
|
|
470
|
+
|
|
471
|
+
# neg fail-closed leg: API UNREACHABLE (default-branch query fails)
|
|
472
|
+
export FORGE_HOLD_GH="$GH_STUB" STUB_DEFAULT_BRANCH="" STUB_RUNS_FILE="$ROOT/runs.pass"
|
|
473
|
+
run_hold "$R" 'main...pr'
|
|
474
|
+
check "run-waiver neg (API unreachable): hold stands (exit 1)" "$RC" "1"
|
|
475
|
+
|
|
476
|
+
# neg fail-closed leg: NO MATCHING RUN (API reachable, zero runs)
|
|
477
|
+
: > "$ROOT/runs.empty"
|
|
478
|
+
export STUB_DEFAULT_BRANCH=main STUB_RUNS_FILE="$ROOT/runs.empty"
|
|
479
|
+
run_hold "$R" 'main...pr'
|
|
480
|
+
check "run-waiver neg (no matching run): hold stands (exit 1)" "$RC" "1"
|
|
481
|
+
|
|
482
|
+
# neg fail-closed leg: WRONG REF — the only success run rode a PR-branch copy
|
|
483
|
+
# of the workflow (gate strippable), not the default branch.
|
|
484
|
+
SHA=$(git -C "$R" rev-parse pr)
|
|
485
|
+
printf 'success\tpr\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.wrongref"
|
|
486
|
+
export STUB_RUNS_FILE="$ROOT/runs.wrongref"
|
|
487
|
+
run_hold "$R" 'main...pr'
|
|
488
|
+
check "run-waiver neg (wrong ref): PR-branch run cannot waive (exit 1)" "$RC" "1"
|
|
489
|
+
|
|
490
|
+
# neg fail-closed leg: CONFIG-REF DRIFT — configured ref != repo default branch
|
|
491
|
+
R=$(new_waiver_repo rw-drift 'version: 1
|
|
492
|
+
operators: []
|
|
493
|
+
irreversible_paths: []
|
|
494
|
+
deploy_paths: []
|
|
495
|
+
gating_tests: []
|
|
496
|
+
waiver:
|
|
497
|
+
workflow: waive-hold.yml
|
|
498
|
+
ref: develop')
|
|
499
|
+
SHA=$(git -C "$R" rev-parse pr)
|
|
500
|
+
printf 'success\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.drift"
|
|
501
|
+
export STUB_RUNS_FILE="$ROOT/runs.drift"
|
|
502
|
+
run_hold "$R" 'main...pr'
|
|
503
|
+
check "run-waiver neg (config-ref drift): waiver.ref != default branch (exit 1)" "$RC" "1"
|
|
504
|
+
|
|
505
|
+
# neg fail-closed leg: SUPERSEDED SHA — a new push after the approved run;
|
|
506
|
+
# the run's title carries the OLD head SHA, so it no longer matches.
|
|
507
|
+
R=$(new_waiver_repo rw-stale "$CONFIG_WAIVER")
|
|
508
|
+
OLD_SHA=$(git -C "$R" rev-parse pr)
|
|
509
|
+
printf 'success\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$OLD_SHA" > "$ROOT/runs.stale"
|
|
510
|
+
put "$R" src/followup.js 'pushed after the waiver run'
|
|
511
|
+
export STUB_RUNS_FILE="$ROOT/runs.stale"
|
|
512
|
+
run_hold "$R" 'main...pr'
|
|
513
|
+
check "run-waiver neg (superseded SHA): new push invalidates the waiver (exit 1)" "$RC" "1"
|
|
514
|
+
check_contains "run-waiver neg (superseded SHA): blocked verdict" "$OUT" "hold=blocked"
|
|
515
|
+
|
|
516
|
+
# neg fail-closed leg: WRONG WORKFLOW PATH — a same-shaped run of another workflow
|
|
517
|
+
R=$(new_waiver_repo rw-path "$CONFIG_WAIVER")
|
|
518
|
+
SHA=$(git -C "$R" rev-parse pr)
|
|
519
|
+
printf 'success\tmain\t.github/workflows/other.yml\t%s\twaive-hold sha=%s\n' "$WURL" "$SHA" > "$ROOT/runs.path"
|
|
520
|
+
export STUB_RUNS_FILE="$ROOT/runs.path"
|
|
521
|
+
run_hold "$R" 'main...pr'
|
|
522
|
+
check "run-waiver neg (wrong workflow path): hold stands (exit 1)" "$RC" "1"
|
|
523
|
+
|
|
524
|
+
# neg fail-closed leg: NON-SUCCESS CONCLUSION — pending/rejected run never waives
|
|
525
|
+
printf 'failure\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.fail"
|
|
526
|
+
export STUB_RUNS_FILE="$ROOT/runs.fail"
|
|
527
|
+
run_hold "$R" 'main...pr'
|
|
528
|
+
check "run-waiver neg (non-success run): hold stands (exit 1)" "$RC" "1"
|
|
529
|
+
|
|
530
|
+
# channel precedence: an operator PR approval still waives on its own — the
|
|
531
|
+
# run-waiver channel is additive, not a replacement.
|
|
532
|
+
R=$(new_waiver_repo rw-op 'version: 1
|
|
533
|
+
operators:
|
|
534
|
+
- zayne
|
|
535
|
+
irreversible_paths: []
|
|
536
|
+
deploy_paths: []
|
|
537
|
+
gating_tests: []
|
|
538
|
+
waiver:
|
|
539
|
+
workflow: waive-hold.yml
|
|
540
|
+
ref: main')
|
|
541
|
+
AF=$(approvals rwop zayne)
|
|
542
|
+
export STUB_DEFAULT_BRANCH="" STUB_RUNS_FILE=""
|
|
543
|
+
run_hold "$R" 'main...pr' --approvals "$AF"
|
|
544
|
+
check "run-waiver: operator approval still waives independently (exit 0)" "$RC" "0"
|
|
545
|
+
check_contains "run-waiver: operator verdict unchanged" "$OUT" "hold=waived waived_by=zayne"
|
|
546
|
+
|
|
547
|
+
unset FORGE_HOLD_GH STUB_DEFAULT_BRANCH STUB_RUNS_FILE STUB_LOG
|
|
548
|
+
fi
|
|
549
|
+
|
|
385
550
|
# ---------------------------------------------------------------------------
|
|
386
551
|
# invocation — exit 2 on bad usage, never a false verdict
|
|
387
552
|
# ---------------------------------------------------------------------------
|