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.
- package/README.md +4 -1
- package/docs/architecture.md +18 -2
- package/docs/cli.md +39 -2
- package/docs/project-structure-overview.md +19 -6
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/coding-preflight/overview.md +1 -1
- package/runtime/prompts/lead/convergence.md +11 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -1
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +48 -2
- package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
- package/runtime/prompts/profiles/feature-analysis.md +24 -0
- package/runtime/prompts/profiles/forbidden-actions.json +18 -0
- package/runtime/prompts/profiles/project-analysis.md +24 -0
- package/runtime/prompts/wizard/prompts.ko.json +44 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
- package/runtime/python/okstra_ctl/clarification_items.py +74 -1
- package/runtime/python/okstra_ctl/mutation_probe.py +1263 -0
- package/runtime/python/okstra_ctl/render.py +77 -4
- package/runtime/python/okstra_ctl/render_final_report.py +13 -4
- package/runtime/python/okstra_ctl/report_views.py +134 -3
- package/runtime/python/okstra_ctl/run.py +118 -0
- package/runtime/python/okstra_ctl/run_context.py +34 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
- package/runtime/python/okstra_ctl/self_mock_signals.py +183 -0
- package/runtime/python/okstra_ctl/user_response.py +309 -3
- package/runtime/python/okstra_ctl/wizard.py +545 -32
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
- package/runtime/python/okstra_ctl/workflow.py +22 -0
- package/runtime/schemas/final-report-v1.0.schema.json +849 -3
- package/runtime/skills/okstra-run/SKILL.md +13 -1
- package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
- package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
- package/runtime/templates/reports/final-report.template.md +220 -0
- package/runtime/templates/reports/i18n/en.json +8 -0
- package/runtime/templates/reports/i18n/ko.json +8 -0
- package/runtime/templates/reports/project-analysis-input.template.md +58 -0
- package/runtime/templates/reports/report.js +84 -5
- package/runtime/templates/reports/user-response.template.md +19 -1
- package/runtime/validators/detect_self_mock.py +220 -0
- package/runtime/validators/validate-report-views.py +61 -7
- package/runtime/validators/validate-run.py +518 -0
- package/runtime/validators/validate_analysis_report.py +864 -0
- 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:
|