trace-to-fixture 0.1.0__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 (34) hide show
  1. trace_to_fixture-0.1.0/LICENSE +21 -0
  2. trace_to_fixture-0.1.0/PKG-INFO +150 -0
  3. trace_to_fixture-0.1.0/README.md +127 -0
  4. trace_to_fixture-0.1.0/pyproject.toml +36 -0
  5. trace_to_fixture-0.1.0/setup.cfg +4 -0
  6. trace_to_fixture-0.1.0/src/trace_to_fixture/__init__.py +1 -0
  7. trace_to_fixture-0.1.0/src/trace_to_fixture/build.py +279 -0
  8. trace_to_fixture-0.1.0/src/trace_to_fixture/cases.py +197 -0
  9. trace_to_fixture-0.1.0/src/trace_to_fixture/cli.py +284 -0
  10. trace_to_fixture-0.1.0/src/trace_to_fixture/load.py +70 -0
  11. trace_to_fixture-0.1.0/src/trace_to_fixture/preserve.py +24 -0
  12. trace_to_fixture-0.1.0/src/trace_to_fixture/pytest_plugin.py +17 -0
  13. trace_to_fixture-0.1.0/src/trace_to_fixture/scrub.py +332 -0
  14. trace_to_fixture-0.1.0/src/trace_to_fixture/select.py +130 -0
  15. trace_to_fixture-0.1.0/src/trace_to_fixture/verify.py +176 -0
  16. trace_to_fixture-0.1.0/src/trace_to_fixture/write.py +110 -0
  17. trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/PKG-INFO +150 -0
  18. trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/SOURCES.txt +32 -0
  19. trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/dependency_links.txt +1 -0
  20. trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/entry_points.txt +5 -0
  21. trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/requires.txt +9 -0
  22. trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/top_level.txt +1 -0
  23. trace_to_fixture-0.1.0/tests/test_cases.py +243 -0
  24. trace_to_fixture-0.1.0/tests/test_cli.py +474 -0
  25. trace_to_fixture-0.1.0/tests/test_e2e.py +142 -0
  26. trace_to_fixture-0.1.0/tests/test_load.py +25 -0
  27. trace_to_fixture-0.1.0/tests/test_pi_review.py +66 -0
  28. trace_to_fixture-0.1.0/tests/test_scrub_detectors.py +63 -0
  29. trace_to_fixture-0.1.0/tests/test_scrub_pseudonym.py +56 -0
  30. trace_to_fixture-0.1.0/tests/test_scrub_spans.py +147 -0
  31. trace_to_fixture-0.1.0/tests/test_scrub_walker.py +172 -0
  32. trace_to_fixture-0.1.0/tests/test_select.py +142 -0
  33. trace_to_fixture-0.1.0/tests/test_verify.py +140 -0
  34. trace_to_fixture-0.1.0/tests/test_write.py +155 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faultline-lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: trace-to-fixture
3
+ Version: 0.1.0
4
+ Summary: Turn real agent runs into safe, small, repeatable tests: scrub private data, keep one example of each behaviour, write pytest fixtures for faultgate-zero.
5
+ Author: Faultline-lab
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Faultline-lab/trace-to-fixture
8
+ Keywords: ai,agents,testing,fixtures,opentelemetry,pytest
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: Pytest
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: faultgate<0.5,>=0.4.2
16
+ Requires-Dist: faultgate-zero<0.2,>=0.1
17
+ Requires-Dist: pytest>=8
18
+ Provides-Extra: pii
19
+ Requires-Dist: faultgate[pii]<0.5,>=0.4.2; extra == "pii"
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-xdist>=3; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # Trace-to-Fixture
25
+
26
+ Turn real agent runs into safe, small, repeatable tests.
27
+
28
+ Read a trace export, mask the private data it can detect, keep one example of each distinct behaviour, and write
29
+ tests that run in CI with [faultgate-zero](https://github.com/Faultline-lab/faultgate-zero). The offline tests pass on
30
+ day one with no model calls. The live tests re-run your agent on each real prompt and check it.
31
+
32
+ ```
33
+ T2F_SALT=<a secret> trace-to-fixture build export.json --out tests/agent_fixtures
34
+ ```
35
+
36
+ Free and MIT-licensed. Runs on your machine and sends nothing anywhere.
37
+
38
+ > **What it does and does not do.** It *masks the private data its detectors can find* (secrets, emails, US SSNs,
39
+ > card numbers, IP addresses, phone numbers; names and places with the `pii` extra), plus anything you list in a
40
+ > deny file or as a sensitive key. It is not an anonymisation or compliance product, and detectors miss things such as
41
+ > internal customer ids. Always run `scan`, read what it found, and review the output before you commit it.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install trace-to-fixture # needs Python 3.11+
47
+ pip install 'trace-to-fixture[pii]' # adds Presidio for names and places
48
+ ```
49
+
50
+ It builds on `faultgate` (0.4.2 or newer) (format loaders, private-data detectors) and `faultgate-zero` (the checks).
51
+
52
+ ## Quickstart (5 minutes)
53
+
54
+ The `examples/` folder holds a small refund agent, a "production" export of three of its runs, and a policy.
55
+
56
+ ```bash
57
+ git clone https://github.com/Faultline-lab/trace-to-fixture && cd trace-to-fixture
58
+ pip install -e .
59
+ cd examples
60
+
61
+ # 1. See what is in the export. Nothing is written (scan needs no salt).
62
+ trace-to-fixture scan export.otlp.json
63
+
64
+ # 2. Build the fixture set. build needs a salt: a secret in real use, never committed.
65
+ # On Windows PowerShell use $env:T2F_SALT = "quickstart-demo-salt" or pass --salt-file FILE.
66
+ export T2F_SALT="quickstart-demo-salt"
67
+ trace-to-fixture build export.otlp.json --out ../tests_out --policy policy.json --agent refund_agent:run
68
+
69
+ # 3. Run the offline tests. No model calls, free.
70
+ pytest ../tests_out
71
+ ```
72
+
73
+ `tests_out/` now holds `manifest.json`, `fixtures/*.otlp.json`, `test_agent_fixtures.py` and `substitutions.json`.
74
+
75
+ ### Live replay
76
+
77
+ Live tests re-run your agent on each real prompt, so they need a test-environment value for every placeholder:
78
+
79
+ ```bash
80
+ pytest ../tests_out -m agent_live -rs # skipped: "needs substitutions for <EMAIL_b1beab65>, <PHONE_29a793ca>"
81
+ ```
82
+
83
+ Put test data (never real values) in `substitutions.json`, then run again:
84
+
85
+ ```json
86
+ { "<EMAIL_b1beab65>": "test.user1@example.com", "<PHONE_29a793ca>": "+1 555 0100" }
87
+ ```
88
+
89
+ The known failure (a $900 refund over the $500 limit) now **xfails**: it still fails like production. Fix the bug
90
+ (`FIXED = True` in `refund_agent.py`) and the test fails with:
91
+
92
+ ```
93
+ f-xxxxxxxx no longer fails. Promote it to a guard:
94
+ trace-to-fixture promote ../tests_out f-xxxxxxxx
95
+ ```
96
+
97
+ After `promote`, the fixture is a guard: if the bug ever comes back the test fails with the policy reason.
98
+ Live tests run only with `-m agent_live` or `T2F_LIVE=1`, so a plain `pytest` never spends tokens.
99
+
100
+ ## Commands
101
+
102
+ | Command | What it does | Writes |
103
+ |---|---|---|
104
+ | `scan INPUT` | Format, precheck, counts of detected values by kind and by field, dropped attribute keys. | nothing |
105
+ | `build INPUT --out DIR` | Load, precheck, scrub, prove verdicts are unchanged, group, select, write, leak scan. | `DIR` |
106
+ | `verify DIR` | Leak scan of every file, hash checks, loading in faultgate-zero and faultgate, snapshot verdicts. | nothing |
107
+ | `explain INPUT --trace ID` | One trace: changed fields (path, kind, lengths, never values), raw vs scrubbed verdicts, signature, why kept. | nothing |
108
+ | `promote DIR ID` | Change a fixture from `known_failure` to `guard`. | `manifest.json` |
109
+
110
+ Options: `--format` (default `auto`; OTLP, OpenInference, Langfuse, LangSmith, Braintrust), `--policy FILE`
111
+ (faultgate-zero policy format), `--checks a,b,c`, `--agent module:function`, `--per-group N` (1), `--max-clean N` (20),
112
+ `--pii builtin|presidio`, `--deny-literals FILE`, `--sensitive-key NAME` (repeatable), `--salt-file FILE` or `T2F_SALT`,
113
+ `--drop-mismatched`, `--bundle FILE`, `-v` / `T2F_DEBUG=1` (stage counts and timings on stderr, never values).
114
+
115
+ Exit codes: `0` success, `1` a problem was found (a leak, a verdict mismatch, snapshot drift), `2` a usage or input
116
+ error (a bad file, a missing salt, precheck errors).
117
+
118
+ ## How it works
119
+
120
+ 1. **Load** the export with faultgate's readers; precheck errors stop the build.
121
+ 2. **Scrub.** Each detected value becomes `<KIND_xxxxxxxx>`, an HMAC of the exact value under your secret salt: the same
122
+ value always gets the same placeholder, so checks that compare values keep working. Attributes the checks never read
123
+ are dropped; trace and span ids are remapped; time is rebased. JSON inside strings (up to two levels) is walked.
124
+ 3. **Prove nothing changed.** Every check runs on each trace before and after scrubbing; verdicts must match.
125
+ 4. **Select.** Runs with the same behaviour (tool path, fired checks, outcome, tool errors, size) form a group. Every
126
+ group where a check fired is kept; the largest clean groups fill the rest. Selection is deterministic.
127
+ 5. **Write** fixtures, a manifest, a generated pytest file, and a leak scan of what was written.
128
+
129
+ Same input, same salt and same options give byte-identical output. Use one stable salt per project (a different salt
130
+ changes every placeholder and fixture id). `manifest.json` records only a fingerprint of the salt.
131
+
132
+ ## Privacy rules
133
+
134
+ - Raw values live only in memory: never in files, logs, exceptions or the manifest.
135
+ - The salt is a secret. Keep it in a secret store or CI secret, never in git. One salt per customer audit.
136
+ - `substitutions.json` holds test data only.
137
+ - If a detector misses something, add it to `--deny-literals` (one literal per line) or `--sensitive-key`.
138
+
139
+ ## Develop
140
+
141
+ ```bash
142
+ pip install -e '.[dev]'
143
+ pytest # unit tests
144
+ python evals/run.py # acceptance table (add --perf for the 5,000-trace speed row)
145
+ ```
146
+
147
+ ## Limits
148
+
149
+ Multi-turn conversations are kept as offline fixtures only (`replayable: false`). Tool responses are not mocked:
150
+ live replay calls your own tools in your own test environment. Input is an export file; there is no vendor API puller.
@@ -0,0 +1,127 @@
1
+ # Trace-to-Fixture
2
+
3
+ Turn real agent runs into safe, small, repeatable tests.
4
+
5
+ Read a trace export, mask the private data it can detect, keep one example of each distinct behaviour, and write
6
+ tests that run in CI with [faultgate-zero](https://github.com/Faultline-lab/faultgate-zero). The offline tests pass on
7
+ day one with no model calls. The live tests re-run your agent on each real prompt and check it.
8
+
9
+ ```
10
+ T2F_SALT=<a secret> trace-to-fixture build export.json --out tests/agent_fixtures
11
+ ```
12
+
13
+ Free and MIT-licensed. Runs on your machine and sends nothing anywhere.
14
+
15
+ > **What it does and does not do.** It *masks the private data its detectors can find* (secrets, emails, US SSNs,
16
+ > card numbers, IP addresses, phone numbers; names and places with the `pii` extra), plus anything you list in a
17
+ > deny file or as a sensitive key. It is not an anonymisation or compliance product, and detectors miss things such as
18
+ > internal customer ids. Always run `scan`, read what it found, and review the output before you commit it.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install trace-to-fixture # needs Python 3.11+
24
+ pip install 'trace-to-fixture[pii]' # adds Presidio for names and places
25
+ ```
26
+
27
+ It builds on `faultgate` (0.4.2 or newer) (format loaders, private-data detectors) and `faultgate-zero` (the checks).
28
+
29
+ ## Quickstart (5 minutes)
30
+
31
+ The `examples/` folder holds a small refund agent, a "production" export of three of its runs, and a policy.
32
+
33
+ ```bash
34
+ git clone https://github.com/Faultline-lab/trace-to-fixture && cd trace-to-fixture
35
+ pip install -e .
36
+ cd examples
37
+
38
+ # 1. See what is in the export. Nothing is written (scan needs no salt).
39
+ trace-to-fixture scan export.otlp.json
40
+
41
+ # 2. Build the fixture set. build needs a salt: a secret in real use, never committed.
42
+ # On Windows PowerShell use $env:T2F_SALT = "quickstart-demo-salt" or pass --salt-file FILE.
43
+ export T2F_SALT="quickstart-demo-salt"
44
+ trace-to-fixture build export.otlp.json --out ../tests_out --policy policy.json --agent refund_agent:run
45
+
46
+ # 3. Run the offline tests. No model calls, free.
47
+ pytest ../tests_out
48
+ ```
49
+
50
+ `tests_out/` now holds `manifest.json`, `fixtures/*.otlp.json`, `test_agent_fixtures.py` and `substitutions.json`.
51
+
52
+ ### Live replay
53
+
54
+ Live tests re-run your agent on each real prompt, so they need a test-environment value for every placeholder:
55
+
56
+ ```bash
57
+ pytest ../tests_out -m agent_live -rs # skipped: "needs substitutions for <EMAIL_b1beab65>, <PHONE_29a793ca>"
58
+ ```
59
+
60
+ Put test data (never real values) in `substitutions.json`, then run again:
61
+
62
+ ```json
63
+ { "<EMAIL_b1beab65>": "test.user1@example.com", "<PHONE_29a793ca>": "+1 555 0100" }
64
+ ```
65
+
66
+ The known failure (a $900 refund over the $500 limit) now **xfails**: it still fails like production. Fix the bug
67
+ (`FIXED = True` in `refund_agent.py`) and the test fails with:
68
+
69
+ ```
70
+ f-xxxxxxxx no longer fails. Promote it to a guard:
71
+ trace-to-fixture promote ../tests_out f-xxxxxxxx
72
+ ```
73
+
74
+ After `promote`, the fixture is a guard: if the bug ever comes back the test fails with the policy reason.
75
+ Live tests run only with `-m agent_live` or `T2F_LIVE=1`, so a plain `pytest` never spends tokens.
76
+
77
+ ## Commands
78
+
79
+ | Command | What it does | Writes |
80
+ |---|---|---|
81
+ | `scan INPUT` | Format, precheck, counts of detected values by kind and by field, dropped attribute keys. | nothing |
82
+ | `build INPUT --out DIR` | Load, precheck, scrub, prove verdicts are unchanged, group, select, write, leak scan. | `DIR` |
83
+ | `verify DIR` | Leak scan of every file, hash checks, loading in faultgate-zero and faultgate, snapshot verdicts. | nothing |
84
+ | `explain INPUT --trace ID` | One trace: changed fields (path, kind, lengths, never values), raw vs scrubbed verdicts, signature, why kept. | nothing |
85
+ | `promote DIR ID` | Change a fixture from `known_failure` to `guard`. | `manifest.json` |
86
+
87
+ Options: `--format` (default `auto`; OTLP, OpenInference, Langfuse, LangSmith, Braintrust), `--policy FILE`
88
+ (faultgate-zero policy format), `--checks a,b,c`, `--agent module:function`, `--per-group N` (1), `--max-clean N` (20),
89
+ `--pii builtin|presidio`, `--deny-literals FILE`, `--sensitive-key NAME` (repeatable), `--salt-file FILE` or `T2F_SALT`,
90
+ `--drop-mismatched`, `--bundle FILE`, `-v` / `T2F_DEBUG=1` (stage counts and timings on stderr, never values).
91
+
92
+ Exit codes: `0` success, `1` a problem was found (a leak, a verdict mismatch, snapshot drift), `2` a usage or input
93
+ error (a bad file, a missing salt, precheck errors).
94
+
95
+ ## How it works
96
+
97
+ 1. **Load** the export with faultgate's readers; precheck errors stop the build.
98
+ 2. **Scrub.** Each detected value becomes `<KIND_xxxxxxxx>`, an HMAC of the exact value under your secret salt: the same
99
+ value always gets the same placeholder, so checks that compare values keep working. Attributes the checks never read
100
+ are dropped; trace and span ids are remapped; time is rebased. JSON inside strings (up to two levels) is walked.
101
+ 3. **Prove nothing changed.** Every check runs on each trace before and after scrubbing; verdicts must match.
102
+ 4. **Select.** Runs with the same behaviour (tool path, fired checks, outcome, tool errors, size) form a group. Every
103
+ group where a check fired is kept; the largest clean groups fill the rest. Selection is deterministic.
104
+ 5. **Write** fixtures, a manifest, a generated pytest file, and a leak scan of what was written.
105
+
106
+ Same input, same salt and same options give byte-identical output. Use one stable salt per project (a different salt
107
+ changes every placeholder and fixture id). `manifest.json` records only a fingerprint of the salt.
108
+
109
+ ## Privacy rules
110
+
111
+ - Raw values live only in memory: never in files, logs, exceptions or the manifest.
112
+ - The salt is a secret. Keep it in a secret store or CI secret, never in git. One salt per customer audit.
113
+ - `substitutions.json` holds test data only.
114
+ - If a detector misses something, add it to `--deny-literals` (one literal per line) or `--sensitive-key`.
115
+
116
+ ## Develop
117
+
118
+ ```bash
119
+ pip install -e '.[dev]'
120
+ pytest # unit tests
121
+ python evals/run.py # acceptance table (add --perf for the 5,000-trace speed row)
122
+ ```
123
+
124
+ ## Limits
125
+
126
+ Multi-turn conversations are kept as offline fixtures only (`replayable: false`). Tool responses are not mocked:
127
+ live replay calls your own tools in your own test environment. Input is an export file; there is no vendor API puller.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "trace-to-fixture"
7
+ version = "0.1.0"
8
+ description = "Turn real agent runs into safe, small, repeatable tests: scrub private data, keep one example of each behaviour, write pytest fixtures for faultgate-zero."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ authors = [{ name = "Faultline-lab" }]
14
+ keywords = ["ai", "agents", "testing", "fixtures", "opentelemetry", "pytest"]
15
+ classifiers = ["Development Status :: 3 - Alpha", "Framework :: Pytest", "Programming Language :: Python :: 3"]
16
+ dependencies = ["faultgate>=0.4.2,<0.5", "faultgate-zero>=0.1,<0.2", "pytest>=8"]
17
+
18
+ [project.optional-dependencies]
19
+ pii = ["faultgate[pii]>=0.4.2,<0.5"]
20
+ dev = ["pytest-xdist>=3"]
21
+
22
+ [project.scripts]
23
+ trace-to-fixture = "trace_to_fixture.cli:main"
24
+
25
+ [project.entry-points.pytest11]
26
+ trace_to_fixture = "trace_to_fixture.pytest_plugin"
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/Faultline-lab/trace-to-fixture"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ markers = ["slow: performance tests, run nightly"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,279 @@
1
+ """scan, build and explain pipelines (spec sections 4, 6, 9, 10, 11)."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass, field, replace
8
+ from pathlib import Path
9
+ from typing import Callable
10
+
11
+ from faultgate_zero import otlp
12
+ from faultgate_zero.checks import DEFAULT_CHECKS, Options, resolve
13
+ from faultgate_zero.policy import Policy
14
+ from faultgate_zero.runner import Settings
15
+ from faultgate_zero.trace import Trace, messages, parts
16
+
17
+ from . import __version__, load, preserve, write
18
+ from .scrub import PLACEHOLDER_RE, Scrubber, ScrubReport, salt_id, presidio_find
19
+ from .select import Candidate, Selection, select, signature
20
+
21
+
22
+ class BuildError(Exception):
23
+ """Exit code 1: a problem was found (leak, verdict mismatch)."""
24
+
25
+
26
+ @dataclass
27
+ class Options_:
28
+ input: Path
29
+ out: Path | None = None
30
+ fmt: str = "auto"
31
+ policy: Path | None = None
32
+ checks: str | None = None
33
+ agent: str | None = None
34
+ per_group: int = 1
35
+ max_clean: int = 20
36
+ pii: str = "builtin"
37
+ deny_literals: list[str] = field(default_factory=list)
38
+ sensitive_keys: list[str] = field(default_factory=list)
39
+ salt: bytes = b""
40
+ drop_mismatched: bool = False
41
+ bundle: Path | None = None
42
+
43
+
44
+ def make_scrubber(o: Options_) -> Scrubber:
45
+ extra = [presidio_find()] if o.pii == "presidio" else []
46
+ return Scrubber(o.salt, deny_literals=o.deny_literals, sensitive_keys=o.sensitive_keys, extra_finders=extra)
47
+
48
+
49
+ def make_settings(o: Options_) -> Settings:
50
+ names = resolve([c.strip() for c in o.checks.split(",")]) if o.checks else DEFAULT_CHECKS
51
+ return Settings(checks=names, policy=Policy.load(o.policy) if o.policy else Policy(),
52
+ options=Options(), redact=make_scrubber(o).scrub_text)
53
+
54
+
55
+ @dataclass
56
+ class ScanResult:
57
+ fmt: str
58
+ traces: int
59
+ tool_steps: int
60
+ warnings: list
61
+ report: ScrubReport
62
+
63
+
64
+ def scan(o: Options_) -> ScanResult:
65
+ fmt, traces, warnings = load.load(o.input, o.fmt)
66
+ sc = make_scrubber(o)
67
+ for t in traces:
68
+ sc.scrub_trace(t)
69
+ return ScanResult(fmt, len(traces), sum(len(t.steps) for t in traces), warnings, sc.report)
70
+
71
+
72
+ def user_messages(trace: Trace) -> set[str]:
73
+ out: set[str] = set()
74
+ spans = [trace.root] if trace.root else []
75
+ for s in [*spans, *trace.model_calls]:
76
+ for m in messages(s.attrs.get("gen_ai.input.messages")):
77
+ if m.get("role") == "user":
78
+ text = "".join(str(p.get("content", "")) for p in parts(m) if p.get("type") == "text")
79
+ if text.strip():
80
+ out.add(text)
81
+ return out
82
+
83
+
84
+ def placeholders_in(text: str) -> list[str]:
85
+ return sorted(set(PLACEHOLDER_RE.findall(text)))
86
+
87
+
88
+ @dataclass
89
+ class Processed:
90
+ trace_hash: str
91
+ scrubbed: Trace
92
+ fixture_id: str
93
+ fired: frozenset[str]
94
+ reasons: dict[str, str]
95
+ expected: dict[str, bool]
96
+ json_size: int
97
+ mismatch: list[str]
98
+
99
+
100
+ def _mask_denied(text: str, patterns) -> str:
101
+ """A policy reason can quote the denied text; the policy's own words must not travel into the manifest."""
102
+ for pat in patterns:
103
+ if pat:
104
+ text = re.sub(re.escape(pat), "<DENIED>", text, flags=re.IGNORECASE)
105
+ return text
106
+
107
+
108
+ def process(traces: list[Trace], sc: Scrubber, settings: Settings,
109
+ progress: Callable[[str], None] | None = None) -> list[Processed]:
110
+ from faultgate_zero import runner
111
+ out = []
112
+ for t in traces:
113
+ raw = preserve.verdicts(t, settings)
114
+ s = sc.scrub_trace(t)
115
+ fid = write.canonical_id(s)
116
+ root = s.root
117
+ if root is not None:
118
+ root.attrs["faultgate.scenario"] = fid
119
+ s.scenario = fid
120
+ res = runner.check(s, settings)
121
+ got = {v.check: v.fired for v in res.verdicts}
122
+ mism = sorted(k for k in raw.keys() | got.keys() if raw.get(k) != got.get(k))
123
+ out.append(Processed(
124
+ trace_hash=hashlib.sha256(t.trace_id.encode()).hexdigest()[:12], scrubbed=s, fixture_id=fid,
125
+ fired=frozenset(k for k, v in got.items() if v), reasons={v.check: _mask_denied(v.reason, settings.policy.deny) for v in res.fired},
126
+ expected=dict(sorted(got.items())), json_size=len(write.fixture_bytes(s)), mismatch=mism))
127
+ if progress:
128
+ progress(f"scrubbed {len(out)} traces")
129
+ return out
130
+
131
+
132
+ @dataclass
133
+ class BuildResult:
134
+ fmt: str
135
+ loaded: int
136
+ scrubbed_values: int
137
+ identical: int
138
+ mismatches: list[tuple[str, list[str]]]
139
+ dropped: int
140
+ selection: Selection
141
+ removed: list[str]
142
+ leaks: list
143
+ out: Path
144
+ warnings: list = field(default_factory=list)
145
+
146
+ def summary_lines(self) -> list[str]:
147
+ sel = self.selection
148
+ failing = sum(1 for _, _, s in sel.kept if s == "known_failure")
149
+ guards = len(sel.kept) - failing
150
+ lines = [
151
+ f"loaded {self.loaded:,} traces ({self.fmt})",
152
+ f"scrubbed {self.scrubbed_values:,} values in {self.loaded:,} traces",
153
+ f"verdicts {self.identical:,} of {self.loaded:,} identical before and after scrubbing",
154
+ ]
155
+ if self.mismatches:
156
+ lines.append(f"mismatch {len(self.mismatches)} traces changed verdicts"
157
+ + (f"; {self.dropped} dropped" if self.dropped else ""))
158
+ lines += [
159
+ f"grouped {len(sel.groups)} behaviours, {sel.failure_groups} with a failing check",
160
+ f"kept {len(sel.kept)} fixtures: {failing} known failures, {guards} guards "
161
+ f"(they stand for {sel.traces_represented:,} of {self.loaded - self.dropped:,} traces)",
162
+ ]
163
+ if self.removed:
164
+ lines.append(f"removed {len(self.removed)} fixtures no longer selected: {', '.join(self.removed)}")
165
+ lines += [f"leak scan {len(self.leaks)} findings", f"wrote {self.out}"]
166
+ return lines
167
+
168
+
169
+ def build(o: Options_, progress: Callable[[str], None] | None = None) -> BuildResult:
170
+ from .verify import scan_files
171
+ fmt, traces, warnings = load.load(o.input, o.fmt)
172
+ settings = make_settings(o)
173
+ sc = make_scrubber(o)
174
+ done = process(traces, sc, settings, progress)
175
+ mism = [(p.trace_hash, p.mismatch) for p in done if p.mismatch]
176
+ if mism and not o.drop_mismatched:
177
+ raise BuildError("verdicts changed by scrubbing for "
178
+ + "; ".join(f"{h}: {','.join(c)}" for h, c in mism[:10])
179
+ + " (see 'trace-to-fixture explain'; --drop-mismatched continues without them)")
180
+ good = [p for p in done if not p.mismatch]
181
+ selection = select([Candidate(p.fixture_id, p.scrubbed, p.fired, p.json_size, [p.trace_hash]) for p in good],
182
+ o.per_group, o.max_clean)
183
+ by_id = {p.fixture_id: p for p in good}
184
+ out = Path(o.out)
185
+ old = write.read_manifest(out) or {}
186
+ old_status = {f["id"]: f["status"] for f in old.get("fixtures", [])}
187
+
188
+ entries, kept_traces = [], {}
189
+ for cand, group, status in selection.kept:
190
+ p = by_id[cand.fixture_id]
191
+ status = old_status.get(cand.fixture_id, status)
192
+ user = p.scrubbed.user_id
193
+ entries.append({
194
+ "id": cand.fixture_id, "file": f"{write.FIXTURE_DIR}/{cand.fixture_id}.otlp.json",
195
+ "sha256": write.sha256_bytes(write.fixture_bytes(p.scrubbed)), "status": status,
196
+ "signature": group.signature, "group_size": group.size, "expected": p.expected,
197
+ "reasons": dict(sorted(p.reasons.items())), "replayable": len(user_messages(p.scrubbed)) == 1
198
+ and bool(p.scrubbed.prompt.strip()),
199
+ "prompt_placeholders": placeholders_in(p.scrubbed.prompt), "user": user,
200
+ })
201
+ kept_traces[cand.fixture_id] = p.scrubbed
202
+ entries.sort(key=lambda e: e["id"])
203
+ opts = {"loop_min_steps": settings.options.loop_min_steps, "loop_empty_share": settings.options.loop_empty_share,
204
+ "repeat_threshold": settings.options.repeat_threshold}
205
+ manifest = {
206
+ "schema": write.SCHEMA, "tool": f"trace-to-fixture {__version__}", "engine": _engine(),
207
+ "source": {"format": fmt, "traces": len(traces), "sha256": load.file_sha256(o.input)},
208
+ "salt_id": salt_id(o.salt), "checks": list(settings.checks),
209
+ "policy": ({"path": write.rel_policy(str(Path(o.policy).resolve()), out.resolve()),
210
+ "sha256": write.sha256_file(o.policy)} if o.policy else None),
211
+ "options": opts, "agent": o.agent, "selection": {"per_group": o.per_group, "max_clean": o.max_clean},
212
+ "coverage": {"groups": len(selection.groups), "failure_groups": selection.failure_groups,
213
+ "kept": len(entries), "traces_represented": selection.traces_represented},
214
+ "fixtures": entries,
215
+ }
216
+ if manifest["policy"] is None:
217
+ del manifest["policy"]
218
+ pending = {f["file"]: write.fixture_bytes(kept_traces[f["id"]]) for f in entries}
219
+ pending["manifest.json"] = write.dumps(manifest)
220
+ pending[write.TEST_FILE] = write.generated_test(o.agent).encode("utf-8")
221
+ leaks = scan_files(pending, o.deny_literals, [presidio_find()] if o.pii == "presidio" else [])
222
+ removed: list[str] = []
223
+ if not leaks: # nothing reaches the disk if the scan finds a value
224
+ removed = write.write_all(out, entries, kept_traces, manifest, o.agent, o.bundle)
225
+ return BuildResult(fmt, len(traces), sc.report.total, len(good), mism, len(done) - len(good),
226
+ selection, removed, leaks, out, warnings)
227
+
228
+
229
+ def _engine() -> str:
230
+ from importlib import metadata
231
+ try:
232
+ return f"faultgate-zero {metadata.version('faultgate-zero')}"
233
+ except metadata.PackageNotFoundError:
234
+ return "faultgate-zero"
235
+
236
+
237
+ @dataclass
238
+ class Explanation:
239
+ trace_hash: str
240
+ changes: list[tuple[str, str, int, int]]
241
+ raw: dict[str, bool]
242
+ scrubbed: dict[str, bool]
243
+ signature: str
244
+ group_size: int
245
+ selected: bool
246
+ why: str
247
+ fixture_id: str
248
+
249
+
250
+ def explain(o: Options_, trace_id: str) -> Explanation:
251
+ fmt, traces, _ = load.load(o.input, o.fmt)
252
+ settings = make_settings(o)
253
+ sc = make_scrubber(o)
254
+ done = process(traces, sc, settings)
255
+ want = trace_id.strip()
256
+ idx = next((i for i, t in enumerate(traces)
257
+ if t.trace_id == want or done[i].fixture_id == want or done[i].trace_hash == want), None)
258
+ if idx is None:
259
+ raise load.InputError(f"no trace with id {want!r} in the export")
260
+ one = make_scrubber(o)
261
+ one.scrub_trace(traces[idx])
262
+ cands = [Candidate(p.fixture_id, p.scrubbed, p.fired, p.json_size) for p in done if not p.mismatch]
263
+ sel = select(cands, o.per_group, o.max_clean)
264
+ p = done[idx]
265
+ sig = signature(p.scrubbed, p.fired)
266
+ group = next((g for g in sel.groups if g.signature == sig), None)
267
+ kept = {c.fixture_id for c, _, _ in sel.kept}
268
+ selected = p.fixture_id in kept
269
+ if selected:
270
+ why = "kept: " + ("it fired a check" if p.fired else "shortest run of a large clean group")
271
+ elif group is None or p.mismatch:
272
+ why = "not selected: verdicts changed by scrubbing" if p.mismatch else "not selected"
273
+ elif not p.fired:
274
+ why = "not selected: its clean group is outside --max-clean, or another run of the group is shorter"
275
+ else:
276
+ why = "not selected: another run of its failing group is shorter (--per-group)"
277
+ return Explanation(p.trace_hash, one.report.changes,
278
+ preserve.verdicts(traces[idx], settings), p.expected, sig, group.size if group else 1,
279
+ selected, why, p.fixture_id)