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
|
@@ -71,7 +71,7 @@ Cross-machine note: the flag file is machine-local, so a worktree (or the primar
|
|
|
71
71
|
|
|
72
72
|
### 1.0 State Rollup (index.yml is derived)
|
|
73
73
|
|
|
74
|
-
`index.yml` is a **derived registry** — regenerate it from the milestone files before reading, and after any milestone CRUD (promote/defer/resume/delete). **Never hand-edit `index.yml`.** Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed): rendering it here is a local-cache write, so it never conflicts and never needs the orchestrator-only guard for *safety* — any session may render its own copy. The rollup below is unchanged; only the storage is (see FORGE.md → State Ownership, Derived class).
|
|
74
|
+
`index.yml` is a **derived registry** — regenerate it from the milestone files (plus each row's git-computed `release_state`, step 4) before reading, and after any milestone CRUD (promote/defer/resume/delete). **Never hand-edit `index.yml`.** Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed): rendering it here is a local-cache write, so it never conflicts and never needs the orchestrator-only guard for *safety* — any session may render its own copy. The rollup below is unchanged; only the storage is (see FORGE.md → State Ownership, Derived class).
|
|
75
75
|
|
|
76
76
|
Rollup procedure (deterministic + idempotent):
|
|
77
77
|
1. Glob `.forge/state/milestone-*.yml`.
|
|
@@ -81,7 +81,9 @@ Rollup procedure (deterministic + idempotent):
|
|
|
81
81
|
- **complete** — `current.status == complete`
|
|
82
82
|
- **not_started** — `current.status == not_started`
|
|
83
83
|
- **active** — otherwise
|
|
84
|
-
4.
|
|
84
|
+
4. Render each row's `release_state`, then rewrite `index.yml` `milestones:` (sorted by id) as `{id, name, status, last_updated, release_state}` (date from step 2's fallback). No other keys.
|
|
85
|
+
- **Fold is the source.** Run `.claude/hooks/forge-release-fold.sh m-{id}` per row and write its stdout `release_state=` value verbatim (`unmerged|merged|validated`) — agent prose never re-derives `release_state`; the fold binary is the only source.
|
|
86
|
+
- **Degrade, don't block.** Hook missing or exit 2 → write `release_state: unknown`, emit ONE advisory line — *"release fold unavailable for m-{id} — release_state: unknown"* — and continue. A broken fold must never break boot.
|
|
85
87
|
|
|
86
88
|
Output is a pure function of the milestone files, so two sessions regenerating it produce identical bytes — it never needs a hand-merge. **Only the main/orchestrator session runs rollup; worktree agents never write `index.yml`** (they edit only their own `milestone-{id}.yml`).
|
|
87
89
|
|
|
@@ -408,7 +408,7 @@ If the milestone's phases produced `contract.md` files (planning Step 6.1 Tier 1
|
|
|
408
408
|
## Phase Handoff
|
|
409
409
|
|
|
410
410
|
1. Confirm report + backlog
|
|
411
|
-
2. **Human Verification Gate (hard block)** — Read `current.human_verified` from `milestone-{id}.yml`. If **absent/null** → **do not complete the milestone.** Code-clean + review-clean is not enough to close (FORGE.md → Human Verification Gate). The milestone needs an explicit human sign-off, normally captured by `verifying`. Either route the user back to verify (then re-review) or capture the sign-off / recorded override here and write `current.human_verified` before proceeding. A recorded override (`method: override`) satisfies this check but **must** be surfaced in the health report. Never silently complete past an unset gate. *(Attended lane: the recorded `current.human_verified` — or a recorded override — **is** the gate, exactly as today. The M0 `verify-signoff` **forward-gate is not wired here** — that is autonomous-lane. Where an M0 `signoff/<wu-id>` tag does exist, treat `human_verified` as a **rendered echo** of that signature and surface the tag in the report; where none exists it is the stored attestation. A pre-existing tagless `complete` milestone reads complete — grandfathered, never re-gated.)*
|
|
411
|
+
2. **Human Verification Gate (hard block)** — binding-dispatched (B5): read `forge.validation_event` from `.forge/project.yml` first (absent ⇒ `in_session_signoff`). Under **`promotion_approval`** the human gate lives at the deploy boundary, not here: `current.status: complete` is allowed **machine-verified** (no in-session sign-off required), and the health report MUST carry the line *"human gate: at promotion (B5) — unit renders merged until the CI-signed promotion tag validates it"*. The mode is not agent-flippable: `validation_event`/`project.yml` is on the step-2 **protected-paths** hold list, so a diff flipping it blocks auto-merge for operator review (F4/F5; hold machinery is the sibling merge-policy build — handoff-step2-merge-release-policy.md). Under **`in_session_signoff`** (default) the gate runs exactly as follows, unchanged: Read `current.human_verified` from `milestone-{id}.yml`. If **absent/null** → **do not complete the milestone.** Code-clean + review-clean is not enough to close (FORGE.md → Human Verification Gate). The milestone needs an explicit human sign-off, normally captured by `verifying`. Either route the user back to verify (then re-review) or capture the sign-off / recorded override here and write `current.human_verified` before proceeding. A recorded override (`method: override`) satisfies this check but **must** be surfaced in the health report. Never silently complete past an unset gate. *(Attended lane: the recorded `current.human_verified` — or a recorded override — **is** the gate, exactly as today. The M0 `verify-signoff` **forward-gate is not wired here** — that is autonomous-lane. Where an M0 `signoff/<wu-id>` tag does exist, treat `human_verified` as a **rendered echo** of that signature and surface the tag in the report; where none exists it is the stored attestation. A pre-existing tagless `complete` milestone reads complete — grandfathered, never re-gated.)*
|
|
412
412
|
3. **Run promoted-milestone completion hook** (above) if `milestone.origin` set
|
|
413
413
|
4. **Run Contract Landing** (above) for any cross-layer phases — fold ratified contracts into their ADRs
|
|
414
414
|
5. Set `current.status: complete` and `current.completed_at: "<ISO 8601 timestamp>"` in `milestone-{id}.yml`, then regenerate `index.yml` via the `forge` **Rollup**
|
|
@@ -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
|
|
|
@@ -248,6 +248,13 @@ Items that can't be verified automatically (visual appearance, real-time behavio
|
|
|
248
248
|
|
|
249
249
|
Code-level PASS is necessary but **not sufficient** to close a milestone. Before handing off, capture an explicit human sign-off — this is the hard gate that `reviewing`-complete and `orchestrating`-teardown both check (see FORGE.md → Human Verification Gate). It is a precondition, not a peer option: never present milestone close / teardown as an alternative to running this.
|
|
250
250
|
|
|
251
|
+
**Binding dispatch (B5).** Read `forge.validation_event` from `.forge/project.yml` at gate time (absent ⇒ `in_session_signoff`):
|
|
252
|
+
|
|
253
|
+
- **`in_session_signoff` (default)** → run the capture below exactly as written — behavior unchanged from before the binding existed.
|
|
254
|
+
- **`promotion_approval`** → the human gate is **relocated to the deploy boundary**: do NOT require sign-off capture for close. Surface instead: *"human gate relocated to promotion — milestone will render merged until the CI-signed promotion tag exists"* (the derived `release_state` stays `merged` until the host-recorded promotion validates it; `.claude/hooks/forge-release-fold.sh` computes it). Skip the capture steps below and continue the handoff — close is machine-verified under this binding.
|
|
255
|
+
|
|
256
|
+
An agent cannot flip the mode: `validation_event` / `project.yml` is on the step-2 **protected-paths** hold list — a diff touching it blocks auto-merge for operator review (findings F4/F5; the hold machinery is the sibling merge-policy build, see handoff-step2-merge-release-policy.md).
|
|
257
|
+
|
|
251
258
|
Run on a PASSED verdict (and after any **HUMAN VERIFICATION NEEDED** items are resolved):
|
|
252
259
|
|
|
253
260
|
1. Surface what the human should confirm — the Observable Truths, any "Human Verification Items", and any milestone-specific manual check (device session, visual pass, real-device / e2e walk).
|
|
@@ -296,7 +303,7 @@ After verification completes (PASSED or GAPS FOUND), run a quick retrospective o
|
|
|
296
303
|
|
|
297
304
|
After PASSED verdict:
|
|
298
305
|
|
|
299
|
-
1. **Human Verification Gate** — capture explicit sign-off and write `current.human_verified
|
|
306
|
+
1. **Human Verification Gate** — binding-dispatched (see "Human Verification Gate" above). Default (`in_session_signoff`): capture explicit sign-off and write `current.human_verified`; if the human answers **not yet** → STOP here; do not advance to reviewing. The milestone stays open until sign-off (or a recorded override) exists. `promotion_approval`: no capture — surface the relocation line and continue.
|
|
300
307
|
2. **Persist** — Confirm verification report documented, desire-path files written to `.forge/state/desire-paths/`
|
|
301
308
|
3. **Update state** — Set `current.status` to `reviewing` in `.forge/state/milestone-{id}.yml`
|
|
302
309
|
4. **State-sync commit** (State Commit Protocol): `git add .forge/` then `git commit -m "chore(forge): sync state after verifying — m{N} {phase-name}"` (scoped; never `git add .`).
|
|
@@ -310,7 +317,7 @@ If GAPS found, route back to planning in gap-closure mode. Context clear applies
|
|
|
310
317
|
Realizes producer-side continuous integration (FORGE.md → Stream Integration Checkpoints). Runs at Phase Handoff step 5, **only when all hold**:
|
|
311
318
|
|
|
312
319
|
- the phase's plan frontmatter has `integration_checkpoint: true`;
|
|
313
|
-
- the verdict is PASSED — never publish unverified work. (`current.human_verified` gates milestone **close**, *not* per-checkpoint publish: a non-final checkpoint phase publishes on PASS alone; the milestone's **final** checkpoint coincides with close, where the Human Verification Gate applies — so in practice only the last checkpoint also requires `human_verified`.)
|
|
320
|
+
- the verdict is PASSED — never publish unverified work. (`current.human_verified` gates milestone **close**, *not* per-checkpoint publish: a non-final checkpoint phase publishes on PASS alone; the milestone's **final** checkpoint coincides with close, where the Human Verification Gate applies — so in practice only the last checkpoint also requires `human_verified`. On a `promotion_approval` repo even the final checkpoint publishes on PASS alone — close is machine-verified under that binding; the human gate lives at promotion.)
|
|
314
321
|
- the session is a **worktree** (`git rev-parse --git-dir` ≠ `--git-common-dir`). In a single checkout, skip — there is no main to fast-forward into.
|
|
315
322
|
|
|
316
323
|
**Strand guard — never push past the operator's local main (forge#13).** `git push origin HEAD:main` is fast-forward-only *relative to `origin/main`* — but `origin/main` is **not** the operator's canonical checkout. The local `main` ref (shared across this machine's worktrees via the common git dir) can carry commits that were never pushed — another stream's merge, a framework bump, the state-sync commits from step 4. If you push the worktree tip onto an `origin/main` that is *behind* local main, origin fast-forwards but **local main is left diverged** (it has commits origin's new tip lacks, and vice versa). The closing report then mis-reads this as "local main is behind — `git pull`", and `git pull --ff-only` *fails* on divergence while a bare `git pull` makes a surprise merge commit — the exact failure the merge discipline exists to prevent. So gate the push on local main being in the publish's direct line:
|
package/template/.forge/FORGE.md
CHANGED
|
@@ -232,7 +232,8 @@ State lives in `.forge/`:
|
|
|
232
232
|
- `design-system.md` — Component mapping table
|
|
233
233
|
- `requirements/m{N}.yml` — Per-milestone structured requirements with `[NEEDS CLARIFICATION]` markers. **FR-IDs, DEF-IDs, and NFR-IDs are globally unique across all milestone files** — `FR-001` may exist in exactly one `m{N}.yml`. Before adding a new ID, **reserve it via the ID Reservation Protocol** — run `forge-reserve fr|nfr|def --milestone <id>` and use the id it prints (the helper dual-writes the machine-local ledger + `.forge/reservations.yml`; you commit `reservations.yml` with the work). The next number is `max(ledger ∪ in-tree ∪ main:reservations.yml) + 1`. The machine-local ledger is what makes the shared ID space safe across **concurrent worktrees**: bare scan-and-increment (or a branch-local `reservations.yml`) sees only committed/merged state, so two worktrees branched from one baseline claim the same number and collide silently until merge (see ADR-021, supersedes ADR-016). On a residual collision (legacy/un-reserved, e.g. during a migration), keep the older milestone's ID and renumber the newer. Concurrent milestones each own their file — no cross-stream contention on file writes, but ID space is shared. Functional requirements may carry M9 e2e gate fields (`e2e`, `observable_outcome`, `observable_outcome_hash`, `validated`) — lazy migration, absent fields default to `e2e:false`/`validated:false`.
|
|
234
234
|
- `roadmap.yml` — Phases, milestones, dependencies
|
|
235
|
-
- `state/index.yml` — DERIVED registry rolled up from milestone files (id, name, status, last_updated). Never hand-edited; never written by worktree agents. **Git-ignored render-on-read cache (0.53.0): regenerated at every boot from the milestone files, never committed** — so a committed projection can never drift stale from its source, and any session can render it locally without touching `main`. Absent on a fresh clone until the first boot renders it (the project sentinel is `project.yml`, not this file).
|
|
235
|
+
- `state/index.yml` — DERIVED registry rolled up from milestone files (id, name, status, last_updated). Never hand-edited; never written by worktree agents. **Git-ignored render-on-read cache (0.53.0): regenerated at every boot from the milestone files, never committed** — so a committed projection can never drift stale from its source, and any session can render it locally without touching `main`. Absent on a fresh clone until the first boot renders it (the project sentinel is `project.yml`, not this file). The boot rollup also renders each unit's **derived `release_state`** into this cache (see below).
|
|
236
|
+
- **Derived `release_state` (0.56.0, [ADR-024](../docs/decisions/ADR-024-merged-validated-fold.md))** — a unit's `release_state ∈ {unmerged, merged, validated}` is **computed at rollup, never stored** (never an enum on `current.status`, never a committed field) and rendered only into the git-ignored cache. `merged` = the unit's landed SHA is an ancestor of the mainline; `validated` requires `merged` plus the repo's configured approval. Computed by the fold-of-record `.claude/hooks/forge-release-fold.sh` (git-only, offline, main-checkout-resolvable — all field reads via `git show main:`). Validation is dispatched on `forge.validation_event` in `project.yml`: `in_session_signoff` (**default**, absent ⇒ this — `human_verified`/M0 signoff, legacy-complete grandfathered) or `promotion_approval` (a CI-signed `promotion/{sha}` tag verified offline via `git tag -v` + allowed-signers, validated by containment). Inert by default — every repo unchanged until it opts in. The gate-relocation consequence lives in the Human Verification Gate section; the full spec lives in ADR-024 + the fold header (not duplicated here — Article XII).
|
|
236
237
|
- `state/milestone-{id}.yml` — Per-milestone cursor (single source of truth): position, progress, decisions, blockers. One owner at a time.
|
|
237
238
|
- `state/desire-paths/` — Append-only framework-usage observations, one file per observation. Occurrence counts derived by globbing (no mutable counter). Each carries `scope: project | framework` (set at capture; absent ⇒ project). **`forge` boot is the single review owner**: at 3+ occurrences it surfaces a concrete fix from the co-located suggestion table, persists declines to `declined/` (no re-nag), and for `scope: framework` signals routes them upstream — a portable block to `upstream/` plus a `gh issue` offer against `forge.upstream_repo`. This closes Principle 7's capture→act loop; `verifying` captures but no longer surfaces. See [ADR-012](../docs/decisions/ADR-012-upstream-desire-path-feedback-channel.md).
|
|
238
239
|
- `context.md` — Active locked decisions + deferred ideas (target <=12KB)
|
|
@@ -285,7 +286,7 @@ with the work it describes. Every artifact has a sharing class:
|
|
|
285
286
|
|---|---|---|
|
|
286
287
|
| **Single-owner mutable** | `state/milestone-{id}.yml`, `phases/milestone-{id}/`, `research/milestone-{id}.md` | Exactly one writer at a time — the worktree (or main session) currently driving that milestone. **Other worktrees never touch it**, not even to read-then-write — they pull from main if they need it. |
|
|
287
288
|
| **Single-owner mutable (per stream)** | `streams/{stream}.yml`, `streams/{stream}/brief.md`, `streams/{stream}/packages/*.yml` | The stream's **current driver** writes them — one writer at a time, like the milestone file. Distinct streams own distinct files (no cross-stream contention). `active.yml` is **derived** from them: a worktree driving a stream may write its own stream file but **never** `active.yml` or a peer stream's file. |
|
|
288
|
-
| **Derived (git-ignored render-on-read cache)** | `state/index.yml`, `streams/active.yml` | Never hand-edited, **never committed** — git-ignored local caches (0.53.0), regenerated by rollup from their sources (`index.yml` ← `milestone-*.yml` via Rollup 1.0; `active.yml` ← per-stream files + active milestones via the Stream Rollup) at every boot. Because the value is computed on read and never stored in git, a committed projection cannot drift stale from its source, and **any** session (not just the orchestrator) may render them locally without a `main` write. Absent on a fresh clone until the first boot; readers must render-then-read and must **not** use their absence as a "not a Forge project" sentinel (that is `project.yml`). |
|
|
289
|
+
| **Derived (git-ignored render-on-read cache)** | `state/index.yml`, `streams/active.yml`, the **`release_state`** column rendered into `index.yml` (ADR-024) | Never hand-edited, **never committed** — git-ignored local caches (0.53.0), regenerated by rollup from their sources (`index.yml` ← `milestone-*.yml` via Rollup 1.0; `active.yml` ← per-stream files + active milestones via the Stream Rollup; `release_state` ← `forge-release-fold.sh` over git truth) at every boot. Because the value is computed on read and never stored in git, a committed projection cannot drift stale from its source, and **any** session (not just the orchestrator) may render them locally without a `main` write. Absent on a fresh clone until the first boot; readers must render-then-read and must **not** use their absence as a "not a Forge project" sentinel (that is `project.yml`). |
|
|
289
290
|
| **Append-only shared** | `releases.yml` (version reservations), `reservations.yml` (ADR/DEF/FR/NFR id **audit trail + cross-machine floor**), `state/desire-paths/*` (one file per observation) | Many writers OK; structure prevents collision. `releases.yml` uses the Version Reservation Protocol (append + commit + push). `reservations.yml` is written by `forge-reserve` (ADR-021) and committed with the caller's work — it is no longer the id *coordination* point (the machine-local ledger is), just the durable committed shadow. Desire-paths use distinct filenames so two writers never touch the same file. |
|
|
290
291
|
| **Machine-local runtime** | `.git/forge/id-reservations` (id-reservation ledger), `.git/forge/integration-pending` (integration flag) | In the git common dir, shared across a repo's worktrees on one machine, **not** git-tracked. `forge-reserve` writes the ledger under a `mkdir` lock; excluded from framework context. Machine-local by design (cross-machine falls back to the `main:reservations.yml` floor). |
|
|
291
292
|
| **Shared mutable** | `context.md`, `refactor-backlog.yml`, `requirements/m{N}.yml` (ID space `FR-`/`DEF-`/`NFR-` is globally shared even though each milestone owns its file) | Write only to your milestone/stream block. Within a block, append-only; strike through instead of rewriting. |
|
|
@@ -378,11 +379,15 @@ Plans MAY declare a per-truth `check:` shell command in `must_haves.truths` (exi
|
|
|
378
379
|
**No milestone reaches `complete` and no orchestration worktree is torn down until a human has explicitly signed off** — recorded as `current.human_verified` in `milestone-{id}.yml`. Code-level verification (tests, lint, type-check, the 3-level goal-backward pass) is necessary but **never sufficient** to close a milestone. A human must confirm the work in whatever form fits the milestone — device session, visual check, e2e walk, smoke test ("of some sorts").
|
|
379
380
|
|
|
380
381
|
- **Captured** by `verifying` — after the verdict it prompts for explicit sign-off and writes `current.human_verified: {at, method, notes}`. If the human can't verify yet (e.g. a device session is still pending), the field stays unset and downstream close blocks.
|
|
381
|
-
- **Enforced (hard block)** at both close points — `reviewing` refuses to set `current.status: complete`, and `orchestrating` Step 5 refuses teardown, unless `current.human_verified` is present.
|
|
382
|
+
- **Enforced (hard block)** at both close points — `reviewing` refuses to set `current.status: complete`, and `orchestrating` Step 5 refuses teardown of **un-merged** work (see the teardown split below), unless `current.human_verified` is present.
|
|
382
383
|
- **Recorded override** is the only bypass. A human may explicitly close without verifying, but the skill writes `human_verified: {method: override, override: true, reason}` and surfaces it in the report. Silent close is impossible — an agent must **never** present milestone close / orchestration teardown as a peer alternative to "do the verification". The verification is a precondition, not an option.
|
|
383
384
|
|
|
384
385
|
Lazy migration: milestones with no `human_verified` field (pre-0.27.0) simply hit the prompt on their next close transition; already-`complete` milestones are never re-gated.
|
|
385
386
|
|
|
387
|
+
**Per-binding relocation (B5, operator-ratified 2026-07-15).** The gate above is the **`in_session_signoff`** binding — the default (`forge.validation_event` absent ⇒ this), preserved exactly. A repo that opts into **`validation_event: promotion_approval`** relocates the human gate to the **deploy boundary**: `complete` becomes the machine/code-verified state (`reviewing` closes without in-session sign-off, its health report noting *"human gate: at promotion (B5)"*), and the derived `release_state: validated` — computed by `forge-release-fold.sh` when a CI-signed promotion tag covers the unit — **is** the human gate (the full release_state ladder lands in State Management with phase 52). The mode is **not agent-flippable**: `validation_event`/`project.yml` sits on the step-2 **protected-paths** hold (F4/F5), so a diff flipping it blocks auto-merge for operator review.
|
|
388
|
+
|
|
389
|
+
**Teardown splits on merged-ness (operator ruling 2026-07-16 — binding-independent).** Worktree teardown auto-proceeds when the unit renders `merged`/`validated` via `forge-release-fold.sh` — the work is on main; removing the worktree loses nothing. The human gate applies only to **un-merged** teardown. The close gate is unaffected by this split — it keeps the per-binding rule above.
|
|
390
|
+
|
|
386
391
|
**Grandfather (legacy-complete marker).** A milestone already stored `current.status: complete` with **no** `signoff/<wu-id>` tag (the pre-M0 history — 26 milestones as of 0.53.0) is **grandfathered**: readers honor it as complete and it is **never re-gated** by any later sign-off convention. "Complete with no signoff tag" **is** the legacy-complete marker — no field is stamped onto those files. Only *new* completions going forward can be asked to carry a signature (that forward-gate is autonomous-lane and is **not** wired in the attended lane — see below).
|
|
387
392
|
|
|
388
393
|
**Where the Contract M0 sign-off convention is adopted, `human_verified` is a derived echo, not the source of truth.** The authoritative predicate becomes `verify-signoff <wu-id>` exiting 0 — an SSH-signed `signoff/<wu-id>` tag an agent cannot forge (see `docs/sign-off-convention.md` and `.claude/hooks/{verify-signoff,wu-done}.sh`). `current.human_verified` may still be written for human-readable display, but treat it as a cache of the signature check, never the gate itself. Core does not yet wire this predicate into `verifying`/`reviewing`'s own hard-block logic automatically — that remains a flagged follow-up (Contract M0 §D2 scoped to a minimal fold stub, not the full skill wiring).
|
|
@@ -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
|