okstra 0.143.0 → 0.145.0

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 (46) hide show
  1. package/README.md +4 -1
  2. package/docs/architecture.md +18 -2
  3. package/docs/cli.md +39 -2
  4. package/docs/project-structure-overview.md +19 -6
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/coding-preflight/overview.md +1 -1
  8. package/runtime/prompts/lead/convergence.md +11 -3
  9. package/runtime/prompts/lead/okstra-lead-contract.md +7 -1
  10. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  11. package/runtime/prompts/profiles/_common-contract.md +1 -1
  12. package/runtime/prompts/profiles/_implementation-verifier.md +48 -2
  13. package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
  14. package/runtime/prompts/profiles/feature-analysis.md +24 -0
  15. package/runtime/prompts/profiles/forbidden-actions.json +18 -0
  16. package/runtime/prompts/profiles/project-analysis.md +24 -0
  17. package/runtime/prompts/wizard/prompts.ko.json +44 -1
  18. package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
  19. package/runtime/python/okstra_ctl/clarification_items.py +74 -1
  20. package/runtime/python/okstra_ctl/mutation_probe.py +1263 -0
  21. package/runtime/python/okstra_ctl/render.py +77 -4
  22. package/runtime/python/okstra_ctl/render_final_report.py +13 -4
  23. package/runtime/python/okstra_ctl/report_views.py +134 -3
  24. package/runtime/python/okstra_ctl/run.py +118 -0
  25. package/runtime/python/okstra_ctl/run_context.py +34 -2
  26. package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
  27. package/runtime/python/okstra_ctl/self_mock_signals.py +183 -0
  28. package/runtime/python/okstra_ctl/user_response.py +309 -3
  29. package/runtime/python/okstra_ctl/wizard.py +545 -32
  30. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  31. package/runtime/python/okstra_ctl/workflow.py +22 -0
  32. package/runtime/schemas/final-report-v1.0.schema.json +849 -3
  33. package/runtime/skills/okstra-run/SKILL.md +13 -1
  34. package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
  35. package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
  36. package/runtime/templates/reports/final-report.template.md +220 -0
  37. package/runtime/templates/reports/i18n/en.json +8 -0
  38. package/runtime/templates/reports/i18n/ko.json +8 -0
  39. package/runtime/templates/reports/project-analysis-input.template.md +58 -0
  40. package/runtime/templates/reports/report.js +84 -5
  41. package/runtime/templates/reports/user-response.template.md +19 -1
  42. package/runtime/validators/detect_self_mock.py +220 -0
  43. package/runtime/validators/validate-report-views.py +61 -7
  44. package/runtime/validators/validate-run.py +518 -0
  45. package/runtime/validators/validate_analysis_report.py +864 -0
  46. package/src/commands/execute/render-bundle.mjs +3 -0
@@ -7,6 +7,7 @@ import hashlib
7
7
  import importlib.util
8
8
  import json
9
9
  import os
10
+ import posixpath
10
11
  import re
11
12
  import shlex
12
13
  import sys
@@ -20,6 +21,8 @@ from typing import Any
20
21
  # copy under ~/.okstra/lib. The manifests advertise the bare script path, so
21
22
  # it must self-bootstrap rather than rely on an inherited PYTHONPATH.
22
23
  _VALIDATORS_DIR = Path(__file__).resolve().parent
24
+ if str(_VALIDATORS_DIR) not in sys.path:
25
+ sys.path.insert(0, str(_VALIDATORS_DIR))
23
26
  for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
24
27
  if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
25
28
  sys.path.insert(0, str(_ssot_dir))
@@ -52,6 +55,11 @@ from okstra_ctl.build_tools import ( # noqa: E402
52
55
  command_invokes_build_tool,
53
56
  resolve_build_tool_tokens,
54
57
  )
58
+ from okstra_ctl.mutation_probe import ( # noqa: E402
59
+ INTEGRITY_INSPECTION,
60
+ classify_reason,
61
+ )
62
+ from okstra_ctl.self_mock_signals import selfmock_path_key # noqa: E402
55
63
  from okstra_ctl.stage_citations import cited_stage_numbers # noqa: E402
56
64
  from okstra_ctl.workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE # noqa: E402
57
65
  from okstra_ctl.md_table import ( # noqa: E402
@@ -90,6 +98,7 @@ from okstra_ctl.worker_prompt_contract import ( # noqa: E402
90
98
  PromptRecord,
91
99
  validate_initial_prompt_records,
92
100
  )
101
+ from validate_analysis_report import validate_analysis_report # noqa: E402
93
102
  from okstra_ctl.convergence_engine import ( # noqa: E402
94
103
  grouped_input_digest,
95
104
  validate_final_state,
@@ -1679,6 +1688,475 @@ def _validate_conformance(
1679
1688
  return warnings
1680
1689
 
1681
1690
 
1691
+ _TEST_FILE_RE = re.compile(r"(\.spec\.|\.test\.|(^|/)test_|_test\.|(^|/)tests?/)")
1692
+ # okstra ships deliberately self-mocked samples as detector fixtures; gating
1693
+ # them would make okstra permanently fail its own gate when verifying itself.
1694
+ _SELFMOCK_FIXTURE_PREFIX = "tests/fixtures/self_mock/"
1695
+
1696
+
1697
+ _SELFMOCK_WAIVER_FILENAME = "self-mock-waivers.json"
1698
+
1699
+
1700
+ def _selfmock_source_is_canonical(source: object, canonical: Path) -> bool:
1701
+ """True when `waiverSource` names the task's own waiver file.
1702
+
1703
+ Compared on the whole task-scoped path, never a tail: `/tmp/qa/` +
1704
+ `self-mock-waivers.json` reproduces the canonical basename exactly, so a
1705
+ short-suffix comparison would hand the redirect straight back. A LONGER
1706
+ absolute prefix on the recorded source is accepted — macOS resolves a
1707
+ `/var/...` task root to `/private/var/...` — because the task-scoped segments
1708
+ are what carry the property, and false-blocking a legitimate run over a
1709
+ realpath prefix costs more than that leniency does. The reverse (a source
1710
+ SHORTER than the canonical path) is refused: a bare relative
1711
+ `qa/self-mock-waivers.json` resolves against the worktree cwd, which is a
1712
+ file the run can write.
1713
+ """
1714
+ if not isinstance(source, str) or not source.strip():
1715
+ return False
1716
+ key = selfmock_path_key(source)
1717
+ want = selfmock_path_key(str(canonical))
1718
+ return key == want or key.endswith("/" + want.lstrip("/"))
1719
+
1720
+
1721
+ def _selfmock_mutation_waiver_failure(mutation: object, sidecar: Path) -> str | None:
1722
+ """The BLOCKING message for an invalid mutation waiver set, else None.
1723
+
1724
+ The exact mirror of `_selfmock_waiver_failure`, because gate B's escape hatch
1725
+ carries the same risk gate A's does: it is the one input that can talk the
1726
+ gate out of a finding about the code this run just wrote. So a waived
1727
+ surviving mutant needs a `reason` that makes it reviewable and the
1728
+ `acknowledgedBy` of the USER who accepted it, and the entry has to have been
1729
+ read from the task's own `qa/self-mock-waivers.json` — the same file gate A
1730
+ uses, so the user manages one place and the source stays singular.
1731
+
1732
+ `mutation_probe._apply_waivers` deliberately matches without adjudicating and
1733
+ carries an unacknowledged entry into `waived`, so it surfaces here instead of
1734
+ being silently dropped by the run that produced the finding.
1735
+ """
1736
+ if not isinstance(mutation, dict):
1737
+ return None
1738
+ waived = mutation.get("waived")
1739
+ if not waived:
1740
+ # Nothing was waived, so there is no acknowledgement to locate.
1741
+ return None
1742
+ problems: list[str] = []
1743
+ if not _selfmock_source_is_canonical(
1744
+ mutation.get("waiverSource"), sidecar.parent / _SELFMOCK_WAIVER_FILENAME
1745
+ ):
1746
+ problems.append(
1747
+ f"mutation.waiverSource must be the task's own "
1748
+ f"{sidecar.parent / _SELFMOCK_WAIVER_FILENAME}, got "
1749
+ f"{mutation.get('waiverSource')!r}"
1750
+ )
1751
+ if not isinstance(waived, list):
1752
+ problems.append("mutation.waived must be an array of waiver objects")
1753
+ else:
1754
+ for idx, entry in enumerate(waived):
1755
+ if not isinstance(entry, dict):
1756
+ problems.append(f"mutation.waived[{idx}] must be an object")
1757
+ continue
1758
+ for field in ("reason", "acknowledgedBy"):
1759
+ value = entry.get(field)
1760
+ if not isinstance(value, str) or not value.strip():
1761
+ problems.append(
1762
+ f"mutation.waived[{idx}].{field} must be a non-empty string"
1763
+ )
1764
+ if not problems:
1765
+ return None
1766
+ return (
1767
+ f"self-mock gate BLOCKING: {sidecar} carries invalid mutation waiver(s): "
1768
+ + "; ".join(problems)
1769
+ + ". A waived surviving mutant needs a `reason` and the `acknowledgedBy` of "
1770
+ "the user who accepted it, read from the task's own "
1771
+ "`qa/self-mock-waivers.json` — a verifier may report a suspected "
1772
+ "false-positive mutant, but only the user waives one, and the "
1773
+ "acknowledgement has to land in the task bundle where it can be reviewed. "
1774
+ f"Add the entry to {sidecar.parent / _SELFMOCK_WAIVER_FILENAME} (keyed on "
1775
+ "`file` + `line` + `mutant`, alongside any `signal` entries) and re-run the "
1776
+ "detector with `--waivers` pointing there; never hand-edit the sidecar."
1777
+ )
1778
+
1779
+
1780
+ def _selfmock_capped_note(mutation: dict, survived: list) -> str:
1781
+ """Explain a survivor list the report cap trimmed, or `""` when it did not.
1782
+
1783
+ The cap makes an EMPTY `survived` reachable on a FAIL — every listed mutant
1784
+ waived while the trimmed ones still stand — and a bare "[]" is
1785
+ indistinguishable from a gate bug. Naming the counts and the way out keeps
1786
+ this message actionable like every other block in this gate: the unlisted
1787
+ mutants cannot be waived, so the only route through is killing them.
1788
+ """
1789
+ total = mutation.get("survivedTotal")
1790
+ if not isinstance(total, int) or total <= len(survived):
1791
+ return ""
1792
+ waived = mutation.get("waived")
1793
+ waived_count = len(waived) if isinstance(waived, list) else 0
1794
+ unlisted = max(total - len(survived) - waived_count, 0)
1795
+ return (
1796
+ f" The report lists {len(survived)} of {total} surviving mutant(s) found on "
1797
+ f"changed lines ({waived_count} waived); {unlisted} were trimmed by the "
1798
+ "report cap and cannot be waived — the only way past them is to make the "
1799
+ "tests kill them."
1800
+ )
1801
+
1802
+
1803
+ def _selfmock_mutation_failure(mutation: object, sidecar: Path) -> str | None:
1804
+ """Gate B's contribution to the fail-closed verdict, or `None` when it passes.
1805
+
1806
+ Three outcomes, and only one of them blocks:
1807
+
1808
+ - `FAIL` — a mutant of a changed line went undetected by the stage's own
1809
+ suite, so nothing in it constrains that line. Blocks like a static hit.
1810
+ - `unsupported(<reason>)` — splits by the reason's CLASS
1811
+ (`mutation_probe.classify_reason`). A capability gap (no adapter, no tool
1812
+ installed) or a language with nothing to verify is excluded from the
1813
+ verdict and kept in the sidecar for audit: that is the normal case in a
1814
+ repo without mutation tooling and must never block. An integrity or
1815
+ inspection failure BLOCKS — a diff the verifier built wrong, or a report
1816
+ the tool left unreadable, means nothing was checked, and passing it as
1817
+ "excluded" is the fail-open this gate exists to prevent.
1818
+ - `PASS` — nothing to add.
1819
+
1820
+ A block the gate cannot read is itself blocking: the detector writes this
1821
+ structure unconditionally, so a missing or misshapen one means the sidecar
1822
+ was hand-edited, and reading it as a pass is the fail-open this gate exists
1823
+ to prevent.
1824
+ """
1825
+ if not isinstance(mutation, dict):
1826
+ failure = "absent" if mutation is None else "not an object"
1827
+ return (
1828
+ f"self-mock gate BLOCKING: {sidecar} has a mutation block that is "
1829
+ f"{failure}. The detector writes it on every run, so a sidecar "
1830
+ "without one was hand-edited; re-run detect_self_mock.py."
1831
+ )
1832
+ status = mutation.get("status")
1833
+ shape = _selfmock_mutation_shape_error(mutation, status)
1834
+ if shape:
1835
+ return (
1836
+ f"self-mock gate BLOCKING: {sidecar} mutation block is malformed "
1837
+ f"({shape}). Never hand-edit the sidecar — re-run "
1838
+ "detect_self_mock.py so the probe writes it."
1839
+ )
1840
+ if status.startswith("unsupported("):
1841
+ # Task 9 made every `unsupported` non-blocking so that a repo with no
1842
+ # mutation tooling could never wedge. That is right for a boundary gate B
1843
+ # declared in advance, and wrong for a fault in this run: a `--diff` the
1844
+ # verifier built incorrectly, or a report the tool left unreadable, means
1845
+ # the stage was never checked at all. The class comes from the one SSOT
1846
+ # in `mutation_probe.py`, the same table the cross-language merge reads.
1847
+ if classify_reason(status) != INTEGRITY_INSPECTION:
1848
+ return None
1849
+ return (
1850
+ f"self-mock gate BLOCKING: {sidecar} mutation={status!r} — gate B could "
1851
+ "not inspect this stage, so its PASS covers the static scan only. This "
1852
+ "is a fault in the run's own inputs or output, not a missing tool. Fix "
1853
+ "and re-run the detector: write the diff with "
1854
+ "`git diff <base>...HEAD > <task_root>/qa/self-mock-<stage-name>.diff` "
1855
+ "using the SAME `<base>` the `--changed-file` list came from "
1856
+ "(`git diff --name-only <base>...HEAD`), pass every changed file — "
1857
+ "production sources included — and confirm the mutation tool wrote a "
1858
+ "readable report. Never hand-edit the sidecar."
1859
+ )
1860
+ if status != "FAIL":
1861
+ return None
1862
+ survived = mutation.get("survived") or []
1863
+ listed = ", ".join(
1864
+ f"{s.get('file')}:{s.get('line')} {s.get('mutant')} ({s.get('status')})"
1865
+ for s in survived
1866
+ if isinstance(s, dict)
1867
+ )
1868
+ return (
1869
+ f"self-mock gate BLOCKING: {sidecar} mutation={status!r} "
1870
+ f"tool={mutation.get('tool')!r}, undetected mutants on changed lines: "
1871
+ f"[{listed}].{_selfmock_capped_note(mutation, survived)} A mutant that "
1872
+ "survives means no test constrains that line — "
1873
+ "`Survived` says the test ran the code and asserted nothing about it, "
1874
+ "`NoCoverage` says no test reached the code at all. Assert on the real "
1875
+ "behaviour instead of on the test's own wiring."
1876
+ )
1877
+
1878
+
1879
+ def _selfmock_mutation_shape_error(mutation: dict, status: object) -> str | None:
1880
+ """Name the first structural problem with a mutation block, or `None`."""
1881
+ if not isinstance(status, str):
1882
+ return "`status` is missing or not a string"
1883
+ known = status in ("PASS", "FAIL") or (
1884
+ status.startswith("unsupported(")
1885
+ and status.endswith(")")
1886
+ # An empty reason switches gate B off while naming nothing actionable.
1887
+ and status[len("unsupported(") : -1].strip()
1888
+ )
1889
+ if not known:
1890
+ return f"`status` {status!r} is outside PASS / FAIL / unsupported(<reason>)"
1891
+ for field in ("survived", "waived"):
1892
+ if not isinstance(mutation.get(field), list):
1893
+ return f"`{field}` is missing or not a list"
1894
+ if not isinstance(mutation.get("tool"), (str, type(None))):
1895
+ return "`tool` is neither a string nor null"
1896
+ return None
1897
+
1898
+
1899
+ def _selfmock_waiver_failure(static: dict, sidecar: Path) -> str | None:
1900
+ """The BLOCKING message for an invalid self-mock waiver set, else None.
1901
+
1902
+ A waiver is the false-positive escape hatch, and it is the ONE place the gate
1903
+ can be talked out of a finding — so it carries the same shape a conformance
1904
+ waiver does (`okstra_ctl/conformance.py::_check_waiver`): a `reason` that
1905
+ makes it reviewable and an `acknowledgedBy` that names the USER who accepted
1906
+ it. Both are required precisely because the party the finding is about is the
1907
+ agent: an entry with no acknowledgement is an agent excusing its own
1908
+ self-mock, which is the review this gate exists to force. The detector
1909
+ (`validators/detect_self_mock.py`) deliberately carries such an entry through
1910
+ instead of dropping it, so it surfaces here rather than vanishing.
1911
+
1912
+ Requiring the fields is not enough on its own, because the run also chooses
1913
+ WHERE they are read from: `--waivers /tmp/mine.json` with a forged
1914
+ `acknowledgedBy` satisfies every field check and leaves nothing under
1915
+ `.okstra/` to review. So a non-empty `waived` must also come from the task's
1916
+ own `qa/self-mock-waivers.json`, which puts the acknowledgement in the task
1917
+ bundle where it is auditable. An empty `waived` waived nothing, so the source
1918
+ is not asked for.
1919
+ """
1920
+ waived = static.get("waived")
1921
+ if waived is None:
1922
+ return None
1923
+ problems: list[str] = []
1924
+ if waived and not _selfmock_source_is_canonical(
1925
+ static.get("waiverSource"), sidecar.parent / _SELFMOCK_WAIVER_FILENAME
1926
+ ):
1927
+ problems.append(
1928
+ f"staticDetect.waiverSource must be the task's own "
1929
+ f"{sidecar.parent / _SELFMOCK_WAIVER_FILENAME}, got "
1930
+ f"{static.get('waiverSource')!r}"
1931
+ )
1932
+ if not isinstance(waived, list):
1933
+ problems.append("staticDetect.waived must be an array of waiver objects")
1934
+ else:
1935
+ for idx, entry in enumerate(waived):
1936
+ if not isinstance(entry, dict):
1937
+ problems.append(f"staticDetect.waived[{idx}] must be an object")
1938
+ continue
1939
+ for field in ("reason", "acknowledgedBy"):
1940
+ value = entry.get(field)
1941
+ if not isinstance(value, str) or not value.strip():
1942
+ problems.append(
1943
+ f"staticDetect.waived[{idx}].{field} must be a non-empty string"
1944
+ )
1945
+ if not problems:
1946
+ return None
1947
+ return (
1948
+ f"self-mock gate BLOCKING: {sidecar} carries invalid self-mock waiver(s): "
1949
+ + "; ".join(problems)
1950
+ + ". A waived detector hit needs a `reason` and the `acknowledgedBy` of the "
1951
+ "user who accepted it, read from the task's own `qa/self-mock-waivers.json` "
1952
+ "— a verifier may report a suspected false positive, but only the user "
1953
+ "waives one, and the acknowledgement has to land in the task bundle where "
1954
+ "it can be reviewed. Add the entry to "
1955
+ f"{sidecar.parent / _SELFMOCK_WAIVER_FILENAME} and re-run the detector with "
1956
+ "`--waivers` pointing there; never hand-edit the sidecar."
1957
+ )
1958
+
1959
+
1960
+ def _selfmock_received_files(sidecar_data: dict) -> list[str] | None:
1961
+ """Every `--test-file` the detector reports receiving; None if unreadable.
1962
+
1963
+ The union of `scannedFiles` and `skippedFiles`. Coverage is asked of what the
1964
+ verifier PASSED, not of what the detector could read, because this gate's
1965
+ trigger (`_TEST_FILE_RE`) is extension- and existence-agnostic while the
1966
+ detector's scan set is neither: a Go test, a JSON fixture under `tests/`, and
1967
+ a test file the stage deleted all trigger the gate and are all legitimately
1968
+ skipped. Demanding they appear among the scanned ones would block those runs
1969
+ with a recovery command that reproduces the block. A file the verifier never
1970
+ passed is in neither list, so the fail-open hole stays closed.
1971
+ """
1972
+ received: list[str] = []
1973
+ for field in ("scannedFiles", "skippedFiles"):
1974
+ value = sidecar_data.get(field)
1975
+ if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
1976
+ return None
1977
+ received.extend(value)
1978
+ return received
1979
+
1980
+
1981
+ def _selfmock_unreceived(received: list[str], changed: list[str]) -> list[str]:
1982
+ """Return the changed test files the sidecar does NOT show the detector receiving.
1983
+
1984
+ An ABSOLUTE `--test-file` argument names the same file as the report's
1985
+ repo-relative row, so it is matched on a whole-segment suffix — false-blocking
1986
+ a legitimate run over path spelling would be worse than the hole this closes.
1987
+ A relative entry must match exactly: in a monorepo,
1988
+ `packages/web/src/cart/x.spec.ts` and a repo-root `src/cart/x.spec.ts` are
1989
+ different files, and letting the tail overlap count would reopen the hole.
1990
+ """
1991
+ keys = {selfmock_path_key(entry) for entry in received}
1992
+ absolute = {key for key in keys if posixpath.isabs(key)}
1993
+ return [
1994
+ path
1995
+ for path in changed
1996
+ if (key := selfmock_path_key(path)) not in keys
1997
+ and not any(entry.endswith(f"/{key}") for entry in absolute)
1998
+ ]
1999
+
2000
+
2001
+ def _validate_selfmock(report_path: Path, failures: list[str]) -> None:
2002
+ """Fail-closed self-mock gate (implementation / final-verification).
2003
+
2004
+ Two gates are folded here. Gate A is the static SUT-stub scan; gate B is the
2005
+ mutation probe, which blocks on `mutation.status == "FAIL"` and is excluded
2006
+ from the verdict when it reports `unsupported(<reason>)` — a repo with no
2007
+ mutation tool installed must not wedge. See `_selfmock_mutation_failure`.
2008
+
2009
+ A diff that changes test files without a detector sidecar means the detector
2010
+ never ran, so absence blocks exactly like a FAIL verdict does. A PASS is not
2011
+ taken on trust either: every changed test file must appear in the sidecar's
2012
+ `scannedFiles` or its `skippedFiles`, because an empty hit list over an empty
2013
+ or wrong input reads identically to a clean scan. The check is against that
2014
+ union, not against `scannedFiles` alone — see `_selfmock_received_files`. A
2015
+ diff whose test-file selection is empty — no test file at all, or only the
2016
+ gate's own fixtures — is a vacuous PASS: changed test files are the trigger,
2017
+ not the run.
2018
+ Stage-isolated runs read their own `self-mock-stage-<N>.json`; whole-task runs
2019
+ read the flat `self-mock.json`.
2020
+
2021
+ Only the implementation template renders the §5.7.3 diff summary
2022
+ (`templates/reports/final-report.template.md:589`); a final-verification report
2023
+ quotes the diff as a blockquote instead, so this gate is vacuous there by
2024
+ design — self-mock is enforced at the implementation stage, and
2025
+ final-verification is a read-only re-verify that adds no test files.
2026
+ """
2027
+ changed = _parse_diff_summary_files(report_path.read_text(encoding="utf-8"))
2028
+ test_files = [
2029
+ path
2030
+ for path in changed
2031
+ if _TEST_FILE_RE.search(path)
2032
+ and _SELFMOCK_FIXTURE_PREFIX not in path.replace("\\", "/")
2033
+ ]
2034
+ if not test_files:
2035
+ return
2036
+ run_dir = report_path.parent.parent
2037
+ stage_name = _stage_isolated_name(run_dir)
2038
+ name = f"self-mock-{stage_name}.json" if stage_name else "self-mock.json"
2039
+ sidecar = _task_root_from_run_dir(run_dir) / "qa" / name
2040
+ if not sidecar.is_file():
2041
+ failures.append(
2042
+ f"self-mock gate BLOCKING: diff changes test file(s) {test_files} but "
2043
+ f"the detector sidecar {sidecar} is absent. Run "
2044
+ f"`python3 ~/.okstra/lib/validators/detect_self_mock.py "
2045
+ f"--test-file <path>... --sidecar {sidecar}` over the changed test "
2046
+ f"files and record the result."
2047
+ )
2048
+ return
2049
+ try:
2050
+ data = json.loads(sidecar.read_text(encoding="utf-8"))
2051
+ except (OSError, json.JSONDecodeError) as exc:
2052
+ failures.append(
2053
+ f"self-mock gate BLOCKING: detector sidecar unreadable at {sidecar}: {exc}"
2054
+ )
2055
+ return
2056
+ if not isinstance(data, dict):
2057
+ failures.append(
2058
+ f"self-mock gate BLOCKING: detector sidecar payload malformed at {sidecar}"
2059
+ )
2060
+ return
2061
+ static = data.get("staticDetect")
2062
+ static = static if isinstance(static, dict) else {}
2063
+ # Checked ahead of the verdict: a waiver too weak to hold is BLOCKING whether
2064
+ # or not anything survived it, and a PASS bought with one is the case the
2065
+ # verdict check cannot see.
2066
+ waiver_failure = _selfmock_waiver_failure(static, sidecar)
2067
+ if waiver_failure:
2068
+ failures.append(waiver_failure)
2069
+ return
2070
+ # Ahead of the `overall` check: a mutation FAIL also makes `overall` FAIL, and
2071
+ # the generic branch below would report it as empty static hits — sending the
2072
+ # reader to hunt for a stubbed subject that is not there.
2073
+ # Ahead of the verdict, for the same reason gate A's waiver check is: a PASS
2074
+ # bought with an unacknowledged waiver is exactly what the verdict cannot see.
2075
+ mutation_waiver_failure = _selfmock_mutation_waiver_failure(
2076
+ data.get("mutation"), sidecar
2077
+ )
2078
+ if mutation_waiver_failure:
2079
+ failures.append(mutation_waiver_failure)
2080
+ return
2081
+ mutation_failure = _selfmock_mutation_failure(data.get("mutation"), sidecar)
2082
+ if mutation_failure:
2083
+ failures.append(mutation_failure)
2084
+ return
2085
+ if data.get("overall") != "PASS":
2086
+ failures.append(
2087
+ f"self-mock gate BLOCKING: {sidecar} overall={data.get('overall')!r}, "
2088
+ f"staticDetect={static.get('status')!r}, hits={static.get('hits') or []}. "
2089
+ "Stubbing the unit under test proves only the test's own wiring — "
2090
+ "exercise the real method, or stub injected collaborators only."
2091
+ )
2092
+ return
2093
+ received = _selfmock_received_files(data)
2094
+ if received is None:
2095
+ failures.append(
2096
+ f"self-mock gate BLOCKING: {sidecar} carries no usable `scannedFiles` + "
2097
+ "`skippedFiles` lists, so its PASS cannot be told apart from a detector "
2098
+ "run over an empty or wrong input. Re-run "
2099
+ f"`python3 ~/.okstra/lib/validators/detect_self_mock.py "
2100
+ f"--test-file <path>... --sidecar {sidecar}` — the detector writes both "
2101
+ "fields itself; never hand-edit the sidecar."
2102
+ )
2103
+ return
2104
+ unreceived = _selfmock_unreceived(received, test_files)
2105
+ if unreceived:
2106
+ failures.append(
2107
+ f"self-mock gate BLOCKING: changed test file(s) {unreceived} were never "
2108
+ f"fed to the detector — {sidecar} recorded neither scanning nor skipping "
2109
+ f"them (received={received}). A PASS over a narrower input says nothing "
2110
+ "about the files this diff changed; re-run the detector with one "
2111
+ "`--test-file` per changed test file. Pass every test file the diff "
2112
+ "touched — the detector decides what it can scan, so a deleted file or "
2113
+ "one in a language it has no signals for is recorded as skipped and "
2114
+ "clears this check; filtering them out yourself is what trips it."
2115
+ )
2116
+ return
2117
+ _check_selfmock_changed_files(data, changed, sidecar, failures)
2118
+
2119
+
2120
+ def _check_selfmock_changed_files(
2121
+ data: dict, changed: list[str], sidecar: Path, failures: list[str]
2122
+ ) -> None:
2123
+ """Gate B's counterpart to the `scannedFiles` coverage check.
2124
+
2125
+ `--changed-file` is what the mutation adapters select their production
2126
+ sources from. Omit it and every adapter gets an empty target set, which is
2127
+ not a finding but reads like one had been looked for. Requiring the sidecar
2128
+ to account for every file in the §5.7.3 diff summary keeps "gate B saw this
2129
+ stage" apart from "gate B was handed nothing".
2130
+
2131
+ Coverage is asked of the WHOLE diff, not just its test files: the production
2132
+ sources are precisely the part gate A never looks at.
2133
+ """
2134
+ declared = data.get("changedFiles")
2135
+ if not isinstance(declared, list) or any(
2136
+ not isinstance(entry, str) for entry in declared
2137
+ ):
2138
+ failures.append(
2139
+ f"self-mock gate BLOCKING: {sidecar} carries no usable `changedFiles` "
2140
+ "list, so the mutation gate cannot be told apart from one that was "
2141
+ "handed nothing to mutate. Re-run "
2142
+ f"`python3 ~/.okstra/lib/validators/detect_self_mock.py "
2143
+ f"--changed-file <path>... --sidecar {sidecar}` passing every file the "
2144
+ "diff changed; the detector writes the field itself."
2145
+ )
2146
+ return
2147
+ unfed = _selfmock_unreceived(declared, changed)
2148
+ if unfed:
2149
+ failures.append(
2150
+ f"self-mock gate BLOCKING: changed file(s) {unfed} were never fed to "
2151
+ f"the mutation gate — {sidecar} records changedFiles={declared}. Each "
2152
+ "adapter picks its production sources out of that set, so a file left "
2153
+ "out is a file no mutant was ever generated for. Pass every path from "
2154
+ "the §5.7.3 diff summary with `--changed-file`, production sources "
2155
+ "included — filtering to the test files leaves gate B nothing to run "
2156
+ "on and reports a pass it never earned."
2157
+ )
2158
+
2159
+
1682
2160
  def _check_incremental_audit_block(
1683
2161
  report_path: Path,
1684
2162
  content: str,
@@ -2513,6 +2991,9 @@ def validate_final_report_data(
2513
2991
  failures: list[str],
2514
2992
  *,
2515
2993
  report_contracts: set[str] | None = None,
2994
+ run_manifest: dict | None = None,
2995
+ project_root: Path | None = None,
2996
+ clarification_text: str = "",
2516
2997
  ) -> Mapping[str, Any] | None:
2517
2998
  """Validate the final-report data.json against the v1.0 schema.
2518
2999
 
@@ -2561,6 +3042,21 @@ def validate_final_report_data(
2561
3042
  errors = schema_validate(data, schema)
2562
3043
  for err in errors:
2563
3044
  failures.append(f"final-report data.json: {err}")
3045
+ if errors:
3046
+ return data
3047
+
3048
+ analysis_result = validate_analysis_report(
3049
+ data=data,
3050
+ report_path=report_path,
3051
+ project_root=project_root or report_path.parent,
3052
+ run_manifest=run_manifest or {},
3053
+ clarification_text=clarification_text,
3054
+ )
3055
+ manifest_task_type = str((run_manifest or {}).get("taskType") or "")
3056
+ task_type = manifest_task_type or str(
3057
+ (data.get("header") or {}).get("taskType") or ""
3058
+ )
3059
+ failures.extend(f"{task_type}: {error}" for error in analysis_result.errors)
2564
3060
 
2565
3061
  _validate_rationale_evidence(data, failures)
2566
3062
  _validate_no_opaque_id_references(data, failures)
@@ -3642,6 +4138,22 @@ def _carry_in_source_for_run(run_manifest_path: Path) -> str:
3642
4138
  return raw.strip() if isinstance(raw, str) else ""
3643
4139
 
3644
4140
 
4141
+ def _clarification_text_for_run(
4142
+ run_manifest_path: Path, report_path: Path
4143
+ ) -> str:
4144
+ """Read only the clarification input explicitly attached to this run."""
4145
+ if not _carry_in_source_for_run(run_manifest_path):
4146
+ return ""
4147
+ task_root = _task_root_from_run_dir(report_path.parent.parent)
4148
+ staged = task_root / "instruction-set" / "clarification-response.md"
4149
+ if not staged.is_file():
4150
+ return ""
4151
+ try:
4152
+ return staged.read_text(encoding="utf-8")
4153
+ except OSError:
4154
+ return ""
4155
+
4156
+
3645
4157
  def _validate_clarification_carry_in_recorded(
3646
4158
  data: dict,
3647
4159
  report_path: Path,
@@ -6591,6 +7103,11 @@ def main() -> int:
6591
7103
  report_path,
6592
7104
  failures,
6593
7105
  report_contracts=report_contracts,
7106
+ run_manifest=run_manifest,
7107
+ project_root=project_root,
7108
+ clarification_text=_clarification_text_for_run(
7109
+ run_manifest_path, report_path
7110
+ ),
6594
7111
  )
6595
7112
  validation_data = report_data if isinstance(report_data, Mapping) else {}
6596
7113
  _validate_fix_cycle(run_manifest, validation_data, failures)
@@ -6641,6 +7158,7 @@ def main() -> int:
6641
7158
  failures,
6642
7159
  ),
6643
7160
  )
7161
+ _validate_selfmock(report_path, failures)
6644
7162
  for warning in conformance_warnings:
6645
7163
  print(f"validate-run: warning: {warning}", file=sys.stderr)
6646
7164
  if task_type in _BRIEF_DERIVED_PHASES: