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.
Files changed (111) hide show
  1. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/PKG-INFO +1 -1
  2. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/__init__.py +1 -1
  3. substratum_cli-0.2.4/substratum_cli/_vendor/client_gate.py +170 -0
  4. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/apply.py +2 -2
  5. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/failing.py +2 -1
  6. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/pr.py +1 -1
  7. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/engine.py +8 -1
  8. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/gitio.py +10 -3
  9. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/product_ops.py +87 -0
  10. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/remote.py +3 -1
  11. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/session.py +4 -3
  12. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/PKG-INFO +1 -1
  13. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/SOURCES.txt +1 -0
  14. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/README.md +0 -0
  15. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/pyproject.toml +0 -0
  16. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/setup.cfg +0 -0
  17. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/README.md +0 -0
  18. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/__main__.py +0 -0
  19. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_boot.py +0 -0
  20. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_update.py +0 -0
  21. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_vendor/__init__.py +0 -0
  22. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_vendor/_pro_test_derive.py +0 -0
  23. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/_wordmark.py +0 -0
  24. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/account.py +0 -0
  25. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/build_state.py +0 -0
  26. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/cli.py +0 -0
  27. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/codes.py +0 -0
  28. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/__init__.py +0 -0
  29. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/auth.py +0 -0
  30. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/commit.py +0 -0
  31. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/explain.py +0 -0
  32. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/fix.py +0 -0
  33. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/history.py +0 -0
  34. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/init_cmd.py +0 -0
  35. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/replay.py +0 -0
  36. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/scaffold.py +0 -0
  37. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/show.py +0 -0
  38. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/undo.py +0 -0
  39. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/verify.py +0 -0
  40. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/commands/write.py +0 -0
  41. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/config.py +0 -0
  42. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/diffs.py +0 -0
  43. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/langscan.py +0 -0
  44. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/refusals.py +0 -0
  45. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/render.py +0 -0
  46. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/result.py +0 -0
  47. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/run_store.py +0 -0
  48. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/runid.py +0 -0
  49. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli/style.py +0 -0
  50. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/dependency_links.txt +0 -0
  51. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/entry_points.txt +0 -0
  52. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/requires.txt +0 -0
  53. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/substratum_cli.egg-info/top_level.txt +0 -0
  54. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_adaptive_prior.py +0 -0
  55. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_alias_warrant_trace.py +0 -0
  56. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_bidirectional_gate_contracts.py +0 -0
  57. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_coherence_evolution.py +0 -0
  58. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_coherences.py +0 -0
  59. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_connection_web_api.py +0 -0
  60. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_cross_entity_scoping.py +0 -0
  61. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_data.py +0 -0
  62. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_de28_negation_refuse.py +0 -0
  63. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_definite_description_handler.py +0 -0
  64. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_demo_regression_catalog.py +0 -0
  65. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_demo_response_shape.py +0 -0
  66. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_e1_e2_field_alignment.py +0 -0
  67. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_extraction_class_write_time.py +0 -0
  68. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_falsification_predictions.py +0 -0
  69. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_gap_d_wh_normalization.py +0 -0
  70. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_inference.py +0 -0
  71. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_inflection_cvc_rule.py +0 -0
  72. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_inflection_irregular.py +0 -0
  73. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_intervention.py +0 -0
  74. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_invariant.py +0 -0
  75. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_legal_citation_handler.py +0 -0
  76. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_lens_noop_seam.py +0 -0
  77. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_markup_tag_handler.py +0 -0
  78. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_nl_parser.py +0 -0
  79. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_nl_parser_adversarial.py +0 -0
  80. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_noise.py +0 -0
  81. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_pass2_contextual.py +0 -0
  82. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_perspectives.py +0 -0
  83. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_polysemous_token_alias_guard.py +0 -0
  84. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_possessive_normalization.py +0 -0
  85. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_predicate_canon.py +0 -0
  86. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_projection_gate.py +0 -0
  87. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_projection_gate_tag_question.py +0 -0
  88. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_query_normalizer.py +0 -0
  89. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_records_answer_format.py +0 -0
  90. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_referent_aboutness_gate.py +0 -0
  91. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_referent_quality_gate.py +0 -0
  92. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_refuse_message_topic_neutrality.py +0 -0
  93. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_relation_detection.py +0 -0
  94. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_relaxation.py +0 -0
  95. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_residual_content_gate.py +0 -0
  96. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_reverse_lookup_subject_filter.py +0 -0
  97. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_sec_statement_aliases.py +0 -0
  98. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_span_lens.py +0 -0
  99. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_span_lens_query_integration.py +0 -0
  100. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_substrate.py +0 -0
  101. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_synonymy_detector.py +0 -0
  102. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_table_ingest.py +0 -0
  103. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_triple_ingest.py +0 -0
  104. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_two_phase_schema.py +0 -0
  105. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_user_asserted_aliases.py +0 -0
  106. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_v2_api.py +0 -0
  107. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_v2_api_claims.py +0 -0
  108. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_v2_claim_determinism.py +0 -0
  109. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_word_role.py +0 -0
  110. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_word_role_wiring.py +0 -0
  111. {substratum_cli-0.2.2 → substratum_cli-0.2.4}/tests/test_xbrl_doc_aliases.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: substratum-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Substratum: verified-or-refused code, with provenance. The terminal over the comprehension engine.
5
5
  Author: Substratum
6
6
  License: LicenseRef-Proprietary
@@ -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.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, timeout=timeout,
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, timeout=60)
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, timeout=60)
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", "re-run earlier commands (!<n> !! !<prefix>)")
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 command fault must not kill the session
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: substratum-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Substratum: verified-or-refused code, with provenance. The terminal over the comprehension engine.
5
5
  Author: Substratum
6
6
  License: LicenseRef-Proprietary
@@ -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