Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/rules/lane-claim-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ La clause `paths:` est parsee par `_extract_paths_clause` dans `scripts/check_la

**Référence organe** : `scripts/check_lane_claim.py:_extract_paths_clause` + `scripts/tests/test_check_lane_claim.py::test_paths_clause_*`. **Référence diagnostic** : issue **#12052** (parenthèse + prose), #10958 (annotation tiret), #10597 (accolades), #11064 (`--paths`).

**Déviation sémantique — un glob bien formé qui ne matche aucun fichier suivi (#12740)** : la garantie fail-CLOSED de la ligne 28 tient pour les déviations **syntaxiques**. Elle ne tient pas pour la déviation **sémantique** — un glob syntaxiquement correct mais qui nomme un chemin inexistant (ex. `paths: scripts/notebook_tools/check_code_in_markdown.py` alors que le fichier réel est `detect_code_in_markdown_cells.py`, incident #12620 : deux lanes ont livré le même fichier réel). Politique choisie (option **b**, signaler sans re-bloquer) : on NE rouvre PAS le fail-open #10958 — un claim actif dont tout le scope est mort reste porté epic-wide (un claim cassé n'est pas permissif) — mais `check_lane_claim.py` ajoute un champ JSON `dead_scope_globs` (lane-keyed, agrégé sur TOUS les événements de claim, y compris relâchés) pour que le typo soit visible à un sweep JSON, et non seulement sur le stderr que le gate/le picker ne consomment pas. Le cas légitime du fichier pas encore créé (grain Lean, nouveau notebook) reste couvert sans geler la lane : la cible s'écrit avec un métacaractère de répertoire (`scripts/notebook_tools/*markdown*`).

## Tie-break — l'issue l'emporte, l'override s'ecrit (#10223)

Les deux collisions du 2026-08-09 (#10169 puis #10161) ont revele deux
Expand Down
44 changes: 44 additions & 0 deletions scripts/check_lane_claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -1756,6 +1756,40 @@ def _run_check(payload: dict, my_lane: str, stale_threshold=None,
for ev in events:
if ev.get("paths"):
ev["empty_scope"] = _empty_scope_in(ev["paths"], tracked)
# #12740 -- dead-scope aggregate, lane-keyed, over ALL claim events.
#
# The #10881/#10958 witnesses surface a dead glob to a consumer that
# reads them, but only for the ACTIVE claims (`active_claims.<lane>
# .empty_scope`) and for the CALLER's own scope (`caller_empty_scope`).
# A claim whose `paths:` glob is a typo and is then RELEASED/CLOSED leaves
# the typo invisible to a JSON sweep -- the stderr WARN in
# `_lint_claim_events` is the only channel, and it goes to STDERR, which
# the CI gate, `pick_idle_grain` and the lane scripts do not consume.
# That is exactly the fail-open #12740 names: a `[CLAIMED] -- paths:
# scripts/notebook_tools/check_code_in_markdown.py` (real file:
# detect_code_in_markdown_cells.py) claimed #12620, yet both lanes
# worked the same real file -- the dead glob never surfaced in the JSON.
#
# Policy (chosen, #12740): SIGNAL, not re-block. We do NOT re-open the
# #10958 fail-open: an ACTIVE claim whose entire scope is dead is STILL
# lifted to epic-wide (a broken claim is not a permissive claim, and
# lifting it back to scoped would re-create the #9764-style false CLEAR).
# We ADD the signal -- a lane-keyed map of dead globs across every claim
# event (open, override AND close) -- so a sweep can grep ONE key and a
# released-claim typo still surfaces. A coordinator wanting full (a)
# fail-CLOSED for the legitimate new-file case can address the semantic
# deviation separately; the mechanism here is the visibility half.
dead_scope_globs: dict[str, list[str]] = {}
if tracked is not None:
for ev in events:
lane = ev.lane
dead = ev.get("empty_scope") or []
if not lane or not dead:
continue
bucket = dead_scope_globs.setdefault(lane, [])
for g in dead:
if g not in bucket:
bucket.append(g)
active, unattributed = compute_active_claims(events, pr_states=pr_states)
others = {ln: ev for ln, ev in active.items() if ln != my_lane}
mine = active.get(my_lane)
Expand Down Expand Up @@ -2006,6 +2040,16 @@ def _run_check(payload: dict, my_lane: str, stale_threshold=None,
# the dead globs cover the whole scope, otherwise the live globs
# continue to carry the disjointness test.
"caller_empty_scope": caller_empty_scope,
# #12740 -- lane-keyed dead-glob map, aggregated over EVERY claim
# event (not just active claims). Empty (`{}`) when no glob of any
# claim matches zero tracked files, or when the tracked walk was
# impossible (`tracked is None` -> cannot prove deadness). Unlike the
# per-active-claim `empty_scope` (which disappears when a claim is
# released) and the stderr WARN (unread by the CI gate / picker /
# lane scripts), this key survives a release so a typo'd scope is
# always visible to a JSON sweep. Non-blocking by design -- it only
# reports; it does not change the verdict.
"dead_scope_globs": dead_scope_globs,
}
print(json.dumps(summary, ensure_ascii=False, indent=2))

Expand Down
57 changes: 57 additions & 0 deletions scripts/tests/test_check_lane_claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -2689,6 +2689,63 @@ def test_run_check_partially_dead_scope_stays_scoped(capsys):
assert '"blocking_lanes": []' in capsys.readouterr().out


# --- #12740 -- dead-scope aggregate in the JSON --------------------------------
#
# #10958 surfaces `empty_scope` under the ACTIVE claim and `caller_empty_scope`
# for the caller's own scope. GAP measured on #12620: a `[CLAIMED] -- paths:
# scripts/notebook_tools/check_code_in_markdown.py` (the real file is
# detect_code_in_markdown_cells.py) was released without ever being read --
# the stderr WARN goes to a channel the CI gate / picker / lane scripts do not
# consume, and once the claim closes its dead glob vanishes from the JSON
# altogether. `dead_scope_globs` aggregates the dead globs lane-keyed across
# EVERY claim event (open, override, close) so a sweep can grep ONE key.

def test_run_check_dead_scope_surfaces_even_when_released(capsys):
"""The specific #12740 shape: a dead-glob claim that has been RELEASED.
It is not active (`active_claims: {}`), it does not block (`blocking_lanes:
[]`), and the stderr WARN does not fire on a close marker -- the typo is
invisible to a JSON consumer. `dead_scope_globs` must still name it."""
p = payload(
comment(
"[CLAIMED] lane myia-po-2023:CoursIA -- "
"paths: scripts/notebook_tools/check_code_in_markdown.py",
"2026-08-23T22:45:32Z",
),
comment(
"[RELEASED] lane myia-po-2023:CoursIA -- "
"paths: scripts/notebook_tools/check_code_in_markdown.py",
"2026-08-24T00:10:00Z",
),
)
rc = clc._run_check(p, "myia-po-2025:CoursIA")
# Released -> no active claim, no blocker, CLEAR.
assert rc == 0
out = capsys.readouterr().out
assert '"active_claims": {}' in out # the silent look #12740 names
assert '"blocking_lanes": []' in out
# The dead glob still surfaces, lane-keyed, even though the claim closed.
assert '"dead_scope_globs"' in out
assert "myia-po-2023:CoursIA" in out
assert "scripts/notebook_tools/check_code_in_markdown.py" in out


def test_run_check_dead_scope_aggregate_empty_for_live_scope(capsys):
"""Positive control: a scope whose globs all match a tracked file yields
`dead_scope_globs: {}` AND still blocks another lane. Without this, a
guard that reported a dead glob for every live scope would be
indistinguishable from one that works -- the aggregate must stay silent on
a well-formed scope (and the block must survive)."""
p = payload(
comment("[CLAIMED] lane myia-po-2024:CoursIA-2 -- "
"paths: scripts/check_lane_claim.py",
"2026-08-12T11:00:00Z"),
)
rc = clc._run_check(p, "myia-po-2025:CoursIA")
assert rc == 2 # #12322 NOT_SCOPED -- caller gave no --paths, cannot prove disjointness
out = capsys.readouterr().out
assert '"dead_scope_globs": {}' in out


# --- #10597 bonus -- SCOPE_ZERO_COVERAGE warning ------------------------------
#
# When the caller's own claim carries a SCOPE that matches zero tracked
Expand Down
Loading