Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/deep-dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ A stable system is not one that claims to have no edges — it is one whose edge
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.

**Verified this pass:** `pytest` → green, 2,197 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
**Verified this pass:** `pytest` → green, 2,205 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.

[ROADMAP.md](../ROADMAP.md) tracks what is built and what is not, item by item.

Expand Down
125 changes: 114 additions & 11 deletions tests/test_cli_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from __future__ import annotations

import json
import os
import re
import selectors
Expand All @@ -30,14 +31,41 @@
not hasattr(os, "openpty"), reason="needs a pty, which Windows has no equivalent for"
)

def _fixed(*args: str):
"""A case whose argv needs nothing built first."""

def build(_workspace: dict) -> list[str]:
return list(args)

return build


# Commands with styled human-mode output. Exit codes are deliberately not pinned
# here: `models --check` exits 1 when the host can reach no real provider, which
# is correct and is what a machine with no credentials does.
#
# A case is a *builder* rather than a literal argv, because the styled commands
# that were missing from this gate all take a file argument — a topology, a
# trace — and those have to be made first. `run`, `trace` and `metrics` are
# exactly the commands most likely to be piped (`run --check-only` is a linter
# in this repo's own CI, `trace | grep`, `metrics` in a script), so they are the
# ones whose piped bytes matter most, and they were the ones nothing checked.
STYLED = [
pytest.param(["plan", "investigate the checkout outage", "--scripted"], id="plan"),
pytest.param(["models"], id="models"),
pytest.param(["models", "--check"], id="models-check"),
pytest.param(["demo", "stage0"], id="demo-stage0"),
pytest.param(_fixed("plan", "investigate the checkout outage", "--scripted"), id="plan"),
pytest.param(_fixed("models"), id="models"),
pytest.param(_fixed("models", "--check"), id="models-check"),
pytest.param(_fixed("demo", "stage0"), id="demo-stage0"),
# The ADMITTED verdict block and its accent-tinted fingerprint.
pytest.param(
lambda w: ["run", str(w["admitted"]), "--check-only", *w["hermetic"]],
id="run-check-only",
),
# The REFUSED block, which tints the key *and* the value of its verdict line
# and then paints a row per objection — a shape no other case reaches.
pytest.param(lambda w: ["run", str(w["refused"]), *w["hermetic"]], id="run-refused"),
# A painted row per trace event: dim, cell, accent and err in one output.
pytest.param(lambda w: ["trace", str(w["trace"])], id="trace"),
pytest.param(lambda w: ["metrics", str(w["trace"]), w["run_id"]], id="metrics"),
]

# The subset whose output is reproducible enough to compare byte-for-byte across
Expand All @@ -58,6 +86,19 @@
# The default trace directory stamp: two invocations of one command are two
# runs with two stamps, and the comparison is about styling, not clocks.
_RUNDIR = re.compile(r"\d{8}-\d{6}-[0-9a-f]{6}")
# `run`'s fingerprint, which is *not* stable across two loads of the same
# topology file: `Subgraph.proposal_id` defaults to a fresh `uuid4` and
# `fingerprint()` hashes the whole model, `proposal_id` included. Normalised
# here so this file can still compare the styling of the line it appears on —
# which is the whole point of covering the ADMITTED block — rather than dropping
# `run` out of the comparison over one token.
#
# It is normalised under protest. `graphrun.py` prints it under the comment "the
# fingerprint is what a later run is compared against", and a value that differs
# on every invocation cannot do that job. Filed separately; if that is fixed so
# the fingerprint follows the topology, this normaliser should be deleted and the
# comparison will be stricter for it.
_FINGERPRINT = re.compile(r"(?<=fingerprint: )[0-9a-f]{16}")


def _env(**extra: str) -> dict[str, str]:
Expand Down Expand Up @@ -127,11 +168,72 @@ def _on_pty(args: list[str], **extra: str) -> tuple[str, int]:

def _normalise(text: str) -> str:
text = _TMPDIR.sub("/tmp/grapharc-NORMALISED", text)
return _RUNDIR.sub("RUNDIR-NORMALISED", text)


@pytest.mark.parametrize("args", STYLED)
def test_a_terminal_gets_escapes_and_a_pipe_gets_none(args):
text = _RUNDIR.sub("RUNDIR-NORMALISED", text)
return _FINGERPRINT.sub("FINGERPRINT-NORMALISED", text)


@pytest.fixture(scope="module")
def workspace(tmp_path_factory):
"""Files the file-taking styled commands need, built once for the module.

Hermetic on purpose, and the `hermetic` argv is the load-bearing part.
`run` resolves its registry and policy from the working directory when not
told otherwise, and this repository's root carries a `registry.py` and a
`grapharc.toml` that are *gitignored* — dogfooding residue. A case that
relied on them would read one registry here and a different one in CI, and
compare output that differs for a reason that has nothing to do with
styling. So the registry is named explicitly and `--config` points at an
empty file, which is what keeps `./grapharc.toml` out of it.
"""
root = tmp_path_factory.mktemp("cli-style")

def topology(nodes, edges):
return json.dumps({"nodes": nodes, "edges": edges}) + "\n"

admitted = root / "admitted.json"
admitted.write_text(
topology(
[{"name": "gather", "kind": "collect_context"}],
[
{"source": "__start__", "target": "gather"},
{"source": "gather", "target": "__end__"},
],
),
encoding="utf-8",
)
# Two objections rather than one, so the per-objection row is exercised more
# than once: a sentinel pointing the wrong way, and an unregistered kind.
refused = root / "refused.json"
refused.write_text(
topology(
[{"name": "triage", "kind": "not_a_registered_kind"}],
[
{"source": "__end__", "target": "triage"},
{"source": "triage", "target": "__end__"},
],
),
encoding="utf-8",
)
config = root / "empty.toml"
config.write_text("", encoding="utf-8")

trace = root / "trace.jsonl"
out, err, code = _piped(["demo", "stage0", "--trace", str(trace)])
assert code == 0, f"could not produce a trace to style:\n{out}\n{err}"
run_id = json.loads(trace.read_text(encoding="utf-8").splitlines()[0])["run_id"]

return {
"admitted": admitted,
"refused": refused,
"trace": trace,
"run_id": run_id,
"hermetic": ["--registry", "grapharc.stdlib:build_registry", "--config", str(config)],
}


@pytest.mark.parametrize("build", STYLED)
def test_a_terminal_gets_escapes_and_a_pipe_gets_none(build, workspace):
args = build(workspace)
"""Both halves in one test: styling must be real, and confined to a terminal."""
on_pty, pty_code = _on_pty(args)
out, err, piped_code = _piped(args)
Expand All @@ -145,14 +247,15 @@ def test_a_terminal_gets_escapes_and_a_pipe_gets_none(args):
assert "\x1b" not in err, "an escape reached piped stderr"


@pytest.mark.parametrize("args", COMPARABLE)
def test_stripping_the_escapes_reproduces_the_piped_output_exactly(args):
@pytest.mark.parametrize("build", COMPARABLE)
def test_stripping_the_escapes_reproduces_the_piped_output_exactly(build, workspace):
"""The property the byte-compared doc pages depend on.

Colour must be the *only* difference between what a terminal shows and what a
pipe carries. A tty-only change to spacing, alignment or line count would pass
the leak check above and still make README describe output nobody sees.
"""
args = build(workspace)
on_pty, _ = _on_pty(args)
out, err, _ = _piped(args)

Expand Down
Loading