substratum-cli 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/PKG-INFO +1 -1
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/__init__.py +1 -1
- substratum_cli-0.2.4/substratum_cli/_vendor/client_gate.py +170 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/apply.py +2 -2
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/failing.py +2 -1
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/pr.py +1 -1
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/engine.py +8 -1
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/gitio.py +10 -3
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/product_ops.py +87 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/remote.py +3 -1
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/session.py +4 -3
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/PKG-INFO +1 -1
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/SOURCES.txt +1 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/README.md +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/pyproject.toml +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/setup.cfg +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/README.md +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/__main__.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_boot.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_update.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_vendor/__init__.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_vendor/_pro_test_derive.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_wordmark.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/account.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/build_state.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/cli.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/codes.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/__init__.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/auth.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/commit.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/explain.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/fix.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/history.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/init_cmd.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/replay.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/scaffold.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/show.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/undo.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/verify.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/write.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/config.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/diffs.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/langscan.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/refusals.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/render.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/result.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/run_store.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/runid.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/style.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/dependency_links.txt +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/entry_points.txt +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/requires.txt +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/top_level.txt +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_adaptive_prior.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_alias_warrant_trace.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_bidirectional_gate_contracts.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_coherence_evolution.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_coherences.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_connection_web_api.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_cross_entity_scoping.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_data.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_de28_negation_refuse.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_definite_description_handler.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_demo_regression_catalog.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_demo_response_shape.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_e1_e2_field_alignment.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_extraction_class_write_time.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_falsification_predictions.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_gap_d_wh_normalization.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_inference.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_inflection_cvc_rule.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_inflection_irregular.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_intervention.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_invariant.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_legal_citation_handler.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_lens_noop_seam.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_markup_tag_handler.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_nl_parser.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_nl_parser_adversarial.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_noise.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_pass2_contextual.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_perspectives.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_polysemous_token_alias_guard.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_possessive_normalization.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_predicate_canon.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_projection_gate.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_projection_gate_tag_question.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_query_normalizer.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_records_answer_format.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_referent_aboutness_gate.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_referent_quality_gate.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_refuse_message_topic_neutrality.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_relation_detection.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_relaxation.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_residual_content_gate.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_reverse_lookup_subject_filter.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_sec_statement_aliases.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_span_lens.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_span_lens_query_integration.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_substrate.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_synonymy_detector.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_table_ingest.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_triple_ingest.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_two_phase_schema.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_user_asserted_aliases.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_v2_api.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_v2_api_claims.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_v2_claim_determinism.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_word_role.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_word_role_wiring.py +0 -0
- {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_xbrl_doc_aliases.py +0 -0
|
@@ -5,4 +5,4 @@ Import budget: this package is stdlib-only; the ONLY module reaching into backen
|
|
|
5
5
|
"""
|
|
6
6
|
# SINGLE SOURCE OF TRUTH for the release version. pyproject reads this (dynamic version), the boot shows
|
|
7
7
|
# it, and the update-check compares against it. To cut a release, bump ONLY this line, then publish.
|
|
8
|
-
__version__ = "0.2.
|
|
8
|
+
__version__ = "0.2.4"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""client_gate -- the Docker-less INSTANCE-GRAIN gate for client repos.
|
|
2
|
+
|
|
3
|
+
The commercial unlock: the verified-or-refuse machine currently gates instance-grain
|
|
4
|
+
emissions through benchmark Docker images. A client repo has no image -- it has ITS OWN
|
|
5
|
+
tests. This gate runs them, with DIFFERENTIAL (fail_to_pass) semantics:
|
|
6
|
+
|
|
7
|
+
the named tests must FAIL on the UNPATCHED base and PASS with the candidate applied.
|
|
8
|
+
|
|
9
|
+
The differential is load-bearing for zero-wrong: a candidate that "passes" tests which
|
|
10
|
+
already passed at base has proven NOTHING about the fix (pass-because-it-tests-nothing);
|
|
11
|
+
it is refused NAMED (GATE_TESTS_PASS_AT_BASE), never shipped on that warrant.
|
|
12
|
+
|
|
13
|
+
Isolation: base_commit is materialized in a TEMP GIT WORKTREE (the proven toolchain_derive
|
|
14
|
+
pattern); the client checkout is never touched; the worktree is always removed.
|
|
15
|
+
|
|
16
|
+
Outcomes are a CLOSED set and only PASS ships:
|
|
17
|
+
PASS differential held (failed at base, passed with patch)
|
|
18
|
+
TEST_FAILED patch applied, tests still fail
|
|
19
|
+
GATE_TESTS_PASS_AT_BASE nothing to fix on this warrant -> refuse
|
|
20
|
+
GATE_BASE_ERROR tests could not RUN at base (env/collection) -> refuse
|
|
21
|
+
GATE_ERROR tests could not RUN with the patch -> refuse (never a pass)
|
|
22
|
+
GATE_TIMEOUT / GATE_WORKTREE_FAILED / GATE_NO_RUNNER named infrastructure refusals
|
|
23
|
+
|
|
24
|
+
Runner resolution (closed, explicit-first): caller-provided command > detected by repo
|
|
25
|
+
shape (pytest for python repos, `go test` for go.mod repos, npm test for package.json with
|
|
26
|
+
a test script). Detection failure refuses NAMED -- the gate never guesses how to test."""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import shlex
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import tempfile
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ClientGateResult:
|
|
40
|
+
passed: bool
|
|
41
|
+
code: str # closed set above
|
|
42
|
+
detail: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run(cmd: Sequence[str], cwd: str, timeout: int) -> Tuple[Optional[int], str]:
|
|
46
|
+
try:
|
|
47
|
+
# children get a REAL-fs tmpdir: pytest's capture holds unlinked-open tempfiles,
|
|
48
|
+
# which drvfs/9p mounts cannot represent (FileNotFoundError at truncate).
|
|
49
|
+
# PYTHONDONTWRITEBYTECODE is a CORRECTNESS requirement, not hygiene: the base run
|
|
50
|
+
# writes __pycache__; a patched source of the same size within the same second is
|
|
51
|
+
# SHADOWED by the stale pyc (timestamp validation is second-granular), and the
|
|
52
|
+
# with-patch run silently re-imports the buggy module -- the differential then
|
|
53
|
+
# reports TEST_FAILED for a genuine fix (or worse directions on other shapes).
|
|
54
|
+
# errors="replace": test output is arbitrary user bytes (a test may print binary or a
|
|
55
|
+
# non-UTF-8 traceback); strict decode would raise UnicodeDecodeError and crash the gate.
|
|
56
|
+
# The verdict is the return code, never the decoded text, so replacement is verdict-neutral.
|
|
57
|
+
r = subprocess.run(list(cmd), cwd=cwd, capture_output=True, text=True,
|
|
58
|
+
errors="replace", timeout=timeout,
|
|
59
|
+
env=dict(os.environ, PYTHONHASHSEED="0", TMPDIR="/tmp",
|
|
60
|
+
PYTHONDONTWRITEBYTECODE="1"))
|
|
61
|
+
return r.returncode, (r.stdout + "\n" + r.stderr)[-4000:]
|
|
62
|
+
except subprocess.TimeoutExpired:
|
|
63
|
+
return None, "timeout"
|
|
64
|
+
except FileNotFoundError as e:
|
|
65
|
+
return None, f"runner missing: {e}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_test_command(repo_dir: str, test_cmd: Optional[str],
|
|
69
|
+
test_paths: Optional[Sequence[str]]) -> Optional[List[str]]:
|
|
70
|
+
"""The command that runs the WARRANT tests. Explicit beats detection; detection is a
|
|
71
|
+
closed table keyed by repo shape; anything else -> None (refuse, never guess)."""
|
|
72
|
+
test_cmd = test_cmd or os.environ.get("SUBSTRATUM_CLIENT_TEST_CMD")
|
|
73
|
+
if test_cmd:
|
|
74
|
+
return shlex.split(test_cmd)
|
|
75
|
+
paths = list(test_paths or [])
|
|
76
|
+
if os.path.exists(os.path.join(repo_dir, "go.mod")):
|
|
77
|
+
go = os.path.expanduser("~/.local/go/bin/go")
|
|
78
|
+
go = go if os.path.exists(go) else shutil.which("go")
|
|
79
|
+
if not go:
|
|
80
|
+
return None
|
|
81
|
+
# go test takes package dirs + -run for test-name selectors (pkg::Test form)
|
|
82
|
+
pkgs, runs = [], []
|
|
83
|
+
for p in paths:
|
|
84
|
+
if "::" in p:
|
|
85
|
+
d, name = p.split("::", 1)
|
|
86
|
+
pkgs.append("./" + os.path.dirname(d) if os.path.dirname(d) else "./...")
|
|
87
|
+
runs.append(name)
|
|
88
|
+
else:
|
|
89
|
+
pkgs.append("./" + p if not p.startswith("./") else p)
|
|
90
|
+
cmd = [go, "test"] + (sorted(set(pkgs)) or ["./..."])
|
|
91
|
+
if runs:
|
|
92
|
+
cmd += ["-run", "|".join(dict.fromkeys(runs))]
|
|
93
|
+
return cmd
|
|
94
|
+
if any(os.path.exists(os.path.join(repo_dir, f))
|
|
95
|
+
for f in ("pytest.ini", "setup.py", "pyproject.toml", "setup.cfg", "tox.ini")) \
|
|
96
|
+
or os.path.isdir(os.path.join(repo_dir, "tests")) or any(
|
|
97
|
+
p.endswith(".py") or "::" in p for p in paths):
|
|
98
|
+
import sys
|
|
99
|
+
return [sys.executable, "-m", "pytest", "-x", "-q", "-p", "no:cacheprovider",
|
|
100
|
+
*paths]
|
|
101
|
+
pkg = os.path.join(repo_dir, "package.json")
|
|
102
|
+
if os.path.exists(pkg):
|
|
103
|
+
try:
|
|
104
|
+
import json
|
|
105
|
+
if json.load(open(pkg)).get("scripts", {}).get("test"):
|
|
106
|
+
return ["npm", "test", "--silent"]
|
|
107
|
+
except Exception:
|
|
108
|
+
return None
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_client_gate(repo_dir: str, base_commit: str, edits: Dict[str, str],
|
|
113
|
+
test_paths: Optional[Sequence[str]] = None,
|
|
114
|
+
test_cmd: Optional[str] = None,
|
|
115
|
+
differential: bool = True,
|
|
116
|
+
timeout: int = 600,
|
|
117
|
+
transcript_sink: Optional[dict] = None) -> ClientGateResult:
|
|
118
|
+
"""THE gate. Materialize base_commit in a temp worktree; (differential) run the warrant
|
|
119
|
+
tests unpatched -- they must FAIL; write `edits`; run again -- they must PASS.
|
|
120
|
+
|
|
121
|
+
transcript_sink (out-param, additive; the proven refusal_sink precedent): when a dict is
|
|
122
|
+
passed, it is populated with {runner_command, base_rc, base_out, patched_rc, patched_out}
|
|
123
|
+
so the product surface can show the "base FAIL -> patched PASS" proof and store the gate
|
|
124
|
+
logs. Default None keeps every existing caller byte-identical."""
|
|
125
|
+
wt = tempfile.mkdtemp(prefix="cg_wt_", dir=os.environ.get("TMPDIR") or None)
|
|
126
|
+
try:
|
|
127
|
+
r = subprocess.run(["git", "-C", repo_dir, "worktree", "add", "--detach", "-f",
|
|
128
|
+
wt, base_commit], capture_output=True, text=True,
|
|
129
|
+
errors="replace", timeout=120)
|
|
130
|
+
if r.returncode != 0:
|
|
131
|
+
return ClientGateResult(False, "GATE_WORKTREE_FAILED", r.stderr[-300:])
|
|
132
|
+
cmd = resolve_test_command(wt, test_cmd, test_paths)
|
|
133
|
+
if cmd is None:
|
|
134
|
+
return ClientGateResult(False, "GATE_NO_RUNNER")
|
|
135
|
+
if transcript_sink is not None:
|
|
136
|
+
transcript_sink["runner_command"] = list(cmd)
|
|
137
|
+
if differential:
|
|
138
|
+
rc, out = _run(cmd, wt, timeout)
|
|
139
|
+
if transcript_sink is not None:
|
|
140
|
+
transcript_sink["base_rc"] = rc
|
|
141
|
+
transcript_sink["base_out"] = out
|
|
142
|
+
if rc is None:
|
|
143
|
+
return ClientGateResult(False, "GATE_TIMEOUT" if out == "timeout"
|
|
144
|
+
else "GATE_BASE_ERROR", out[-300:])
|
|
145
|
+
if rc == 0:
|
|
146
|
+
return ClientGateResult(False, "GATE_TESTS_PASS_AT_BASE")
|
|
147
|
+
# rc != 0 at base: the warrant tests fail before the fix, as fail_to_pass
|
|
148
|
+
# requires. (A collection error also exits non-zero; the with-patch run below
|
|
149
|
+
# is the arbiter -- a real fix makes the SAME invocation exit 0.)
|
|
150
|
+
for rel, content in edits.items():
|
|
151
|
+
p = os.path.join(wt, rel)
|
|
152
|
+
os.makedirs(os.path.dirname(p) or wt, exist_ok=True)
|
|
153
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
154
|
+
fh.write(content)
|
|
155
|
+
rc, out = _run(cmd, wt, timeout)
|
|
156
|
+
if transcript_sink is not None:
|
|
157
|
+
transcript_sink["patched_rc"] = rc
|
|
158
|
+
transcript_sink["patched_out"] = out
|
|
159
|
+
if rc is None:
|
|
160
|
+
return ClientGateResult(False, "GATE_TIMEOUT" if out == "timeout"
|
|
161
|
+
else "GATE_ERROR", out[-300:])
|
|
162
|
+
if rc == 0:
|
|
163
|
+
return ClientGateResult(True, "PASS")
|
|
164
|
+
return ClientGateResult(False, "TEST_FAILED", out[-400:])
|
|
165
|
+
except subprocess.TimeoutExpired:
|
|
166
|
+
return ClientGateResult(False, "GATE_WORKTREE_FAILED", "worktree add timeout")
|
|
167
|
+
finally:
|
|
168
|
+
subprocess.run(["git", "-C", repo_dir, "worktree", "remove", "-f", wt],
|
|
169
|
+
capture_output=True)
|
|
170
|
+
shutil.rmtree(wt, ignore_errors=True)
|
|
@@ -35,10 +35,10 @@ def cmd_apply(repo: str, run_id: str) -> "_result.SubstratumResult":
|
|
|
35
35
|
patch = fh.name
|
|
36
36
|
try:
|
|
37
37
|
r = subprocess.run(["git", "-C", repo, "apply", "--index", patch],
|
|
38
|
-
capture_output=True, text=True)
|
|
38
|
+
capture_output=True, text=True, errors="replace")
|
|
39
39
|
if r.returncode != 0:
|
|
40
40
|
r2 = subprocess.run(["git", "-C", repo, "apply", "--3way", patch],
|
|
41
|
-
capture_output=True, text=True)
|
|
41
|
+
capture_output=True, text=True, errors="replace")
|
|
42
42
|
if r2.returncode != 0:
|
|
43
43
|
return _result.make(codes.REFUSED, run_id, "apply", code=codes.APPLY_STATE_DRIFT,
|
|
44
44
|
refusal=refusals.build(codes.APPLY_STATE_DRIFT,
|
|
@@ -117,7 +117,8 @@ def cmd_failing(repo: str, path: Optional[str] = None, test_command: Optional[st
|
|
|
117
117
|
if path:
|
|
118
118
|
run_argv.append(path)
|
|
119
119
|
try:
|
|
120
|
-
r = subprocess.run(run_argv, cwd=repo, capture_output=True, text=True,
|
|
120
|
+
r = subprocess.run(run_argv, cwd=repo, capture_output=True, text=True,
|
|
121
|
+
errors="replace", timeout=timeout,
|
|
121
122
|
env={**os.environ, "TMPDIR": "/tmp", "CI": "1", "NO_COLOR": "1"})
|
|
122
123
|
except subprocess.TimeoutExpired:
|
|
123
124
|
return _refuse(rid, codes.GATE_PRECONDITION, f"the test suite did not finish within {timeout}s")
|
|
@@ -66,7 +66,7 @@ def cmd_pr(repo: str, run_id: str = None, title: str = None) -> "_result.Substra
|
|
|
66
66
|
if gh:
|
|
67
67
|
title = title or f"Substratum verified: {res.operation} {target}"[:100]
|
|
68
68
|
r = subprocess.run([gh, "pr", "create", "--title", title, "--body", body],
|
|
69
|
-
cwd=repo, capture_output=True, text=True, timeout=60)
|
|
69
|
+
cwd=repo, capture_output=True, text=True, errors="replace", timeout=60)
|
|
70
70
|
if r.returncode == 0:
|
|
71
71
|
return _result.make(codes.PASS, run_id, "pr",
|
|
72
72
|
data={"url": r.stdout.strip(), "branch": branch, "certified": run_id})
|
|
@@ -26,10 +26,17 @@ _LEAF_CACHE: dict[str, ModuleType] = {}
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _leaf(name: str) -> ModuleType:
|
|
29
|
-
"""Load `<engine>/<name>.py` in isolation (no backend __init__), cached.
|
|
29
|
+
"""Load `<engine>/<name>.py` in isolation (no backend __init__), cached. On a THIN CLIENT
|
|
30
|
+
(pip install; no engine checkout on disk) fall back to the copy vendored INSIDE the wheel --
|
|
31
|
+
a pip user must never depend on a path that only exists on a dev machine. Dev machines still
|
|
32
|
+
get the live engine file first, so engine edits are picked up without a re-vendor."""
|
|
30
33
|
if name in _LEAF_CACHE:
|
|
31
34
|
return _LEAF_CACHE[name]
|
|
32
35
|
path = os.path.join(_ENGINE_DIR, f"{name}.py")
|
|
36
|
+
if not os.path.isfile(path):
|
|
37
|
+
vendored = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_vendor", f"{name}.py")
|
|
38
|
+
if os.path.isfile(vendored):
|
|
39
|
+
path = vendored
|
|
33
40
|
spec = importlib.util.spec_from_file_location(f"_substratum_leaf_{name}", path)
|
|
34
41
|
if spec is None or spec.loader is None:
|
|
35
42
|
raise ImportError(f"cannot leaf-load {path}")
|
|
@@ -7,8 +7,13 @@ from typing import List, Optional
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def _git(repo: str, *args: str, timeout: int = 60) -> subprocess.CompletedProcess:
|
|
10
|
+
# errors="replace": git output carries arbitrary working-tree bytes (a `diff` of a large file
|
|
11
|
+
# git treats as text but that holds a non-UTF-8 byte; a non-ASCII untracked filename). Strict
|
|
12
|
+
# decode would raise UnicodeDecodeError -- this runs on EVERY command via the run-id dirty-hash,
|
|
13
|
+
# so a single binary-ish file in the user's tree would crash the whole CLI. Replacement is
|
|
14
|
+
# deterministic (same bytes -> same text -> same hash), so the run-id story is preserved.
|
|
10
15
|
return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True,
|
|
11
|
-
timeout=timeout)
|
|
16
|
+
errors="replace", timeout=timeout)
|
|
12
17
|
|
|
13
18
|
|
|
14
19
|
def is_git_repo(repo: str) -> bool:
|
|
@@ -23,11 +28,13 @@ def apply_reverse(repo: str, diff_text: str) -> tuple[bool, str]:
|
|
|
23
28
|
if not diff_text.strip():
|
|
24
29
|
return False, "empty diff"
|
|
25
30
|
r = subprocess.run(["git", "-C", repo, "apply", "--reverse", "--3way", "-"],
|
|
26
|
-
input=diff_text, capture_output=True, text=True,
|
|
31
|
+
input=diff_text, capture_output=True, text=True,
|
|
32
|
+
errors="replace", timeout=60)
|
|
27
33
|
if r.returncode == 0:
|
|
28
34
|
return True, "reverted"
|
|
29
35
|
r2 = subprocess.run(["git", "-C", repo, "apply", "--reverse", "-"],
|
|
30
|
-
input=diff_text, capture_output=True, text=True,
|
|
36
|
+
input=diff_text, capture_output=True, text=True,
|
|
37
|
+
errors="replace", timeout=60)
|
|
31
38
|
if r2.returncode == 0:
|
|
32
39
|
return True, "reverted"
|
|
33
40
|
err = (r.stderr or r2.stderr or "").strip().splitlines()
|
|
@@ -41,6 +41,15 @@ def op_fix_from_failing_test(repo: str, failing_tests: list[str], intent: str =
|
|
|
41
41
|
stage("GROUND", f"reading {len(failing_tests)} failing test(s)")
|
|
42
42
|
|
|
43
43
|
if not engine.available(): # client-only install: fix needs the engine (local or hosted)
|
|
44
|
+
# HOSTED: when logged in, the server SYNTHESIZES from focal snippets (only the focal module +
|
|
45
|
+
# test NAMES transit; test bodies and the wider repo never leave) and the candidate diff is
|
|
46
|
+
# gated HERE, differentially, on the user's own tests. The gate stays the arbiter, client-side.
|
|
47
|
+
from . import remote
|
|
48
|
+
if remote.enabled():
|
|
49
|
+
rr = _remote_fix(repo, base, rid, key, inputs, failing_tests, intent,
|
|
50
|
+
test_command, target, stage)
|
|
51
|
+
if rr is not None:
|
|
52
|
+
return rr
|
|
44
53
|
ref = refusals.build(codes.NO_ENGINE, engine_code="NO_ENGINE",
|
|
45
54
|
ctx={"target": target or "the failing test", "run_id": rid})
|
|
46
55
|
return _store(repo, _result.make(codes.REFUSED, rid, "fix", code=codes.NO_ENGINE, refusal=ref),
|
|
@@ -155,6 +164,84 @@ def _route_test_derived(repo, base_commit, failing_tests, intent, test_command,
|
|
|
155
164
|
return None
|
|
156
165
|
|
|
157
166
|
|
|
167
|
+
def _focal_context(repo: str, failing_tests: list, intent: str):
|
|
168
|
+
"""Build the focal payload for the hosted engine: parse the failing TEST locally (it never
|
|
169
|
+
transits), find the module it imports-and-calls, and send ONLY that module's content + names.
|
|
170
|
+
Returns the focal dict or None if no focal module is discoverable."""
|
|
171
|
+
test_files = sorted({t.split("::")[0] for t in failing_tests if "::" in t or t.endswith(".py")})
|
|
172
|
+
for tf in test_files:
|
|
173
|
+
tf_abs = os.path.join(repo, tf)
|
|
174
|
+
if not os.path.isfile(tf_abs):
|
|
175
|
+
continue
|
|
176
|
+
tsrc = open(tf_abs, encoding="utf-8", errors="replace").read()
|
|
177
|
+
try:
|
|
178
|
+
tree = ast.parse(tsrc)
|
|
179
|
+
except SyntaxError:
|
|
180
|
+
continue
|
|
181
|
+
for node in ast.walk(tree):
|
|
182
|
+
if isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
183
|
+
mod_rel = node.module.replace(".", "/") + ".py"
|
|
184
|
+
mod_abs = os.path.join(repo, mod_rel)
|
|
185
|
+
if not os.path.isfile(mod_abs):
|
|
186
|
+
continue
|
|
187
|
+
for a in node.names:
|
|
188
|
+
if re.search(rf"\b{re.escape(a.name)}\s*\(", tsrc):
|
|
189
|
+
content = open(mod_abs, encoding="utf-8", errors="replace").read()
|
|
190
|
+
return {"files": {mod_rel: content}, "path": mod_rel,
|
|
191
|
+
"function": a.name, "buggy_source": content,
|
|
192
|
+
"repo": os.path.basename(repo), "intent": intent or ""}
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _remote_fix(repo, base, rid, key, inputs, failing_tests, intent, test_command, target, stage):
|
|
197
|
+
"""fix over the hosted engine: POST focal -> receive a candidate diff -> GATE IT HERE
|
|
198
|
+
(differential, vendored client gate) -> VERIFIED or a named refusal. Returns None only when
|
|
199
|
+
the remote is unreachable AND no focal exists (falls through to the NO_ENGINE refusal)."""
|
|
200
|
+
from . import diffs, remote
|
|
201
|
+
focal = _focal_context(repo, failing_tests, intent)
|
|
202
|
+
if focal is None:
|
|
203
|
+
ref = refusals.from_engine("no_candidate", ctx={"target": target, "run_id": rid})
|
|
204
|
+
return _store(repo, _result.make(codes.REFUSED, rid, "fix", code=ref.code, refusal=ref),
|
|
205
|
+
key, inputs)
|
|
206
|
+
stage("REMOTE", f"hosted engine synthesizes {focal['function']} (focal module only transits)")
|
|
207
|
+
try:
|
|
208
|
+
res = remote.candidate(list(failing_tests), focal)
|
|
209
|
+
except remote.RemoteError as e:
|
|
210
|
+
ref = refusals.build(codes.GATE_PRECONDITION, engine_code=str(e),
|
|
211
|
+
ctx={"target": target, "run_id": rid})
|
|
212
|
+
return _store(repo, _result.make(codes.REFUSED, rid, "fix",
|
|
213
|
+
code=codes.GATE_PRECONDITION, refusal=ref), key, inputs)
|
|
214
|
+
if (res.get("status") or "") != "candidate" or not res.get("diff"):
|
|
215
|
+
reason = res.get("refuse_reason") or "no_candidate"
|
|
216
|
+
ref = refusals.from_engine(reason, ctx={"target": target, "run_id": rid})
|
|
217
|
+
return _store(repo, _result.make(codes.REFUSED, rid, "fix", code=ref.code, refusal=ref),
|
|
218
|
+
key, inputs)
|
|
219
|
+
# the diff came back against the focal content we sent -- apply to that, then GATE locally
|
|
220
|
+
parsed = diffs.parse(res["diff"])
|
|
221
|
+
edits, stale = diffs.materialize({f.path: focal["files"].get(f.path, "") for f in parsed}, parsed)
|
|
222
|
+
if not edits:
|
|
223
|
+
ref = refusals.build(codes.HUNK_CONTEXT_STALE, engine_code="remote_diff_stale",
|
|
224
|
+
ctx={"target": ", ".join(stale) or target, "run_id": rid})
|
|
225
|
+
return _store(repo, _result.make(codes.UNVERIFIABLE, rid, "fix",
|
|
226
|
+
code=codes.HUNK_CONTEXT_STALE, refusal=ref), key, inputs)
|
|
227
|
+
stage("GATE", f"differential gate on {', '.join(failing_tests)}")
|
|
228
|
+
sink: dict = {}
|
|
229
|
+
cg = engine.client_gate.run_client_gate(repo, base, edits, test_paths=list(failing_tests),
|
|
230
|
+
test_cmd=test_command, differential=True,
|
|
231
|
+
transcript_sink=sink)
|
|
232
|
+
if not cg.passed:
|
|
233
|
+
ref = refusals.from_engine("candidate_failed_execution_gate",
|
|
234
|
+
ctx={"target": target, "run_id": rid})
|
|
235
|
+
return _store(repo, _result.make(codes.REFUSED, rid, "fix", code=ref.code, refusal=ref,
|
|
236
|
+
gate=_gate_from_sink(sink, failing_tests)), key, inputs)
|
|
237
|
+
prov = [str(p) for p in (res.get("provenance") or [])][:8] + ["execution_gate:client:PASS"]
|
|
238
|
+
diff = "".join(_diff_for(repo, base, p, c) for p, c in sorted(edits.items()))
|
|
239
|
+
out = _result.make(codes.VERIFIED, rid, "fix", diff=diff, files=tuple(sorted(edits)),
|
|
240
|
+
provenance=_result.Provenance(entries=tuple(prov)),
|
|
241
|
+
gate=_gate_from_sink(sink, failing_tests), data={"target": target})
|
|
242
|
+
return _store(repo, out, key, inputs)
|
|
243
|
+
|
|
244
|
+
|
|
158
245
|
def op_write_from_intent(repo: str, intent: str, signature: str,
|
|
159
246
|
into: Optional[str] = None, emit=None,
|
|
160
247
|
prefer_purpose: Optional[str] = None) -> "_result.SubstratumResult":
|
|
@@ -85,8 +85,10 @@ def clarify(intent: str, signature: str) -> dict:
|
|
|
85
85
|
def candidate(test_ids: list, focal: dict) -> dict:
|
|
86
86
|
"""POST /api/cli/candidate -> {status, diff, provenance, refuse_reason}. The server SYNTHESIZES;
|
|
87
87
|
the client gates the returned diff locally, so the user's code + tests never leave the machine."""
|
|
88
|
+
# generous timeout: the first call on a cold server pays the numpy import chain + the lazy
|
|
89
|
+
# library's resident index load before it can synthesize
|
|
88
90
|
return _post_json(account.server() + "/api/cli/candidate",
|
|
89
|
-
{"test_ids": list(test_ids), "focal": focal}, _auth_headers())
|
|
91
|
+
{"test_ids": list(test_ids), "focal": focal}, _auth_headers(), timeout=180)
|
|
90
92
|
|
|
91
93
|
|
|
92
94
|
# ---- GitHub device-flow login --------------------------------------------------------------------
|
|
@@ -592,7 +592,7 @@ class Session:
|
|
|
592
592
|
head("MEMORY & PROOF")
|
|
593
593
|
row("runs", "all you have done, and which refusals became fixes")
|
|
594
594
|
row("show <run>", "see a run; replay <run> re-runs it byte-identical")
|
|
595
|
-
row("history", "
|
|
595
|
+
row("history", "the whole build trail; history <word> searches; run <n> replays a line")
|
|
596
596
|
head("SETUP")
|
|
597
597
|
row("doctor / init", "check the environment / what this repo can gate")
|
|
598
598
|
row("login", "connect the hosted engine for fix and write")
|
|
@@ -638,8 +638,9 @@ class Session:
|
|
|
638
638
|
sys.stdout.write("\n")
|
|
639
639
|
self._say(" cancelled (session still warm)", _CYAN)
|
|
640
640
|
continue
|
|
641
|
-
except Exception as e: # a
|
|
642
|
-
self._say(f" error: {e}", _CYAN)
|
|
641
|
+
except Exception as e: # belt: the ops refuse, never raise -- but a fault must not kill the session
|
|
642
|
+
self._say(f" unexpected internal error: {e}", _CYAN)
|
|
643
|
+
self._say(" the session is still warm; retry or try a different request", _DIM)
|
|
643
644
|
continue
|
|
644
645
|
if not keep:
|
|
645
646
|
_leave()
|
|
@@ -32,6 +32,7 @@ substratum_cli.egg-info/requires.txt
|
|
|
32
32
|
substratum_cli.egg-info/top_level.txt
|
|
33
33
|
substratum_cli/_vendor/__init__.py
|
|
34
34
|
substratum_cli/_vendor/_pro_test_derive.py
|
|
35
|
+
substratum_cli/_vendor/client_gate.py
|
|
35
36
|
substratum_cli/commands/__init__.py
|
|
36
37
|
substratum_cli/commands/apply.py
|
|
37
38
|
substratum_cli/commands/auth.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|