forge-orkes 0.56.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.
@@ -709,6 +709,12 @@ async function upgrade() {
709
709
  // merged separately by upgradeSettings().
710
710
  upgradeAdditiveDir('.claude/hooks', results);
711
711
  upgradeAdditiveDir('.claude/rules', results);
712
+ // 2-checks. Sync the merge-floor CI check scripts + fixture suites (0.58.2).
713
+ // Tracked in installed projects (unlike .claude/hooks/, which many projects
714
+ // gitignore as per-developer files) so CI can run them and the hold check's
715
+ // protected set (.forge/checks/**) can guard them. Additive for the same
716
+ // reason as above: never delete a project-local extra check.
717
+ upgradeAdditiveDir('.forge/checks', results);
712
718
 
713
719
  // 2a. Confirm stale framework-file removals before deleting (R066a). Defaults
714
720
  // to yes (cleanup is the normal case); auto-confirms when non-interactive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.56.0",
3
+ "version": "0.58.2",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "forge": {
3
- "version": "0.1.0",
3
+ "version": "0.58.2",
4
4
  "default_tier": "standard",
5
5
  "beads_integration": false,
6
6
  "context_gates": {
@@ -40,6 +40,7 @@ Read the source version (`{source}/packages/create-forge/package.json` `version`
40
40
  | **Import-managed** | `CLAUDE.md` | Never overwrite content; migrate legacy forge sections + guarantee the `@.forge/FORGE.md` import line (Step 5) |
41
41
  | **Merge-owned** | `.claude/settings.json` | Smart-merge `forge.*` keys only (Step 5) |
42
42
  | **Template-only** | `.forge/templates/**`, `.forge/migrations/**`, `.forge/gitignore` → `.forge/.gitignore` | Overwrite |
43
+ | **Additive-sync** | `.claude/hooks/**` (incl. `tests/`), `.claude/rules/**`, `.forge/checks/**` (incl. `tests/`) | Add + overwrite template-shipped files; never delete local extras (Step 4a) |
43
44
 
44
45
  **Never touch** user-generated files: `.forge/project.yml`, `.forge/state/`, `.forge/constitution.md`, `.forge/context.md`, `.forge/requirements/`, `.forge/roadmap.yml`, `.forge/design-system.md`, `.forge/refactor-backlog.yml`.
45
46
 
@@ -87,6 +88,22 @@ Same process as Step 3 for `.forge/templates/**`, `.forge/migrations/**`, and `.
87
88
 
88
89
  **Sync the framework prose file `.forge/FORGE.md`** (framework-owned single file — it holds the `# Forge` prose, formerly embedded in CLAUDE.md). Copy/refresh `{source}/packages/create-forge/template/.forge/FORGE.md` → `.forge/FORGE.md` (overwrite when different; report added/updated/unchanged). **Run this BEFORE the Step 5 CLAUDE.md pass** so a migrated import line never points at a missing file.
89
90
 
91
+ ## Step 4a: Sync Hooks + Rules + Checks (additive — mirrors the npm bin)
92
+
93
+ The template ships the safety-guardrail hook scripts (ADR-017), the always-on rules layer (ADR-018), and — since 0.58.2 — the merge-floor CI check scripts: `.claude/hooks/**` — **including the `tests/` subtree** — `.claude/rules/**`, and `.forge/checks/**` (incl. `tests/`). The npm bin has additively synced hooks + rules since those ADRs landed (`upgradeAdditiveDir`); this skill previously synced neither, so a project upgraded only via `/upgrading` never received `.claude/hooks/` at all — while its settings.json wires guards like `protect-files.sh` and skills invoke `forge-release-fold.sh`, none of which existed on disk.
94
+
95
+ For every file under `{source}/packages/create-forge/template/.claude/hooks/`, `.../.claude/rules/`, and `.../.forge/checks/` (recursive):
96
+
97
+ 1. Absent locally → copy, report **added**.
98
+ 2. Present but differs → overwrite, report **updated**.
99
+ 3. Identical → report **unchanged**.
100
+ 4. **Additive only** — never delete a local file the template lacks. Project-local and experimental hooks (e.g. M10 `forge-claim-check*.sh`) and pack-installed path-scoped rules are preserved; no "removed from template" verdicts in this pass.
101
+ 5. `chmod +x` every synced `*.sh` (a copy can strip the execute bit; guard hooks and CI checks must be runnable).
102
+
103
+ The two subtrees differ in tracked-ness, and the sync respects it: `.forge/checks/**` is **committed** project content (CI must clone it; the hold check's protected set guards `.forge/checks/**`), while `.claude/hooks/**` is a **machine-local carve-out** in many projects (gitignored as per-developer files — host-verified on ptnrkit). Sync both to disk identically; stage only what the project's own `.gitignore` admits — never `git add -f` a hook file past a project's ignore rules. The old `.claude/hooks/` copies of `forge-hold-check.sh` / `forge-check-lint.sh` (+ their two `tests/*.test.sh` suites) are relocation leftovers on upgraded projects — the migration guide `0.58.2-merge-floor-checks-relocation.md` covers removing them and re-pointing CI.
104
+
105
+ The settings.json *wiring* for these hooks is not written here — Step 5's smart-merge owns it.
106
+
90
107
  ## Step 4b: Codex Adapter (presence-gated — detect + offer, never auto)
91
108
 
92
109
  Forge ships a **Codex/AGENTS adapter** (`.agents/skills/` + `.codex/` wiring) for projects run on Codex. It is a *derived translation* of the canonical `.claude/` skills (ADR-014) and drifts whenever core moves — but `upgrading` syncs only `.claude/`, so the Codex adapter would silently rot. This step makes that staleness visible and one-keystroke-fixable, **without** ever generating it here (only an agent-as-Codex can author it natively).
@@ -120,6 +137,7 @@ No manual-merge prompt for forge sections; migration is silent except the report
120
137
  2. Compare `forge.*` keys only
121
138
  3. If different → update `forge.*` keys in local, preserve user's `hooks` and custom keys
122
139
  4. Set `forge.version` to match the source package version
140
+ 5. **Additively merge the template's framework hook wiring** (ADR-017, mirrors the bin's `upgradeSettingsHooks`): for each hook group in the template's `hooks`, add it to the local `hooks` unless a deep-equal group already exists. Never remove or rewrite a user's own hook entries. Without this, the scripts synced in Step 4a exist on disk but never fire on projects whose settings.json predates the wiring.
123
141
 
124
142
  ## Step 6: Report
125
143
 
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env sh
2
+ # forge-check-lint — the merge-floor check-quality lint (m-31, Brief-R2 step 2 Track A).
3
+ #
4
+ # M23 promoted to the merge floor: a check that cannot fail manufactures fake
5
+ # evidence — worse than no check. This lints the PR diff's CI/check/test
6
+ # definitions with the planning skill's heuristics ported to an executable
7
+ # (a SECOND CONSUMER of the same rules — the planning-time prose stays where it
8
+ # is). P16's teeth. HONEST LIMIT: the lint is line/unit-heuristic over YAML and
9
+ # diffs, not a parser — what it can't catch, your UAT stands behind.
10
+ #
11
+ # forge-check-lint.sh <base>...<head>
12
+ #
13
+ # stdout (machine-parseable):
14
+ # lint=clean
15
+ # lint=fail + finding=<rule>:<file>[:<line>]
16
+ # lint=justified justify="<trailer line>" + finding=<rule>:<file>[:<line>] justified
17
+ # rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker
18
+ # exit: 0 = clean or justified; 1 = fail; 2 = bad invocation. Logs → stderr.
19
+ #
20
+ # Targets (nothing else is scanned):
21
+ # CI definitions (.github/workflows/*.yml|yaml, .gitlab-ci.yml) — check UNITS
22
+ # (a `run:` scalar, a `run: |` block, a `script:` list) parsed from the HEAD
23
+ # side of each changed file, REPORTED only when the unit's lines intersect
24
+ # the diff's added lines (lint the diff, not the world).
25
+ # Test files (*_test.*, *.test.*, test_*.*, *.spec.*) — added/removed diff lines.
26
+ #
27
+ # Rules:
28
+ # (a) cannot-fail — every command in the unit is `true`, `:`, `exit 0`, or an
29
+ # echo/printf-only pipeline. ALWAYS a failure — the
30
+ # Lint-Justify trailer NEVER lifts it (M23 rule a verbatim).
31
+ # (c) existence-only— every command is a bare file-existence test (test -f/-e/-d).
32
+ # (b) silent-fail — every command is silent conjuncts (grep -q / diff -q /
33
+ # test / [) with no `||` diagnostic branch. Checked after (c).
34
+ # deleted-assertion — NET loss of assertion-bearing lines in a gating test
35
+ # (base-side hold-config `gating_tests:` globs when present,
36
+ # else EVERY test file in the diff — raise-only default).
37
+ # Net-count, so a moved/edited line that keeps its assertion
38
+ # does not fire.
39
+ # feature-removal — a deleted-assertion whose test correlates with a same-stem
40
+ # source FILE deletion in the same diff. Reclassified from
41
+ # test-gaming to "feature removal — confirm intended". A
42
+ # CLASSIFIER, not an allowlist: the finding still fires and
43
+ # still requires the recorded operator act (Lint-Justify / PR
44
+ # approval) — only the label changes, so the operator confirms
45
+ # an intended removal instead of being warned of gaming.
46
+ # Bright line (raise-only): only a WHOLE matching-stem source
47
+ # *file* deletion downgrades the label; a partial source
48
+ # shrink is ambiguous and keeps the stricter deleted-assertion.
49
+ # skip-marker — an ADDED .only( / .skip( / it.skip / describe.only / xit( /
50
+ # xdescribe( / @pytest.mark.skip / t.Skip() line.
51
+ #
52
+ # Justification channel (pure git, host-agnostic): a commit-message trailer
53
+ # Lint-Justify: <one line>
54
+ # in ANY commit of merge-base..head lifts (b)/(c)/deleted-assertion/feature-removal/
55
+ # skip-marker → exit 0 `lint=justified`. It never lifts (a).
56
+ #
57
+ # Host-agnostic + offline (NFR-032): POSIX sh over git plumbing only.
58
+ set -u
59
+
60
+ usage() { printf 'usage: forge-check-lint.sh <base>...<head>\n' >&2; }
61
+
62
+ RANGE="${1:-}"
63
+ [ $# -le 1 ] || { printf 'forge-check-lint: unexpected arguments\n' >&2; usage; exit 2; }
64
+ [ -n "$RANGE" ] || { usage; exit 2; }
65
+ case "$RANGE" in
66
+ *...*) : ;;
67
+ *) printf 'forge-check-lint: range must be three-dot BASE...HEAD (got: %s)\n' "$RANGE" >&2; exit 2 ;;
68
+ esac
69
+ BASE_REF="${RANGE%%...*}"
70
+ HEAD_REF="${RANGE#*...}"
71
+ [ -n "$BASE_REF" ] && [ -n "$HEAD_REF" ] || { usage; exit 2; }
72
+
73
+ git rev-parse --verify --quiet "$BASE_REF^{commit}" >/dev/null 2>&1 \
74
+ || { printf 'forge-check-lint: cannot resolve base ref: %s\n' "$BASE_REF" >&2; exit 2; }
75
+ git rev-parse --verify --quiet "$HEAD_REF^{commit}" >/dev/null 2>&1 \
76
+ || { printf 'forge-check-lint: cannot resolve head ref: %s\n' "$HEAD_REF" >&2; exit 2; }
77
+ MB="$(git merge-base "$BASE_REF" "$HEAD_REF" 2>/dev/null)" \
78
+ || { printf 'forge-check-lint: no merge-base for %s\n' "$RANGE" >&2; exit 2; }
79
+
80
+ # Justification trailer (first one wins), from commit messages in the range.
81
+ # Strip any embedded double-quote: it is echoed inside justify="..." on stdout,
82
+ # and an unescaped " from an attacker-authored commit would produce malformed,
83
+ # machine-parseable output a downstream CI parser could mis-read.
84
+ JUSTIFY="$(git log --format=%B "$MB..$HEAD_REF" 2>/dev/null \
85
+ | sed -n 's/^Lint-Justify:[[:space:]]*//p' | head -1 | tr '"' "'")"
86
+
87
+ # Base-side gating_tests globs (same additive config the hold check reads).
88
+ GATING="$(git show "$MB:.forge/hold-config.yml" 2>/dev/null \
89
+ | awk '
90
+ /^[A-Za-z_]+:/ { insec = (index($0, "gating_tests:") == 1) ? 1 : 0; next }
91
+ insec && /^[[:space:]]*-[[:space:]]*/ {
92
+ v = $0
93
+ sub(/^[[:space:]]*-[[:space:]]*/, "", v)
94
+ gsub(/["\047]/, "", v)
95
+ sub(/[[:space:]]*(#.*)?$/, "", v)
96
+ if (v != "") print v
97
+ }')"
98
+
99
+ in_gating() { # path → 0 when gating set is empty (default: everything gates) or a glob matches
100
+ [ -n "$GATING" ] || return 0
101
+ ig_path="$1"
102
+ while IFS= read -r ig_g; do
103
+ [ -n "$ig_g" ] || continue
104
+ case "$ig_path" in
105
+ $ig_g) return 0 ;;
106
+ esac
107
+ done <<EOF
108
+ $GATING
109
+ EOF
110
+ return 1
111
+ }
112
+
113
+ added_lines() { # file → new-side line numbers added by the diff, one per line
114
+ git diff --unified=0 "$MB" "$HEAD_REF" -- "$1" 2>/dev/null | awk '
115
+ /^@@/ {
116
+ plus = $3
117
+ sub(/^\+/, "", plus)
118
+ n = split(plus, a, ",")
119
+ start = a[1] + 0
120
+ count = (n > 1) ? a[2] + 0 : 1
121
+ for (i = 0; i < count; i++) print start + i
122
+ }'
123
+ }
124
+
125
+ # Test-file basename → feature stem (drops the test marker + extension). Used to
126
+ # correlate a shrinking test with its source going away.
127
+ stem_of_test() { # basename
128
+ awk -v b="$1" 'BEGIN {
129
+ if (b ~ /\.(test|spec)\.[^.]+$/) sub(/\.(test|spec)\.[^.]+$/, "", b)
130
+ else if (b ~ /_test\.[^.]+$/) sub(/_test\.[^.]+$/, "", b)
131
+ else if (b ~ /^test_/) { sub(/^test_/, "", b); sub(/\.[^.]+$/, "", b) }
132
+ else sub(/\.[^.]+$/, "", b)
133
+ print b
134
+ }'
135
+ }
136
+
137
+ # Correlated source deletion? True when the diff DELETES a whole non-test, non-CI
138
+ # file whose basename stem matches the test's — the signal that a shrinking test
139
+ # reflects a feature being removed, not a gating test being gamed. Whole-file
140
+ # deletion is the deliberate bright line (raise-only): a partial source shrink is
141
+ # ambiguous and keeps the stricter deleted-assertion label. Classifier, not
142
+ # allowlist — the caller still emits a finding either way.
143
+ has_correlated_source_deletion() { # stem (non-empty)
144
+ git diff --diff-filter=D --name-only "$MB" "$HEAD_REF" 2>/dev/null | awk -v stem="$1" '
145
+ function base(p, b) { b = p; sub(/^.*\//, "", b); return b }
146
+ function is_test(b) { return (b ~ /_test\./ || b ~ /\.test\./ || b ~ /\.spec\./ || b ~ /^test_/) }
147
+ function is_ci(p) { return (p ~ /^\.github\/workflows\/.*\.(yml|yaml)$/ || p == ".gitlab-ci.yml") }
148
+ function srcstem(b, s) { s = b; sub(/\.[^.]+$/, "", s); return s }
149
+ { b = base($0); if (is_test(b) || is_ci($0)) next; if (srcstem(b) == stem) { found = 1; exit } }
150
+ END { exit(found ? 0 : 1) }
151
+ '
152
+ }
153
+
154
+ # Parse the HEAD side of a CI definition file into check units and classify each.
155
+ # Emits: <class> <startline> <endline> per unit (class: cannot-fail|existence-only|silent-fail|clean).
156
+ classify_units() { # file
157
+ git show "$HEAD_REF:$1" 2>/dev/null | awk '
158
+ function indent_of(s, t) { t = s; sub(/[^ ].*$/, "", t); return length(t) }
159
+ function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]*$/, "", s); return s }
160
+ # Drop a trailing shell comment before classification so a cosmetic "# note"
161
+ # cannot slip a cannot-fail command past rule (a) (raise-only: the evasion
162
+ # must not make a fact disappear). Only strips a # preceded by whitespace or
163
+ # at line start — never a # mid-token (e.g. a URL fragment). Safe for echo/
164
+ # printf units too: the classifier keys on the command PREFIX, which survives.
165
+ function strip_comment(s) { sub(/[ \t]#.*$/, "", s); sub(/^#.*$/, "", s); return s }
166
+ function strip_quotes(s) {
167
+ if (s ~ /^".*"$/ || s ~ /^\047.*\047$/) s = substr(s, 2, length(s) - 2)
168
+ return s
169
+ }
170
+ # --- per-command predicates (heuristic, documented in the header) ---------
171
+ function seg_split(cmd, segs) { return split(cmd, segs, /&&|\|\||;|\|/) }
172
+ # cannot-fail = EVERY non-empty segment is one of the enumerated trivial
173
+ # commands {true, :, exit 0} or echo/printf-only. Per-segment (not a
174
+ # whole-string exact match) so a trailing separator ("exit 0 ;") or a
175
+ # multi-trivial form ("true || true") cannot evade the enumerated set. Scope
176
+ # is the ratified set ONLY — a real command guarded by "|| true" is NOT
177
+ # rule (a) (its non-trivial segment fails the test), matching the contract.
178
+ function is_cannot(cmd, segs, n, i, s, any) {
179
+ cmd = trim(strip_comment(trim(cmd)))
180
+ n = seg_split(cmd, segs); any = 0
181
+ for (i = 1; i <= n; i++) {
182
+ s = trim(segs[i]); if (s == "") continue
183
+ any = 1
184
+ # Whitespace/zero-tolerant so "exit 0" (doubled space/tab) and "exit 00"
185
+ # cannot evade — both always exit 0, so both ARE rule (a).
186
+ if (s == "true" || s == ":") continue
187
+ if (s ~ /^exit[ \t]+0+$/) continue
188
+ if (s ~ /^(echo|printf)([ \t]|$)/) continue
189
+ return 0
190
+ }
191
+ return any
192
+ }
193
+ function is_exist(cmd, segs, n, i, s, any) {
194
+ cmd = trim(cmd)
195
+ if (cmd ~ /\|\|/) return 0
196
+ n = seg_split(cmd, segs); any = 0
197
+ for (i = 1; i <= n; i++) {
198
+ s = trim(segs[i]); if (s == "") continue
199
+ any = 1
200
+ if (s !~ /^(test|\[\[?)[ \t]+-[efd][ \t]/) return 0
201
+ }
202
+ return any
203
+ }
204
+ function is_silent(cmd, segs, n, i, s, any, sig) {
205
+ cmd = trim(cmd)
206
+ if (cmd ~ /\|\|/) return 0
207
+ n = seg_split(cmd, segs); any = 0; sig = 0
208
+ for (i = 1; i <= n; i++) {
209
+ s = trim(segs[i]); if (s == "") continue
210
+ any = 1
211
+ if (s ~ /^(grep[ \t]+(-[A-Za-z]+[ \t]+)*-q|diff[ \t]+(-[A-Za-z]+[ \t]+)*-q)/) { sig = 1; continue }
212
+ if (s ~ /^(test|\[\[?)[ \t]/) continue
213
+ return 0
214
+ }
215
+ return any && sig
216
+ }
217
+ # --- unit lifecycle -------------------------------------------------------
218
+ function flush( i, c, all_cannot, all_exist, all_silent, cls) {
219
+ if (ucount == 0) { inunit = 0; return }
220
+ all_cannot = 1; all_exist = 1; all_silent = 1
221
+ for (i = 1; i <= ucount; i++) {
222
+ c = ucmd[i]
223
+ if (!is_cannot(c)) all_cannot = 0
224
+ if (!is_exist(c)) all_exist = 0
225
+ if (!is_silent(c) && !is_exist(c)) all_silent = 0
226
+ }
227
+ if (all_cannot) cls = "cannot-fail"
228
+ else if (all_exist) cls = "existence-only"
229
+ else if (all_silent) cls = "silent-fail"
230
+ else cls = "clean"
231
+ print cls, ustart, uend
232
+ ucount = 0; inunit = 0
233
+ }
234
+ BEGIN { inunit = 0; ucount = 0; mode = ""; uind = 0 }
235
+ {
236
+ line = $0
237
+ ind = indent_of(line)
238
+ t = trim(line)
239
+
240
+ if (inunit) {
241
+ if (mode == "block") {
242
+ if (t == "" || ind > uind) {
243
+ if (t != "") { ucmd[++ucount] = strip_quotes(t); uend = NR }
244
+ next
245
+ }
246
+ flush()
247
+ } else if (mode == "list") {
248
+ if (t == "" || (ind > uind && t ~ /^-[ \t]/)) {
249
+ if (t != "") { sub(/^-[ \t]+/, "", t); ucmd[++ucount] = strip_quotes(t); uend = NR }
250
+ next
251
+ }
252
+ flush()
253
+ }
254
+ }
255
+
256
+ # run: scalar or block start (optional leading "- ")
257
+ if (match(t, /^(-[ \t]+)?run:[ \t]*/)) {
258
+ rest = substr(t, RSTART + RLENGTH)
259
+ rest = trim(rest)
260
+ # Block scalar header: |, >, and their chomping/explicit-indent forms
261
+ # (|-, |+, |2, >2-, ...). An explicit-indent header like "|2" must open a
262
+ # block, not be read as a bogus inline command (which would flush clean
263
+ # and skip the real body — a cannot-fail body could then slip through).
264
+ if (rest == "" || rest ~ /^[|>][0-9]*[+-]?$/) {
265
+ inunit = 1; mode = "block"; uind = ind; ustart = NR; uend = NR; ucount = 0
266
+ } else {
267
+ inunit = 1; mode = ""; ustart = NR; uend = NR; ucount = 0
268
+ ucmd[++ucount] = strip_quotes(rest)
269
+ flush()
270
+ }
271
+ next
272
+ }
273
+ # script: list start (gitlab; also before_script/after_script)
274
+ if (t ~ /^(before_|after_)?script:[ \t]*$/) {
275
+ inunit = 1; mode = "list"; uind = ind; ustart = NR; uend = NR; ucount = 0
276
+ next
277
+ }
278
+ }
279
+ END { if (inunit) flush() }'
280
+ }
281
+
282
+ FINDINGS=""
283
+ HAS_A=0
284
+ add_finding() { # rule, file, line("" ok)
285
+ if [ -n "$3" ]; then
286
+ FINDINGS="${FINDINGS}${1}:${2}:${3}
287
+ "
288
+ else
289
+ FINDINGS="${FINDINGS}${1}:${2}
290
+ "
291
+ fi
292
+ [ "$1" = "cannot-fail" ] && HAS_A=1
293
+ }
294
+
295
+ CHANGED="$(git diff --name-only "$MB" "$HEAD_REF" 2>/dev/null)" \
296
+ || { printf 'forge-check-lint: git diff failed for %s\n' "$RANGE" >&2; exit 2; }
297
+
298
+ while IFS= read -r path; do
299
+ [ -n "$path" ] || continue
300
+ base_name="$(basename "$path")"
301
+
302
+ # --- CI definition files: unit-classify HEAD side, report diff-touched units
303
+ case "$path" in
304
+ .github/workflows/*.yml|.github/workflows/*.yaml|.gitlab-ci.yml)
305
+ ADDED="$(added_lines "$path")"
306
+ [ -n "$ADDED" ] || continue
307
+ classify_units "$path" | while IFS=' ' read -r cls ustart uend; do
308
+ [ "$cls" != "clean" ] || continue
309
+ hit=""
310
+ while IFS= read -r ln; do
311
+ [ -n "$ln" ] || continue
312
+ if [ "$ln" -ge "$ustart" ] && [ "$ln" -le "$uend" ]; then hit=1; break; fi
313
+ done <<EOF2
314
+ $ADDED
315
+ EOF2
316
+ [ -n "$hit" ] && printf '%s %s %s\n' "$cls" "$path" "$ustart"
317
+ done > "${TMPDIR:-/tmp}/fcl_units.$$" || true
318
+ while IFS=' ' read -r cls p ln; do
319
+ [ -n "$cls" ] && add_finding "$cls" "$p" "$ln"
320
+ done < "${TMPDIR:-/tmp}/fcl_units.$$"
321
+ rm -f "${TMPDIR:-/tmp}/fcl_units.$$"
322
+ continue
323
+ ;;
324
+ esac
325
+
326
+ # --- test files: skip-markers on added lines; NET assertion loss on gating tests
327
+ case "$base_name" in
328
+ *_test.*|*.test.*|test_*.*|*.spec.*)
329
+ DIFF="$(git diff --unified=0 "$MB" "$HEAD_REF" -- "$path" 2>/dev/null)"
330
+ [ -n "$DIFF" ] || continue
331
+
332
+ added_body="$(printf '%s\n' "$DIFF" | grep '^+' | grep -v '^+++' | sed 's/^+//')"
333
+ removed_body="$(printf '%s\n' "$DIFF" | grep '^-' | grep -v '^---' | sed 's/^-//')"
334
+
335
+ if printf '%s\n' "$added_body" \
336
+ | grep -qE '\.only\(|\.skip\(|it\.skip|describe\.only|xit\(|xdescribe\(|@pytest\.mark\.skip|t\.Skip\(\)'; then
337
+ add_finding skip-marker "$path" ""
338
+ fi
339
+
340
+ if in_gating "$path"; then
341
+ removed_asserts=$(printf '%s\n' "$removed_body" \
342
+ | grep -cE 'assert|expect\(|\.should|\.to(Be|Equal)|t\.(Error|Fatal)' || true)
343
+ added_asserts=$(printf '%s\n' "$added_body" \
344
+ | grep -cE 'assert|expect\(|\.should|\.to(Be|Equal)|t\.(Error|Fatal)' || true)
345
+ if [ "$removed_asserts" -gt "$added_asserts" ]; then
346
+ hcs_stem="$(stem_of_test "$base_name")"
347
+ if [ -n "$hcs_stem" ] && has_correlated_source_deletion "$hcs_stem"; then
348
+ printf 'forge-check-lint: %s: assertion loss correlates with deletion of same-stem source (%s) — classified as feature removal, confirm intended (NOT test-gaming); the operator act is still required\n' "$path" "$hcs_stem" >&2
349
+ add_finding feature-removal "$path" ""
350
+ else
351
+ add_finding deleted-assertion "$path" ""
352
+ fi
353
+ fi
354
+ fi
355
+ ;;
356
+ esac
357
+ done <<EOF
358
+ $CHANGED
359
+ EOF
360
+
361
+ FINDINGS="$(printf '%s' "$FINDINGS" | sort -u)"
362
+
363
+ if [ -z "$FINDINGS" ]; then
364
+ printf 'lint=clean\n'
365
+ exit 0
366
+ fi
367
+
368
+ if [ "$HAS_A" -eq 0 ] && [ -n "$JUSTIFY" ]; then
369
+ printf 'lint=justified justify="%s"\n' "$JUSTIFY"
370
+ printf '%s\n' "$FINDINGS" | while IFS= read -r f; do
371
+ [ -n "$f" ] && printf 'finding=%s justified\n' "$f"
372
+ done
373
+ exit 0
374
+ fi
375
+
376
+ [ "$HAS_A" -eq 1 ] && [ -n "$JUSTIFY" ] \
377
+ && printf 'forge-check-lint: Lint-Justify present but rule (a) cannot-fail is never justifiable\n' >&2
378
+
379
+ printf 'lint=fail\n'
380
+ printf '%s\n' "$FINDINGS" | while IFS= read -r f; do
381
+ [ -n "$f" ] && printf 'finding=%s\n' "$f"
382
+ done
383
+ exit 1