@boyingliu01/xp-gate 0.30.3 → 0.30.5

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.
Files changed (33) hide show
  1. package/gate-10.sh +80 -13
  2. package/hooks/gate-10.sh +80 -13
  3. package/hooks/pre-push +53 -3
  4. package/lib/mattpocock-license-verdict.json +1 -1
  5. package/mock-policy/AGENTS.md +3 -3
  6. package/mutation/AGENTS.md +3 -3
  7. package/mutation/__tests__/gate-m-survivors.test.ts +167 -0
  8. package/mutation/__tests__/mutation-mirror.test.ts +49 -0
  9. package/mutation/__tests__/update-baseline.test.ts +7 -3
  10. package/mutation/gate-m.ts +164 -20
  11. package/mutation/types.ts +9 -3
  12. package/mutation/update-baseline.ts +24 -7
  13. package/package.json +1 -1
  14. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  15. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  16. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  17. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  18. package/plugins/dsh/package.json +1 -1
  19. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  20. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  21. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  22. package/plugins/opencode/package.json +1 -1
  23. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  24. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  25. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  26. package/plugins/qoder/plugin.json +1 -1
  27. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  28. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  29. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  30. package/principles/AGENTS.md +3 -3
  31. package/skills/delphi-review/AGENTS.md +3 -3
  32. package/skills/sprint-flow/AGENTS.md +3 -3
  33. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/gate-10.sh CHANGED
@@ -91,11 +91,27 @@ else
91
91
  [ -n "$_semgrep_file" ] || continue
92
92
  SEMGREP_FILE_COUNT=$((SEMGREP_FILE_COUNT + 1))
93
93
  SEMGREP_ARGS+=("--include=${_semgrep_file##*/}")
94
- _semgrep_dir="./${_semgrep_file%/*}"
95
- # A repo-root file has no directory component ("./x.ts" strips to "./").
96
- [ "$_semgrep_dir" = "./" ] && _semgrep_dir="."
94
+ # A repo-root file has no slash at all, so "%/*" returns it unchanged and
95
+ # the old comment's assumption (`"./x.ts" strips to "./"`) never held:
96
+ # the target became the FILE path dressed as a directory ("./x.ts").
97
+ # Strip only when a slash is present; otherwise the target is the repo
98
+ # root, spelled ".". Scanning "." widens the scan to the whole tree, which
99
+ # is the safe side (see the note above) and is what a root-level staged
100
+ # file requires in order to be analysed at all.
101
+ case "$_semgrep_file" in
102
+ */*) _semgrep_dir="./${_semgrep_file%/*}" ;;
103
+ *) _semgrep_dir="." ;;
104
+ esac
105
+ # #558: dedup must match WHOLE lines. The previous `case "$SEEN" in
106
+ # *"$_semgrep_dir"*)` was an unanchored substring test: once
107
+ # ./fastdl/tests was recorded, the parent ./fastdl matched it and was
108
+ # never added as a target, so a staged fastdl/a.py contributed an
109
+ # --include= with no directory to scan it in. Nothing errored and nothing
110
+ # was reported -- the file was simply not analysed, which for a security
111
+ # gate is the worst outcome: a silent net loss that looks like a pass.
112
+ # Sibling prefixes (src/api vs src/app) collapsed the same way.
97
113
  case "$SEMGREP_DIRS_SEEN" in
98
- *"$_semgrep_dir"*) : ;;
114
+ *$'\n'"$_semgrep_dir"$'\n'*) : ;;
99
115
  *) SEMGREP_ARGS+=("$_semgrep_dir"); SEMGREP_DIRS_SEEN="$SEMGREP_DIRS_SEEN$_semgrep_dir"$'\n' ;;
100
116
  esac
101
117
  done <<EOF
@@ -131,9 +147,41 @@ EOF
131
147
  # code is demoted to "did the tool itself run". --error stays OFF: it would
132
148
  # add nothing the JSON does not already provide, and keeping it off leaves
133
149
  # failure exit codes unambiguous for the #475 diagnostics path.
134
- SEMGREP_STDOUT_FILE=$(mktemp)
135
- SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
136
- SEMGREP_EXIT=$?
150
+ # #559: bound the run. Without a timeout, a wedged semgrep blocks the
151
+ # invocation for as long as its process lives; the fail-closed verdict logic
152
+ # below is correct but unreachable, so the hook hangs instead of judging.
153
+ # A gate that can wedge an interactive `git commit` gets bypassed with
154
+ # --no-verify, which is worse than any verdict it could have produced.
155
+ # `-k` after the primary signal: pysemgrep spinning in a syscall ignores
156
+ # SIGTERM, and the shipped 0.29 code used the same `-k 10` shape.
157
+ SEMGREP_TIMEOUT_S="${XP_GATE_SEMGREP_TIMEOUT:-180}"
158
+ SEMGREP_TIMED_OUT=false
159
+ if command -v timeout >/dev/null 2>&1; then
160
+ SEMGREP_STDOUT_FILE=$(mktemp)
161
+ SEMGREP_OUTPUT=$(timeout -k 10 "$SEMGREP_TIMEOUT_S" \
162
+ "$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check \
163
+ "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
164
+ SEMGREP_EXIT=$?
165
+ # 124 = timeout's own "I gave up" code. 137 = 128+SIGKILL, which is what
166
+ # `timeout -k` returns when the child ignored SIGTERM and had to be
167
+ # killed -- measured with a semgrep stand-in that traps TERM. Both mean
168
+ # "bounded, no verdict", so both must be reported as a timeout; checking
169
+ # only 124 told the user their semgrep was unparseable when in fact the
170
+ # scan had been cut off.
171
+ case "$SEMGREP_EXIT" in
172
+ 124|137) SEMGREP_TIMED_OUT=true ;;
173
+ esac
174
+ else
175
+ # No `timeout` on this platform (stock macOS). Say so: an unbounded run
176
+ # nobody mentioned is exactly how #559 stayed invisible for a whole
177
+ # release. The scan still runs -- refusing to scan would be a worse
178
+ # trade -- but the unbounded window is now stated.
179
+ echo " ⚠️ 'timeout' command not available — semgrep runs UNBOUNDED (install coreutils or set up gtimeout)"
180
+ SEMGREP_STDOUT_FILE=$(mktemp)
181
+ SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check \
182
+ "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
183
+ SEMGREP_EXIT=$?
184
+ fi
137
185
 
138
186
  # One node pass over the JSON: first line = verdict counts, remaining lines =
139
187
  # finding details. The severity vocabulary is the UNION of the two scales
@@ -175,7 +223,18 @@ console.log("");});
175
223
  if [ "$V_PARSE" != "OK" ] || [ -z "$V_RESULTS" ] || [ -z "$V_ERRORS" ] || [ -z "$V_BLOCKING" ] || [ -z "$V_WARNINGS" ]; then
176
224
  # Unparseable output: nothing the exit code claims can be trusted (#518).
177
225
  # Fail closed — SKIP with diagnostics, never a PASS.
178
- echo " ⚠️ semgrep output unparseable or verdict fields missing (exit code ${SEMGREP_EXIT}) — cannot determine verdict"
226
+ #
227
+ # #559: name a timeout as a timeout. The generic "unparseable" message
228
+ # would be true but useless here -- it points at the JSON parser when the
229
+ # real problem is that the scan was cut off. Same verdict (SKIP), but a
230
+ # diagnosis the reader can act on.
231
+ if [ "$SEMGREP_TIMED_OUT" = true ]; then
232
+ echo " ⏱️ semgrep exceeded ${SEMGREP_TIMEOUT_S}s and was killed — no verdict is possible"
233
+ echo " This is NOT a finding and NOT a clean scan. Raise XP_GATE_SEMGREP_TIMEOUT"
234
+ echo " if the repo legitimately takes longer; investigate if semgrep itself is wedged."
235
+ else
236
+ echo " ⚠️ semgrep output unparseable or verdict fields missing (exit code ${SEMGREP_EXIT}) — cannot determine verdict"
237
+ fi
179
238
  if [ -n "$SEMGREP_OUTPUT" ]; then
180
239
  echo " ── what semgrep reported on stderr ──"
181
240
  printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
@@ -184,11 +243,19 @@ console.log("");});
184
243
  echo " ── what semgrep wrote to stdout (tail) ──"
185
244
  printf '%s\n' "$SEMGREP_STDOUT_TAIL" | sed 's/^/ /'
186
245
  fi
187
- echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
188
- GATE_10_STATUS="SKIP"
189
- # Consumed by the sourcing parent (pre-commit report JSON), not here.
190
- # shellcheck disable=SC2034
191
- GATE_10_REASON="output_unparseable"
246
+ if [ "$SEMGREP_TIMED_OUT" = true ]; then
247
+ echo " ⏭️ SKIPPED - SAST (semgrep timed out after ${SEMGREP_TIMEOUT_S}s)"
248
+ GATE_10_STATUS="SKIP"
249
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
250
+ # shellcheck disable=SC2034
251
+ GATE_10_REASON="semgrep_timeout"
252
+ else
253
+ echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
254
+ GATE_10_STATUS="SKIP"
255
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
256
+ # shellcheck disable=SC2034
257
+ GATE_10_REASON="output_unparseable"
258
+ fi
192
259
  elif [ "$V_RESULTS" -eq 0 ] && { [ "$V_ERRORS" -gt 0 ] || [ "$SEMGREP_EXIT" -ne 0 ]; }; then
193
260
  # Nothing was scanned to a verdict: semgrep either failed outright
194
261
  # (nonzero exit) or completed but reported scan errors for every input
package/hooks/gate-10.sh CHANGED
@@ -91,11 +91,27 @@ else
91
91
  [ -n "$_semgrep_file" ] || continue
92
92
  SEMGREP_FILE_COUNT=$((SEMGREP_FILE_COUNT + 1))
93
93
  SEMGREP_ARGS+=("--include=${_semgrep_file##*/}")
94
- _semgrep_dir="./${_semgrep_file%/*}"
95
- # A repo-root file has no directory component ("./x.ts" strips to "./").
96
- [ "$_semgrep_dir" = "./" ] && _semgrep_dir="."
94
+ # A repo-root file has no slash at all, so "%/*" returns it unchanged and
95
+ # the old comment's assumption (`"./x.ts" strips to "./"`) never held:
96
+ # the target became the FILE path dressed as a directory ("./x.ts").
97
+ # Strip only when a slash is present; otherwise the target is the repo
98
+ # root, spelled ".". Scanning "." widens the scan to the whole tree, which
99
+ # is the safe side (see the note above) and is what a root-level staged
100
+ # file requires in order to be analysed at all.
101
+ case "$_semgrep_file" in
102
+ */*) _semgrep_dir="./${_semgrep_file%/*}" ;;
103
+ *) _semgrep_dir="." ;;
104
+ esac
105
+ # #558: dedup must match WHOLE lines. The previous `case "$SEEN" in
106
+ # *"$_semgrep_dir"*)` was an unanchored substring test: once
107
+ # ./fastdl/tests was recorded, the parent ./fastdl matched it and was
108
+ # never added as a target, so a staged fastdl/a.py contributed an
109
+ # --include= with no directory to scan it in. Nothing errored and nothing
110
+ # was reported -- the file was simply not analysed, which for a security
111
+ # gate is the worst outcome: a silent net loss that looks like a pass.
112
+ # Sibling prefixes (src/api vs src/app) collapsed the same way.
97
113
  case "$SEMGREP_DIRS_SEEN" in
98
- *"$_semgrep_dir"*) : ;;
114
+ *$'\n'"$_semgrep_dir"$'\n'*) : ;;
99
115
  *) SEMGREP_ARGS+=("$_semgrep_dir"); SEMGREP_DIRS_SEEN="$SEMGREP_DIRS_SEEN$_semgrep_dir"$'\n' ;;
100
116
  esac
101
117
  done <<EOF
@@ -131,9 +147,41 @@ EOF
131
147
  # code is demoted to "did the tool itself run". --error stays OFF: it would
132
148
  # add nothing the JSON does not already provide, and keeping it off leaves
133
149
  # failure exit codes unambiguous for the #475 diagnostics path.
134
- SEMGREP_STDOUT_FILE=$(mktemp)
135
- SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
136
- SEMGREP_EXIT=$?
150
+ # #559: bound the run. Without a timeout, a wedged semgrep blocks the
151
+ # invocation for as long as its process lives; the fail-closed verdict logic
152
+ # below is correct but unreachable, so the hook hangs instead of judging.
153
+ # A gate that can wedge an interactive `git commit` gets bypassed with
154
+ # --no-verify, which is worse than any verdict it could have produced.
155
+ # `-k` after the primary signal: pysemgrep spinning in a syscall ignores
156
+ # SIGTERM, and the shipped 0.29 code used the same `-k 10` shape.
157
+ SEMGREP_TIMEOUT_S="${XP_GATE_SEMGREP_TIMEOUT:-180}"
158
+ SEMGREP_TIMED_OUT=false
159
+ if command -v timeout >/dev/null 2>&1; then
160
+ SEMGREP_STDOUT_FILE=$(mktemp)
161
+ SEMGREP_OUTPUT=$(timeout -k 10 "$SEMGREP_TIMEOUT_S" \
162
+ "$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check \
163
+ "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
164
+ SEMGREP_EXIT=$?
165
+ # 124 = timeout's own "I gave up" code. 137 = 128+SIGKILL, which is what
166
+ # `timeout -k` returns when the child ignored SIGTERM and had to be
167
+ # killed -- measured with a semgrep stand-in that traps TERM. Both mean
168
+ # "bounded, no verdict", so both must be reported as a timeout; checking
169
+ # only 124 told the user their semgrep was unparseable when in fact the
170
+ # scan had been cut off.
171
+ case "$SEMGREP_EXIT" in
172
+ 124|137) SEMGREP_TIMED_OUT=true ;;
173
+ esac
174
+ else
175
+ # No `timeout` on this platform (stock macOS). Say so: an unbounded run
176
+ # nobody mentioned is exactly how #559 stayed invisible for a whole
177
+ # release. The scan still runs -- refusing to scan would be a worse
178
+ # trade -- but the unbounded window is now stated.
179
+ echo " ⚠️ 'timeout' command not available — semgrep runs UNBOUNDED (install coreutils or set up gtimeout)"
180
+ SEMGREP_STDOUT_FILE=$(mktemp)
181
+ SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check \
182
+ "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
183
+ SEMGREP_EXIT=$?
184
+ fi
137
185
 
138
186
  # One node pass over the JSON: first line = verdict counts, remaining lines =
139
187
  # finding details. The severity vocabulary is the UNION of the two scales
@@ -175,7 +223,18 @@ console.log("");});
175
223
  if [ "$V_PARSE" != "OK" ] || [ -z "$V_RESULTS" ] || [ -z "$V_ERRORS" ] || [ -z "$V_BLOCKING" ] || [ -z "$V_WARNINGS" ]; then
176
224
  # Unparseable output: nothing the exit code claims can be trusted (#518).
177
225
  # Fail closed — SKIP with diagnostics, never a PASS.
178
- echo " ⚠️ semgrep output unparseable or verdict fields missing (exit code ${SEMGREP_EXIT}) — cannot determine verdict"
226
+ #
227
+ # #559: name a timeout as a timeout. The generic "unparseable" message
228
+ # would be true but useless here -- it points at the JSON parser when the
229
+ # real problem is that the scan was cut off. Same verdict (SKIP), but a
230
+ # diagnosis the reader can act on.
231
+ if [ "$SEMGREP_TIMED_OUT" = true ]; then
232
+ echo " ⏱️ semgrep exceeded ${SEMGREP_TIMEOUT_S}s and was killed — no verdict is possible"
233
+ echo " This is NOT a finding and NOT a clean scan. Raise XP_GATE_SEMGREP_TIMEOUT"
234
+ echo " if the repo legitimately takes longer; investigate if semgrep itself is wedged."
235
+ else
236
+ echo " ⚠️ semgrep output unparseable or verdict fields missing (exit code ${SEMGREP_EXIT}) — cannot determine verdict"
237
+ fi
179
238
  if [ -n "$SEMGREP_OUTPUT" ]; then
180
239
  echo " ── what semgrep reported on stderr ──"
181
240
  printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
@@ -184,11 +243,19 @@ console.log("");});
184
243
  echo " ── what semgrep wrote to stdout (tail) ──"
185
244
  printf '%s\n' "$SEMGREP_STDOUT_TAIL" | sed 's/^/ /'
186
245
  fi
187
- echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
188
- GATE_10_STATUS="SKIP"
189
- # Consumed by the sourcing parent (pre-commit report JSON), not here.
190
- # shellcheck disable=SC2034
191
- GATE_10_REASON="output_unparseable"
246
+ if [ "$SEMGREP_TIMED_OUT" = true ]; then
247
+ echo " ⏭️ SKIPPED - SAST (semgrep timed out after ${SEMGREP_TIMEOUT_S}s)"
248
+ GATE_10_STATUS="SKIP"
249
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
250
+ # shellcheck disable=SC2034
251
+ GATE_10_REASON="semgrep_timeout"
252
+ else
253
+ echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
254
+ GATE_10_STATUS="SKIP"
255
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
256
+ # shellcheck disable=SC2034
257
+ GATE_10_REASON="output_unparseable"
258
+ fi
192
259
  elif [ "$V_RESULTS" -eq 0 ] && { [ "$V_ERRORS" -gt 0 ] || [ "$SEMGREP_EXIT" -ne 0 ]; }; then
193
260
  # Nothing was scanned to a verdict: semgrep either failed outright
194
261
  # (nonzero exit) or completed but reported scan errors for every input
package/hooks/pre-push CHANGED
@@ -47,11 +47,28 @@ write_pre_push_report() {
47
47
  commit_hash=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
48
48
  verdict=$([ "$exit_code" -eq 0 ] && echo "PASS" || echo "BLOCKED")
49
49
  mkdir -p "$reports_dir"
50
+ # #544: the survivor detail is a JSON document, so it cannot be interpolated
51
+ # into this heredoc — a single quote in a path would break the Python source.
52
+ # Hand it to the child through the environment and let Python parse it, then
53
+ # store it serialised back into the `details` string domain (every value there
54
+ # is a string; the journal has broken before over mixed types).
55
+ export XP_GATE_M_SURVIVORS_JSON="${M_SURVIVORS_JSON:-}"
50
56
  python3 - <<PY 2>/dev/null || true
51
57
  import json
58
+ import os
52
59
  from pathlib import Path
53
60
  pushed_files = '''${PUSHED_FILES:-}'''.splitlines()
54
61
  passed = 1 if '$verdict' == 'PASS' else 0
62
+ # Absent stays absent: no key at all when nothing was measured, so a reader can
63
+ # tell "not measured" from "measured: zero survivors" (#544).
64
+ try:
65
+ _survivors_raw = os.environ.get('XP_GATE_M_SURVIVORS_JSON', '').strip()
66
+ survivors_detail = json.loads(_survivors_raw) if _survivors_raw else None
67
+ except (ValueError, TypeError):
68
+ survivors_detail = None
69
+ _m_details = {'skip_reason': '${GATE_M_SKIP_REASON:-}', 'duration_ms': '${M_DURATION_MS:-0}', 'mutation_score': '${M_SCORE:-}'}
70
+ if survivors_detail is not None:
71
+ _m_details['survivors'] = json.dumps(survivors_detail, sort_keys=True)
55
72
  report = {
56
73
  'timestamp': '$timestamp',
57
74
  'trigger': 'pre-push',
@@ -67,15 +84,20 @@ report = {
67
84
  'gates': [
68
85
  {'id': 'MP', 'name': 'Gate-Script Mirror Parity', 'status': '${GATE_MP_STATUS:-SKIP}', 'details': {'duration_ms': '${MP_DURATION_MS:-0}'}},
69
86
  {'id': '10', 'name': 'Build Integrity', 'status': '${GATE_10_STATUS:-SKIP}', 'details': {}},
70
- {'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': {'skip_reason': '${GATE_M_SKIP_REASON:-}', 'duration_ms': '${M_DURATION_MS:-0}', 'mutation_score': '${M_SCORE:-}'}},
71
- {'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PYTHON_STATUS:-SKIP}', 'details': {}},
87
+ {'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': _m_details},
88
+ # #557 follow-up: this line read GATE_M_PYTHON_STATUS while the code assigns
89
+ # GATE_M_PY_STATUS, so the field always fell back to its SKIP default -- the
90
+ # "never write the real state" bug #557 set out to fix, still present for
91
+ # this one gate. The skip reason now travels with it, so a retro reader can
92
+ # tell "no Python here" from "timed out" from "mutmut missing".
93
+ {'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PY_STATUS:-SKIP}', 'details': {'skip_reason': '${GATE_M_PY_SKIP_REASON:-}'}},
72
94
  {'id': 'M-Go', 'name': 'Mutation Testing (Go)', 'status': '${GATE_M_GO_STATUS:-SKIP}', 'details': {}},
73
95
  {'id': 'M-Java', 'name': 'Mutation Testing (Java)', 'status': '${GATE_M_JAVA_STATUS:-SKIP}', 'details': {}},
74
96
  {'id': 'M-Kotlin', 'name': 'Mutation Testing (Kotlin)', 'status': '${GATE_M_KOTLIN_STATUS:-SKIP}', 'details': {}},
75
97
  {'id': 'M2', 'name': 'Mock Density (WARNING only)', 'status': '${GATE_M2_STATUS:-SKIP}', 'details': {}},
76
98
  {'id': 'ML', 'name': 'Mock Layering', 'status': '${GATE_M3_STATUS:-SKIP}', 'details': {}},
77
99
  {'id': 'UI', 'name': 'UI Sprint Gates', 'status': '${GATE_UI_STATUS:-SKIP}', 'details': {}},
78
- {'id': 'MW', 'name': 'Code Walkthrough', 'status': '${GATE_DELPHI_STATUS:-SKIP}', 'details': {'remote': '$REMOTE', 'url': '$URL'}},
100
+ {'id': 'MW', 'name': 'Code Walkthrough', 'status': '${GATE_DELPHI_STATUS:-SKIP}', 'details': {'remote': '$REMOTE', 'url': '$URL', 'skip_reason': '${GATE_DELPHI_SKIP_REASON:-}'}},
79
101
  {'id': 'S', 'name': 'Sprint Flow Enforcement', 'status': '${GATE_S_STATUS:-SKIP}', 'details': {}}
80
102
  ],
81
103
  'warnings': [],
@@ -453,6 +475,15 @@ else
453
475
  # It is printed by gate-m.ts; take the last percentage it reported.
454
476
  M_SCORE=$(sed -n 's/.*[Mm]utation score[: ]*\([0-9][0-9]*\(\.[0-9][0-9]*\)\?\)%.*/\1/p' "$MUTATION_OUTPUT" 2>/dev/null | tail -1)
455
477
 
478
+ # #544: per-file survivor detail, so retro can answer "which module has
479
+ # the most surviving mutants". gate-m.ts emits one
480
+ # GATE_M_SURVIVORS_JSON= line; it is ABSENT when no report was produced,
481
+ # and absent must stay absent. An empty value means "not measured"; a
482
+ # written zero would mean "measured, no survivors". Only the former is
483
+ # ever true when the line is missing -- conflating the two is the whole
484
+ # of #544.
485
+ M_SURVIVORS_JSON=$(printf '%s\n' "$MUTATION_OUTPUT" | sed -n 's/^GATE_M_SURVIVORS_JSON=//p' | tail -1)
486
+
456
487
  case $MUTATION_EXIT in
457
488
  0)
458
489
  echo "✅ Gate M: PASS"
@@ -1066,6 +1097,15 @@ else
1066
1097
  # ============================================================================
1067
1098
  RESULT_FILE=".code-walkthrough-result.json"
1068
1099
  MW_SKIPPED=0
1100
+ # Journal status (#557 follow-up). The journal already read GATE_DELPHI_STATUS
1101
+ # but nothing ever assigned it, so Gate MW reported its SKIP default in every
1102
+ # report -- including pushes where the walkthrough was in fact verified. The
1103
+ # closing banner told the truth while the machine-readable journal did not, which
1104
+ # is the half-fix #557 was about.
1105
+ GATE_DELPHI_STATUS="SKIP"
1106
+ # Consumed by the journal heredoc (sourced/inherited boundary).
1107
+ # shellcheck disable=SC2034
1108
+ GATE_DELPHI_SKIP_REASON="not_run"
1069
1109
 
1070
1110
  # Skipped on main/master by design, exactly like Gate ML and Gate UI above.
1071
1111
  # Work is already walkthrough-reviewed on the feature branch before it lands, so
@@ -1075,6 +1115,9 @@ MW_SKIPPED=0
1075
1115
  # documented this skip; the code did not implement it (drift fixed here).
1076
1116
  if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then
1077
1117
  MW_SKIPPED=1
1118
+ # Consumed by the journal heredoc (sourced/inherited boundary).
1119
+ # shellcheck disable=SC2034
1120
+ GATE_DELPHI_SKIP_REASON="main_branch_push"
1078
1121
  echo ""
1079
1122
  echo " ℹ️ Gate MW skipped (pushing to ${CURRENT_BRANCH} — pre-reviewed via branch walkthrough)"
1080
1123
  echo ""
@@ -1144,6 +1187,13 @@ if ! VALIDATION_ERROR=$(node "$MW_VALIDATOR" "$RESULT_FILE" "$EXPECTED_COMMIT" "
1144
1187
  echo ""
1145
1188
  exit 1
1146
1189
  fi
1190
+
1191
+ # The validator passed: record that the walkthrough was actually verified.
1192
+ # Consumed by the journal heredoc (sourced/inherited boundary).
1193
+ # shellcheck disable=SC2034
1194
+ GATE_DELPHI_STATUS="PASS"
1195
+ # shellcheck disable=SC2034
1196
+ GATE_DELPHI_SKIP_REASON=""
1147
1197
  fi
1148
1198
 
1149
1199
  # ============================================================================
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-20T19:22:03.640Z",
5
+ "checked_at": "2026-10-21T05:03:41.149Z",
6
6
  "source": "network"
7
7
  }
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @test #544 - Gate M survivor detail for retro
3
+ * @intent Verify Gate M reports per-file mutant outcome counts, not just a
4
+ * score. #544: the pre-push journal carried
5
+ * {skip_reason, duration_ms, mutation_score} and nothing else, so
6
+ * `xp-gate retro` could not answer "which module has the most surviving
7
+ * mutants". The data was never missing at the source: Stryker's JSON
8
+ * reporter emits files[].nrOfSurvivedMutants, and stryker-types.ts
9
+ * already declares it. The counts were dropped on the way through, then
10
+ * buildMutationScores hardcoded {mutants: 0, killed: 0, survived: 0}.
11
+ * Three zeros written next to a real score read as "measured: no
12
+ * survivors" -- the same class of false success this project keeps
13
+ * fixing, so unknown must stay absent, never zero.
14
+ * @covers REQ-MUT-001 AC-001
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import {
19
+ summarizeSurvivors,
20
+ buildMutationScores,
21
+ type StrykerReportLike,
22
+ type SurvivorSummary,
23
+ } from '../gate-m';
24
+
25
+ /** A report shaped like Stryker's JSON output. */
26
+ function report(files: Record<string, [number, number, number]>): StrykerReportLike {
27
+ const entries = Object.entries(files);
28
+ const totalMutants = entries.reduce((a, [, v]) => a + v[0], 0);
29
+ const totalKilled = entries.reduce((a, [, v]) => a + v[1], 0);
30
+ const totalSurvived = entries.reduce((a, [, v]) => a + v[2], 0);
31
+ return {
32
+ mutationScore: totalMutants > 0 ? parseFloat(((totalKilled / totalMutants) * 100).toFixed(1)) : 100,
33
+ nrOfMutants: totalMutants,
34
+ nrOfKilledMutants: totalKilled,
35
+ nrOfSurvivedMutants: totalSurvived,
36
+ files: Object.fromEntries(
37
+ entries.map(([k, [m, kd, sv]]) => [k, {
38
+ mutationScore: m > 0 ? parseFloat(((kd / m) * 100).toFixed(1)) : 100,
39
+ nrOfMutants: m,
40
+ nrOfKilledMutants: kd,
41
+ nrOfSurvivedMutants: sv,
42
+ }])
43
+ ),
44
+ };
45
+ }
46
+
47
+ /** Narrow to a non-null summary, failing the test with a clear message. */
48
+ function expectSummary(s: SurvivorSummary | null): SurvivorSummary {
49
+ expect(s).not.toBeNull();
50
+ if (s === null) throw new Error('expected a survivor summary, got null');
51
+ return s;
52
+ }
53
+
54
+ describe('AC-544: Gate M survivor detail', () => {
55
+ it('reports per-file survived counts from the Stryker report', () => {
56
+ const summary = expectSummary(summarizeSurvivors(report({
57
+ 'src/mutation/gate-m.ts': [20, 8, 12],
58
+ 'src/principles/analyzer.ts': [10, 3, 7],
59
+ })));
60
+
61
+ expect(summary.survivedByFile).toEqual({
62
+ 'src/mutation/gate-m.ts': 12,
63
+ 'src/principles/analyzer.ts': 7,
64
+ });
65
+ });
66
+
67
+ it('totals killed, survived and mutants', () => {
68
+ const summary = expectSummary(summarizeSurvivors(report({
69
+ 'a.ts': [20, 8, 12],
70
+ 'b.ts': [10, 3, 7],
71
+ })));
72
+
73
+ expect(summary.mutants).toBe(30);
74
+ expect(summary.killed).toBe(11);
75
+ expect(summary.survived).toBe(19);
76
+ });
77
+
78
+ it('orders survivedByFile worst-first so retro can take the top N', () => {
79
+ const summary = expectSummary(summarizeSurvivors(report({
80
+ 'small.ts': [10, 9, 1],
81
+ 'worst.ts': [10, 0, 10],
82
+ 'middle.ts': [10, 5, 5],
83
+ })));
84
+
85
+ expect(Object.keys(summary.survivedByFile)).toEqual(['worst.ts', 'middle.ts', 'small.ts']);
86
+ });
87
+
88
+ it('caps the per-file map so one huge repo cannot bloat the journal', () => {
89
+ const many: Record<string, [number, number, number]> = {};
90
+ // Every file must have at least one survivor, otherwise this asserts on a
91
+ // smaller population than intended and the count below drifts.
92
+ for (let i = 0; i < 50; i++) {
93
+ const survived = (i % 9) + 1; // 1..9, never 0
94
+ many[`f${i}.ts`] = [10, 10 - survived, survived];
95
+ }
96
+
97
+ const summary = expectSummary(summarizeSurvivors(report(many), { limit: 20 }));
98
+
99
+ expect(Object.keys(summary.survivedByFile)).toHaveLength(20);
100
+ // Truncation must be visible, not silent: a capped list that looks complete
101
+ // is how "top 20" gets mistaken for "all files".
102
+ expect(summary.truncated).toBe(true);
103
+ expect(summary.filesWithSurvivors).toBe(50);
104
+ });
105
+
106
+ it('does not claim truncation when everything fits', () => {
107
+ const summary = expectSummary(summarizeSurvivors(report({ 'a.ts': [10, 5, 5] }), { limit: 20 }));
108
+ expect(summary.truncated).toBe(false);
109
+ });
110
+
111
+ it('omits files with zero survivors', () => {
112
+ const summary = expectSummary(summarizeSurvivors(report({
113
+ 'clean.ts': [10, 10, 0],
114
+ 'dirty.ts': [10, 4, 6],
115
+ })));
116
+
117
+ expect(summary.survivedByFile).toEqual({ 'dirty.ts': 6 });
118
+ });
119
+
120
+ it('degrades to null instead of inventing zeros when no report is available', () => {
121
+ // A missing/timed-out report must not be reported as "0 survivors" -- that
122
+ // is the #544 failure repeated. null means unknown.
123
+ expect(summarizeSurvivors(null)).toBeNull();
124
+ });
125
+
126
+ it('degrades to null when the report has no per-file section', () => {
127
+ expect(summarizeSurvivors({
128
+ mutationScore: 80,
129
+ nrOfMutants: 10,
130
+ nrOfKilledMutants: 8,
131
+ nrOfSurvivedMutants: 2,
132
+ })).toBeNull();
133
+ });
134
+ });
135
+
136
+ describe('buildMutationScores carries the real counts (#544)', () => {
137
+ it('reports measured mutant counts instead of hardcoded zeros', () => {
138
+ // The original defect: the score was real and the three counts beside it
139
+ // were literal zeros, which reads as "measured, no survivors". A consumer
140
+ // that trusts `survived: 0` concludes the module is adequately tested.
141
+ const scores = buildMutationScores({ 'a.ts': 78.4 }, {
142
+ 'a.ts': { mutants: 30, killed: 25, survived: 5 },
143
+ });
144
+
145
+ expect(scores['a.ts']).toEqual({ score: 78.4, mutants: 30, killed: 25, survived: 5 });
146
+ });
147
+
148
+ it('leaves counts absent when they were never measured', () => {
149
+ // Absent must stay absent -- not fabricated as zero.
150
+ const scores = buildMutationScores({ 'a.ts': 50 }, {});
151
+
152
+ expect(scores['a.ts'].score).toBe(50);
153
+ expect(scores['a.ts'].mutants).toBeUndefined();
154
+ expect(scores['a.ts'].survived).toBeUndefined();
155
+ });
156
+
157
+ it('keeps a genuinely zero-survivor file at zero', () => {
158
+ // The distinction this whole change is about: measured zero is a real,
159
+ // meaningful value and must survive the trip.
160
+ const scores = buildMutationScores({ 'clean.ts': 100 }, {
161
+ 'clean.ts': { mutants: 12, killed: 12, survived: 0 },
162
+ });
163
+
164
+ expect(scores['clean.ts'].survived).toBe(0);
165
+ expect(scores['clean.ts'].mutants).toBe(12);
166
+ });
167
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @test #544 - the shipped mutation module stays a mirror of the source module
3
+ * @intent src/npm-package/mutation/ is published to npm ("mutation/" is in the
4
+ * package `files` list) but has no automated mirror check, unlike the
5
+ * hooks and adapters which check-hook-mirror.sh diffs. It IS maintained
6
+ * as a byte-identical copy today, so a fix applied only to src/mutation/
7
+ * would ship stale code to every consumer -- exactly the v0.27.3 failure
8
+ * (#542) where a mirror-only fix was rolled back at publish. This guard
9
+ * makes that class of drift red instead of silent, and it is what caught
10
+ * the hardcoded zero counts still living in the package copy.
11
+ * @covers REQ-MUT-001 AC-001
12
+ */
13
+
14
+ import { describe, it, expect } from 'vitest';
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+
18
+ // This file lives at src/mutation/__tests__/, so the repo root is three levels
19
+ // up (unlike scripts/__tests__/, which is two).
20
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
21
+ const SRC = path.join(ROOT, 'src', 'mutation');
22
+ const PKG = path.join(ROOT, 'src', 'npm-package', 'mutation');
23
+
24
+ /** Files that must be identical in both trees. */
25
+ const MIRRORED = ['gate-m.ts', 'update-baseline.ts', 'types.ts', 'detect-ai-test.ts', 'init-baseline.ts', 'stryker-types.ts'];
26
+
27
+ describe('src/npm-package/mutation mirrors src/mutation', () => {
28
+ it.each(MIRRORED)('%s is byte-identical in both trees', (file) => {
29
+ const a = fs.readFileSync(path.join(SRC, file), 'utf8');
30
+ const b = fs.readFileSync(path.join(PKG, file), 'utf8');
31
+ expect(b, `${file} has drifted: edit src/mutation/${file} and copy it over`).toBe(a);
32
+ });
33
+
34
+ it('the mirror list is non-empty and every entry exists (anti-vacuity)', () => {
35
+ expect(MIRRORED.length).toBeGreaterThan(3);
36
+ for (const f of MIRRORED) {
37
+ expect(fs.existsSync(path.join(SRC, f)), `missing source ${f}`).toBe(true);
38
+ expect(fs.existsSync(path.join(PKG, f)), `missing mirror ${f}`).toBe(true);
39
+ }
40
+ });
41
+
42
+ it('the package copy carries the #544 fix, not the zero-filling original', () => {
43
+ // Anchor on the defect itself so this cannot pass with a stale-but-identical
44
+ // pair of pre-fix files.
45
+ const shipped = fs.readFileSync(path.join(PKG, 'gate-m.ts'), 'utf8');
46
+ expect(shipped).not.toContain('mutants: 0, killed: 0, survived: 0');
47
+ expect(shipped).toContain('MutantCounts');
48
+ });
49
+ });
@@ -225,12 +225,16 @@ describe('update-baseline.ts - Mutation Baseline Update CLI', () => {
225
225
  const written = JSON.parse(
226
226
  (mockFs.default.writeFile as ReturnType<typeof vi.fn>).mock.calls[0][1] as string
227
227
  );
228
+ // #544: a new entry built from a score alone must NOT be seeded with
229
+ // mutants/killed/survived = 0. This assertion previously pinned those
230
+ // zeros as the expected behaviour, which made "nobody measured this file"
231
+ // indistinguishable from "measured, zero survivors" in every baseline
232
+ // written from the CLI. The fields are omitted instead.
228
233
  expect(written.scores['src/new.ts']).toEqual({
229
234
  score: 72.0,
230
- mutants: 0,
231
- killed: 0,
232
- survived: 0
233
235
  });
236
+ expect(written.scores['src/new.ts'].mutants).toBeUndefined();
237
+ expect(written.scores['src/new.ts'].survived).toBeUndefined();
234
238
  // Old entry should still be present
235
239
  expect(written.scores['src/old.ts'].score).toBe(60.0);
236
240
  });
@@ -438,8 +438,9 @@ async function runAllRunners(
438
438
  groups: Map<string, string[]>,
439
439
  timeoutMs: number,
440
440
  cwd: string
441
- ): Promise<Record<string, number | null>> {
441
+ ): Promise<{ scores: Record<string, number | null>; counts: Record<string, MutantCounts> }> {
442
442
  const fileScores: Record<string, number | null> = {};
443
+ const counts: Record<string, MutantCounts> = {};
443
444
 
444
445
  for (const [, files] of Array.from(groups.entries())) {
445
446
  const ext = path.extname(files[0]);
@@ -455,10 +456,10 @@ async function runAllRunners(
455
456
  }
456
457
 
457
458
  const result = await runner.run({ files, timeoutMs, cwd });
458
- applyMutationResult(result, files, fileScores);
459
+ applyMutationResult(result, files, fileScores, counts);
459
460
  }
460
461
 
461
- return fileScores;
462
+ return { scores: fileScores, counts };
462
463
  }
463
464
 
464
465
  /**
@@ -481,6 +482,7 @@ function applyMutationResult(
481
482
  outcome: MutationRunOutcome,
482
483
  files: string[],
483
484
  scores: Record<string, number | null>,
485
+ counts: Record<string, MutantCounts>,
484
486
  ): void {
485
487
  if (outcome.timedOut || outcome.error || !outcome.report) {
486
488
  for (const f of files) scores[f] = null;
@@ -490,11 +492,26 @@ function applyMutationResult(
490
492
  if (outcome.report.files) {
491
493
  for (const [fileKey, fileReport] of Object.entries(outcome.report.files)) {
492
494
  scores[fileKey] = fileReport.mutationScore;
495
+ // #544: keep the outcome counts the reporter already gave us. Dropping
496
+ // them here is what forced buildMutationScores to invent zeros later.
497
+ counts[fileKey] = {
498
+ mutants: fileReport.nrOfMutants,
499
+ killed: fileReport.nrOfKilledMutants,
500
+ survived: fileReport.nrOfSurvivedMutants,
501
+ };
493
502
  }
494
503
  return;
495
504
  }
496
505
 
497
- for (const f of files) scores[f] = outcome.report.mutationScore;
506
+ const totals: MutantCounts = {
507
+ mutants: outcome.report.nrOfMutants,
508
+ killed: outcome.report.nrOfKilledMutants,
509
+ survived: outcome.report.nrOfSurvivedMutants,
510
+ };
511
+ for (const f of files) {
512
+ scores[f] = outcome.report.mutationScore;
513
+ counts[f] = totals;
514
+ }
498
515
  }
499
516
 
500
517
  // ── Main gateway ──
@@ -514,14 +531,117 @@ function logThresholdDetails(thresholds: FileThreshold[], baseline: MutationBase
514
531
  }
515
532
  }
516
533
 
517
- function buildMutationScores(fileScores: Record<string, number>): Record<string, MutationScore> {
534
+ /**
535
+ * Per-file mutant outcome counts, as reported by the runner. Absent fields mean
536
+ * "the runner did not say", which is different from a measured zero -- the whole
537
+ * point of #544 is that those two must not collapse into each other.
538
+ */
539
+ export interface MutantCounts {
540
+ mutants?: number;
541
+ killed?: number;
542
+ survived?: number;
543
+ }
544
+
545
+ export function buildMutationScores(
546
+ fileScores: Record<string, number>,
547
+ counts: Record<string, MutantCounts> = {},
548
+ ): Record<string, MutationScore> {
518
549
  const scores: Record<string, MutationScore> = {};
519
550
  for (const [file, score] of Object.entries(fileScores)) {
520
- scores[file] = { score, mutants: 0, killed: 0, survived: 0 };
551
+ const c = counts[file];
552
+ const entry: MutationScore = { score } as MutationScore;
553
+ // Only attach what the runner actually reported. Writing `survived: 0` for
554
+ // an unmeasured file is what made a skipped run look like a clean one.
555
+ if (c) {
556
+ if (c.mutants !== undefined) entry.mutants = c.mutants;
557
+ if (c.killed !== undefined) entry.killed = c.killed;
558
+ if (c.survived !== undefined) entry.survived = c.survived;
559
+ }
560
+ scores[file] = entry;
521
561
  }
522
562
  return scores;
523
563
  }
524
564
 
565
+ /**
566
+ * The subset of a runner report that survivor summarisation needs. Declared
567
+ * structurally rather than as a concrete Stryker type because every runner
568
+ * (Stryker, mutmut, gremlins, go-mutant) normalises to `MutationRunResult`,
569
+ * which carries the same field names -- the summary must work for all of them,
570
+ * not just the TS one.
571
+ */
572
+ export interface StrykerReportLike {
573
+ mutationScore: number;
574
+ nrOfMutants: number;
575
+ nrOfKilledMutants: number;
576
+ nrOfSurvivedMutants: number;
577
+ files?: Record<string, {
578
+ mutationScore: number;
579
+ nrOfMutants: number;
580
+ nrOfKilledMutants: number;
581
+ nrOfSurvivedMutants: number;
582
+ }>;
583
+ }
584
+
585
+ /** Per-file mutant outcome detail for the pre-push journal (#544). */
586
+ export interface SurvivorSummary {
587
+ /** Files with at least one surviving mutant, worst first, capped by `limit`. */
588
+ survivedByFile: Record<string, number>;
589
+ /**
590
+ * How many files actually had survivors. Present so a capped map can be told
591
+ * apart from a complete one -- a truncated list that looks exhaustive is how
592
+ * "top 20" gets read as "all files".
593
+ */
594
+ filesWithSurvivors: number;
595
+ /** True when `survivedByFile` dropped entries to stay under `limit`. */
596
+ truncated: boolean;
597
+ mutants: number;
598
+ killed: number;
599
+ survived: number;
600
+ }
601
+
602
+ /**
603
+ * Condense a Stryker report into survivor detail (#544).
604
+ *
605
+ * Returns `null` -- never a summary of zeros -- when the report is missing or
606
+ * carries no per-file section. A timed-out or failed run has no measurements,
607
+ * and writing `survived: 0` next to a real-looking score would report
608
+ * "measured: no survivors" for a run that measured nothing. That is the exact
609
+ * shape of false success this project keeps finding, so unknown stays `null`.
610
+ */
611
+ export function summarizeSurvivors(
612
+ report: StrykerReportLike | null | undefined,
613
+ options: { limit?: number } = {},
614
+ ): SurvivorSummary | null {
615
+ if (!report || !report.files) return null;
616
+
617
+ const limit = options.limit ?? 20;
618
+ const survivors: Array<[string, number]> = [];
619
+ let mutants = 0;
620
+ let killed = 0;
621
+ let survived = 0;
622
+
623
+ for (const [file, entry] of Object.entries(report.files)) {
624
+ const fileSurvived = entry.nrOfSurvivedMutants ?? 0;
625
+ mutants += entry.nrOfMutants ?? 0;
626
+ killed += entry.nrOfKilledMutants ?? 0;
627
+ survived += fileSurvived;
628
+ if (fileSurvived > 0) survivors.push([file, fileSurvived]);
629
+ }
630
+
631
+ // Worst first, then by path so equal counts order deterministically.
632
+ survivors.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
633
+ const kept = survivors.slice(0, limit);
634
+
635
+ return {
636
+ survivedByFile: Object.fromEntries(kept),
637
+ filesWithSurvivors: survivors.length,
638
+ truncated: survivors.length > kept.length,
639
+ mutants,
640
+ killed,
641
+ survived,
642
+ };
643
+ }
644
+
525
645
  export async function runGateM(options: GateMOptions): Promise<GateMResult> {
526
646
  const warnings: string[] = [];
527
647
  const errors: string[] = [];
@@ -539,7 +659,8 @@ export async function runGateM(options: GateMOptions): Promise<GateMResult> {
539
659
  logThresholdDetails(thresholds, baseline);
540
660
 
541
661
  const groups = groupByRunner(sourceFiles);
542
- const fileScoresNullable = await runAllRunners(groups, options.timeoutMs, process.cwd());
662
+ const { scores: fileScoresNullable, counts: mutantCounts } =
663
+ await runAllRunners(groups, options.timeoutMs, process.cwd());
543
664
 
544
665
  const { fileScores, timeoutFiles } = partitionScores(fileScoresNullable);
545
666
  if (timeoutFiles.length > 0) {
@@ -557,12 +678,12 @@ export async function runGateM(options: GateMOptions): Promise<GateMResult> {
557
678
  }
558
679
 
559
680
  if (evalResult.blocked) {
560
- return buildResult('block', evaluableThresholds.length, buildMutationScores(fileScores), warnings, errors);
681
+ return buildResult('block', evaluableThresholds.length, buildMutationScores(fileScores, mutantCounts), warnings, errors);
561
682
  }
562
683
  if (evaluableThresholds.length === 0) {
563
684
  return buildResult('skip', 0, {}, warnings, errors);
564
685
  }
565
- return buildResult('pass', evaluableThresholds.length, buildMutationScores(fileScores), warnings, errors);
686
+ return buildResult('pass', evaluableThresholds.length, buildMutationScores(fileScores, mutantCounts), warnings, errors);
566
687
  }
567
688
 
568
689
  interface PartitionedScores {
@@ -595,6 +716,22 @@ async function collectTestIntentWarnings(sourceFiles: string[], warnings: string
595
716
  }
596
717
  }
597
718
 
719
+ /**
720
+ * Read the mutation report the runner left on disk and summarise it (#544).
721
+ * Returns null when there is no readable report -- the caller then prints
722
+ * nothing, and the journal field stays absent. Every failure here is silent by
723
+ * design: #544 explicitly requires that missing detail must not turn Gate M
724
+ * itself into a failure.
725
+ */
726
+ async function readSurvivorSummary(): Promise<SurvivorSummary | null> {
727
+ try {
728
+ const raw = await fs.readFile('.stryker-report.json', 'utf-8');
729
+ return summarizeSurvivors(JSON.parse(raw) as StrykerReportLike);
730
+ } catch {
731
+ return null;
732
+ }
733
+ }
734
+
598
735
  export async function main(args: string[]): Promise<number> {
599
736
  const options = parseArgs(args);
600
737
 
@@ -611,19 +748,14 @@ export async function main(args: string[]): Promise<number> {
611
748
 
612
749
  const result = await runGateM(options);
613
750
 
614
- if (result.warnings.length > 0) {
615
- console.log('\nWarnings:');
616
- for (const warning of result.warnings) {
617
- console.log(` ⚠ ${warning}`);
618
- }
751
+ // #544: survivor detail on a machine-readable line. Printed only when a report
752
+ // exists -- absent means "not measured", never "measured: zero survivors".
753
+ const survivors = await readSurvivorSummary();
754
+ if (survivors) {
755
+ console.log(`GATE_M_SURVIVORS_JSON=${JSON.stringify(survivors)}`);
619
756
  }
620
757
 
621
- if (result.errors.length > 0) {
622
- console.log('\nErrors:');
623
- for (const error of result.errors) {
624
- console.error(` ✗ ${error}`);
625
- }
626
- }
758
+ logDiagnostics(result.warnings, result.errors);
627
759
 
628
760
  const statusIcon = result.status === 'pass' ? '✓' :
629
761
  result.status === 'skip' ? '⊘' :
@@ -634,6 +766,18 @@ export async function main(args: string[]): Promise<number> {
634
766
  return result.exitCode;
635
767
  }
636
768
 
769
+ /** Print Gate M's warnings and errors. Errors go to stderr. */
770
+ function logDiagnostics(warnings: string[], errors: string[]): void {
771
+ if (warnings.length > 0) {
772
+ console.log('\nWarnings:');
773
+ for (const warning of warnings) console.log(` ⚠ ${warning}`);
774
+ }
775
+ if (errors.length > 0) {
776
+ console.log('\nErrors:');
777
+ for (const error of errors) console.error(` ✗ ${error}`);
778
+ }
779
+ }
780
+
637
781
  const cliArgs = process.argv.slice(2);
638
782
  if (typeof require !== 'undefined' && require.main === module) {
639
783
  main(cliArgs)
package/mutation/types.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  export interface MutationScore {
2
2
  score: number;
3
- mutants: number;
4
- killed: number;
5
- survived: number;
3
+ /**
4
+ * Mutant outcome counts. Optional on purpose (#544): a runner that did not
5
+ * report them must leave them absent, so a consumer can tell "not measured"
6
+ * from "measured zero". They used to be hardcoded to 0, which made every
7
+ * unmeasured file read as a clean one.
8
+ */
9
+ mutants?: number;
10
+ killed?: number;
11
+ survived?: number;
6
12
  }
7
13
 
8
14
  export interface MutationBaseline {
@@ -3,9 +3,14 @@ import path from 'path';
3
3
 
4
4
  interface MutationScoreEntry {
5
5
  score: number;
6
- mutants: number;
7
- killed: number;
8
- survived: number;
6
+ /**
7
+ * Optional (#544): a baseline entry built from a score alone has no mutant
8
+ * counts, and inventing zeros would record a clean file that was never
9
+ * measured.
10
+ */
11
+ mutants?: number;
12
+ killed?: number;
13
+ survived?: number;
9
14
  }
10
15
 
11
16
  interface MutationBaseline {
@@ -92,11 +97,13 @@ function updateBaseline(
92
97
  score: parseFloat(score.toFixed(1))
93
98
  };
94
99
  } else {
100
+ // #544: do not seed the baseline with zeros. This path only has a score
101
+ // from the hand-edited/CLI score list and no mutant counts at all, so
102
+ // writing mutants/killed/survived = 0 would record "measured, no
103
+ // survivors" for a file nobody measured. Absent means unknown, and the
104
+ // fields are optional on MutationScoreEntry for exactly this reason.
95
105
  updatedScores[filePath] = {
96
106
  score: parseFloat(score.toFixed(1)),
97
- mutants: 0,
98
- killed: 0,
99
- survived: 0
100
107
  };
101
108
  }
102
109
  }
@@ -127,7 +134,17 @@ async function removeDeletedFiles(baseline: MutationBaseline): Promise<MutationB
127
134
  }
128
135
 
129
136
  async function saveBaseline(baseline: MutationBaseline, baselinePath: string): Promise<void> {
130
- await fs.writeFile(baselinePath, JSON.stringify(baseline, null, 2));
137
+ // A failed write used to propagate as a bare rejection from deep inside the
138
+ // CLI's top-level await, producing an unhandled-rejection stack with no
139
+ // indication of WHICH file could not be written -- and on a permission or
140
+ // disk-full error the baseline silently stayed stale while the run looked
141
+ // complete. Report the path and the reason, then fail loudly.
142
+ try {
143
+ await fs.writeFile(baselinePath, JSON.stringify(baseline, null, 2));
144
+ } catch (err) {
145
+ const reason = err instanceof Error ? err.message : String(err);
146
+ throw new Error(`Failed to write mutation baseline to ${baselinePath}: ${reason}`);
147
+ }
131
148
  }
132
149
 
133
150
  async function main(): Promise<void> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.30.3",
3
+ "version": "0.30.5",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.30.3",
3
+ "version": "0.30.5",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/dsh-plugin-xp-gate",
3
- "version": "0.30.3",
3
+ "version": "0.30.5",
4
4
  "description": "XP-Gate deterministic quality gates + AI workflow skills as a DeepSeek Harness (DSH) native plugin: gate-check / gate-principles / gate-arch tools + 12 SKILL.md skills",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/opencode-plugin",
3
- "version": "0.30.3",
3
+ "version": "0.30.5",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "XP-Gate quality gates + AI workflow skills + Sprint Flow TUI sidebar for OpenCode",
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.30.3",
3
+ "version": "0.30.5",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Qoder. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,9 +1,9 @@
1
1
  # PRINCIPLES CHECKER MODULE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Clean Code & SOLID principles checker — **Gate 4** of pre-commit. 14 rules × 9 language adapters, SARIF 2.1.0 output. Houses the **Boy Scout Rule** enforcement engine (Gate 6) and warning-baseline storage.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-20
4
- **Commit:** 03f6d10a
3
+ **Generated:** 2026-10-21
4
+ **Commit:** 4e1f8be0
5
5
  **Branch:** main
6
- **Version:** 0.30.3.0
6
+ **Version:** 0.30.5.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.