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.
- package/bin/create-forge.js +6 -0
- package/package.json +1 -1
- package/template/.claude/settings.json +1 -1
- package/template/.claude/skills/upgrading/SKILL.md +18 -0
- 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.58.2-merge-floor-checks-relocation.md +61 -0
- package/template/.forge/templates/hold-config.yml +34 -0
package/bin/create-forge.js
CHANGED
|
@@ -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
|
@@ -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
|