Skip to content

Commit 26d36c4

Browse files
committed
fix(crane): block incomplete parity completion
1 parent f7960d1 commit 26d36c4

7 files changed

Lines changed: 135 additions & 27 deletions

File tree

.crane/scripts/score.go

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -149,29 +149,9 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
149149
if !strings.HasPrefix(line, "{") {
150150
continue
151151
}
152-
var gate GateEvent
153-
if err := json.Unmarshal([]byte(line), &gate); err == nil && gate.Crane == "gate" {
152+
if gate, ok := parseGateEvent(line); ok {
154153
eventsSeen++
155-
switch gate.Name {
156-
case "python_reference":
157-
pythonReference = BoolGate{Seen: true, Passed: gate.Passed}
158-
case "surface":
159-
surface = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
160-
case "help":
161-
help = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
162-
case "functional":
163-
functional = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
164-
case "state_diff":
165-
stateDiff = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
166-
case "python_behavior_contracts":
167-
behaviorContracts = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
168-
case "known_exceptions":
169-
knownExceptions = gate.Count
170-
case "python_tests":
171-
pythonTests = BoolGate{Seen: true, Passed: gate.Passed}
172-
case "benchmarks":
173-
benchmarks = BoolGate{Seen: true, Passed: gate.Passed}
174-
}
154+
applyGateEvent(gate, &pythonReference, &surface, &help, &functional, &stateDiff, &behaviorContracts, &knownExceptions, &pythonTests, &benchmarks)
175155
continue
176156
}
177157

@@ -182,6 +162,9 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
182162
eventsSeen++
183163

184164
if ev.Output != "" {
165+
if gate, ok := parseGateEvent(ev.Output); ok {
166+
applyGateEvent(gate, &pythonReference, &surface, &help, &functional, &stateDiff, &behaviorContracts, &knownExceptions, &pythonTests, &benchmarks)
167+
}
185168
if n, ok := approvedExceptionCount(ev.Output); ok && n > knownExceptions {
186169
knownExceptions = n
187170
}
@@ -253,7 +236,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
253236
stateDiff = inferredAnyRatioGate(passed, failed, "TestParityCompletionStateDiffContracts", "TestParityStateDiffContracts")
254237
}
255238
if !behaviorContracts.Seen {
256-
behaviorContracts = inferredAnyRatioGate(passed, failed, "TestParityCompletionPythonBehaviorContracts")
239+
behaviorContracts = RatioGate{Seen: true, Passing: 0, Total: 1}
257240
}
258241
if !pythonTests.Seen {
259242
pythonTests = BoolGate{Seen: true, Passed: testPassed(passed, failed, "TestParityCompletionPythonSuite")}
@@ -341,6 +324,52 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
341324
}, nil
342325
}
343326

327+
func parseGateEvent(line string) (GateEvent, bool) {
328+
line = strings.TrimSpace(line)
329+
if !strings.HasPrefix(line, "{") {
330+
return GateEvent{}, false
331+
}
332+
var gate GateEvent
333+
if err := json.Unmarshal([]byte(line), &gate); err != nil || gate.Crane != "gate" {
334+
return GateEvent{}, false
335+
}
336+
return gate, true
337+
}
338+
339+
func applyGateEvent(
340+
gate GateEvent,
341+
pythonReference *BoolGate,
342+
surface *RatioGate,
343+
help *RatioGate,
344+
functional *RatioGate,
345+
stateDiff *RatioGate,
346+
behaviorContracts *RatioGate,
347+
knownExceptions *int,
348+
pythonTests *BoolGate,
349+
benchmarks *BoolGate,
350+
) {
351+
switch gate.Name {
352+
case "python_reference":
353+
*pythonReference = BoolGate{Seen: true, Passed: gate.Passed}
354+
case "surface":
355+
*surface = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
356+
case "help":
357+
*help = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
358+
case "functional":
359+
*functional = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
360+
case "state_diff":
361+
*stateDiff = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
362+
case "python_behavior_contracts":
363+
*behaviorContracts = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
364+
case "known_exceptions":
365+
*knownExceptions = gate.Count
366+
case "python_tests":
367+
*pythonTests = BoolGate{Seen: true, Passed: gate.Passed}
368+
case "benchmarks":
369+
*benchmarks = BoolGate{Seen: true, Passed: gate.Passed}
370+
}
371+
}
372+
344373
func isTargetPackage(pkg string) bool {
345374
return strings.HasPrefix(pkg, "github.com/githubnext/apm/")
346375
}

.github/workflows/migration-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ jobs:
111111
uv run python scripts/ci/python_behavior_contracts.py check \
112112
--inventory "$RUNNER_TEMP/python-behavior-contracts.json" \
113113
--coverage tests/parity/python_contract_coverage.yml \
114+
--allow-intentionally-incomplete \
114115
--summary "$RUNNER_TEMP/python-contract-coverage.md" || true
115116
python - "$RUNNER_TEMP/migration-score.json" <<'PY'
116117
import json

cmd/apm/python_behavior_contracts_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"os"
67
"os/exec"
78
"path/filepath"
@@ -108,6 +109,10 @@ func normalizeContractHelp(text string) string {
108109
return strings.TrimRight(strings.Join(lines, "\n"), "\n")
109110
}
110111

112+
func emitCraneRatioGate(name string, passing, total int) {
113+
fmt.Printf("{\"crane\":\"gate\",\"name\":%q,\"passing\":%d,\"total\":%d}\n", name, passing, total)
114+
}
115+
111116
func TestParityPythonCommandSurfaceFromSource(t *testing.T) {
112117
inv := loadPythonBehaviorInventory(t, false)
113118
if len(inv.Commands) == 0 {
@@ -190,6 +195,8 @@ func TestParityCompletionPythonBehaviorContracts(t *testing.T) {
190195
check.Env = append(os.Environ(), "NO_COLOR=1", "COLUMNS=10000")
191196
out, err := check.CombinedOutput()
192197
if err != nil {
198+
emitCraneRatioGate("python_behavior_contracts", 0, 1)
193199
t.Fatalf("Python behavior contracts are not fully covered:\n%s", string(out))
194200
}
201+
emitCraneRatioGate("python_behavior_contracts", 1, 1)
195202
}

scripts/ci/python_behavior_contracts.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,16 @@ def cmd_check(args: argparse.Namespace) -> int:
404404
Path(args.summary).write_text(summary, encoding="utf-8")
405405
print(summary)
406406
if coverage.get("status") == "intentionally-incomplete":
407-
# Manifest explicitly declared incomplete; report findings without failing.
407+
if not args.allow_intentionally_incomplete:
408+
print(
409+
"coverage manifest declares status: intentionally-incomplete; "
410+
"remove that status only after all findings are resolved",
411+
file=sys.stderr,
412+
)
413+
return 1
414+
# Report-only mode for progress summaries. Completion checks must not
415+
# use this flag, because an intentionally incomplete manifest is not
416+
# deletion-grade evidence.
408417
return 0
409418
return 1 if findings else 0
410419

@@ -425,6 +434,11 @@ def main(argv: list[str] | None = None) -> int:
425434
help="coverage manifest path",
426435
)
427436
check.add_argument("--summary", help="write markdown coverage summary to this path")
437+
check.add_argument(
438+
"--allow-intentionally-incomplete",
439+
action="store_true",
440+
help="report findings without failing when the manifest is marked incomplete",
441+
)
428442
check.set_defaults(func=cmd_check)
429443

430444
args = parser.parse_args(argv)

tests/parity/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ tests.
1515
contracts to parity evidence. The completion scorer must not reach
1616
`migration_score = 1.0` while any extracted command or Python test lacks mapped
1717
coverage.
18+
19+
`status: intentionally-incomplete` is a progress marker only. It must make
20+
completion checks fail; use `--allow-intentionally-incomplete` only for
21+
report-only summaries.

tests/parity/test_python_behavior_contracts.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ def test_every_python_command_rejects_unknown_option_consistently(
142142
def test_python_contract_coverage_manifest_is_complete(inventory: dict[str, object]) -> None:
143143
coverage = _load_coverage(ROOT / "tests" / "parity" / "python_contract_coverage.yml")
144144
if coverage.get("status") == "intentionally-incomplete":
145-
pytest.skip("Coverage manifest is intentionally incomplete; remove status field to enforce")
145+
pytest.fail(
146+
"Coverage manifest is intentionally incomplete; remove status field "
147+
"only after all contracts are mapped"
148+
)
146149
findings = check_coverage(inventory, coverage)
147150
assert not findings, render_summary(inventory, findings)

tests/unit/test_crane_score.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ def _completion_gate_events() -> list[str]:
9595
return [line for test in tests for line in _go_pass(test)]
9696

9797

98+
def _behavior_contract_gate_output(passing: int, total: int) -> str:
99+
return _event(
100+
"output",
101+
"TestParityCompletionPythonBehaviorContracts",
102+
output=json.dumps(
103+
{
104+
"crane": "gate",
105+
"name": "python_behavior_contracts",
106+
"passing": passing,
107+
"total": total,
108+
}
109+
)
110+
+ "\n",
111+
)
112+
113+
98114
def _gates(score: dict[str, object]) -> dict[str, dict[str, object]]:
99115
gates = score["gates"]
100116
assert isinstance(gates, list)
@@ -220,15 +236,49 @@ def test_crane_score_rejects_empty_event_stream() -> None:
220236
assert "empty or incomplete" in result.stderr
221237

222238

223-
def test_crane_score_infers_cutover_gates_from_completion_tests() -> None:
224-
score = _run_score([*_parity_passes(293), *_completion_gate_events(), _package_pass()])
239+
def test_crane_score_reaches_one_with_completion_tests_and_explicit_behavior_gate() -> None:
240+
score = _run_score(
241+
[
242+
*_parity_passes(293),
243+
*_completion_gate_events(),
244+
_behavior_contract_gate_output(1, 1),
245+
_package_pass(),
246+
]
247+
)
225248

226249
assert score["migration_score"] == 1.0
227250
assert score["progress"] == 1.0
228251
assert score["deletion_grade_ready"] is True
229252
assert all(gate["passing"] for gate in _gates(score).values())
230253

231254

255+
def test_crane_score_does_not_infer_behavior_contracts_from_test_name() -> None:
256+
score = _run_score([*_parity_passes(293), *_completion_gate_events(), _package_pass()])
257+
gates = _gates(score)
258+
259+
assert score["progress"] == 1.0
260+
assert score["migration_score"] < 1.0
261+
assert score["deletion_grade_ready"] is False
262+
assert gates["python_behavior_contracts"]["passing"] is False
263+
264+
265+
def test_crane_score_blocks_incomplete_behavior_contract_gate() -> None:
266+
score = _run_score(
267+
[
268+
*_parity_passes(293),
269+
*_completion_gate_events(),
270+
_behavior_contract_gate_output(0, 1),
271+
_package_pass(),
272+
]
273+
)
274+
gates = _gates(score)
275+
276+
assert score["progress"] == 1.0
277+
assert score["migration_score"] < 1.0
278+
assert score["deletion_grade_ready"] is False
279+
assert gates["python_behavior_contracts"]["passing"] is False
280+
281+
232282
def test_crane_score_blocks_known_exceptions() -> None:
233283
score = _run_score(
234284
[

0 commit comments

Comments
 (0)