faultgate 0.4.0__tar.gz → 0.4.2__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 (56) hide show
  1. {faultgate-0.4.0 → faultgate-0.4.2}/CHANGELOG.md +4 -0
  2. {faultgate-0.4.0/src/faultgate.egg-info → faultgate-0.4.2}/PKG-INFO +9 -3
  3. {faultgate-0.4.0 → faultgate-0.4.2}/README.md +4 -0
  4. {faultgate-0.4.0 → faultgate-0.4.2}/pyproject.toml +5 -3
  5. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/__init__.py +1 -1
  6. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/redact.py +64 -13
  7. {faultgate-0.4.0 → faultgate-0.4.2/src/faultgate.egg-info}/PKG-INFO +9 -3
  8. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate.egg-info/SOURCES.txt +2 -0
  9. faultgate-0.4.2/tests/test_integrations.py +34 -0
  10. faultgate-0.4.2/tests/test_redact_find.py +129 -0
  11. {faultgate-0.4.0 → faultgate-0.4.2}/LICENSE +0 -0
  12. {faultgate-0.4.0 → faultgate-0.4.2}/MANIFEST.in +0 -0
  13. {faultgate-0.4.0 → faultgate-0.4.2}/examples/formats/braintrust.json +0 -0
  14. {faultgate-0.4.0 → faultgate-0.4.2}/examples/formats/langfuse.json +0 -0
  15. {faultgate-0.4.0 → faultgate-0.4.2}/examples/formats/langsmith.json +0 -0
  16. {faultgate-0.4.0 → faultgate-0.4.2}/examples/formats/openinference.json +0 -0
  17. {faultgate-0.4.0 → faultgate-0.4.2}/examples/gate/band.json +0 -0
  18. {faultgate-0.4.0 → faultgate-0.4.2}/examples/gate/baseline_glm_run1.json +0 -0
  19. {faultgate-0.4.0 → faultgate-0.4.2}/examples/gate/baseline_glm_run2.json +0 -0
  20. {faultgate-0.4.0 → faultgate-0.4.2}/examples/gate/candidate_gpt.json +0 -0
  21. {faultgate-0.4.0 → faultgate-0.4.2}/examples/gate/candidate_qwen.json +0 -0
  22. {faultgate-0.4.0 → faultgate-0.4.2}/examples/gate/golden.json +0 -0
  23. {faultgate-0.4.0 → faultgate-0.4.2}/examples/gate/policy.json +0 -0
  24. {faultgate-0.4.0 → faultgate-0.4.2}/examples/golden.json +0 -0
  25. {faultgate-0.4.0 → faultgate-0.4.2}/examples/policy_retail.json +0 -0
  26. {faultgate-0.4.0 → faultgate-0.4.2}/examples/policy_retail_v031.json +0 -0
  27. {faultgate-0.4.0 → faultgate-0.4.2}/examples/policy_support.json +0 -0
  28. {faultgate-0.4.0 → faultgate-0.4.2}/examples/traces.json +0 -0
  29. {faultgate-0.4.0 → faultgate-0.4.2}/setup.cfg +0 -0
  30. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/band.py +0 -0
  31. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/checks.py +0 -0
  32. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/cli.py +0 -0
  33. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/formats.py +0 -0
  34. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/judge.py +0 -0
  35. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/precheck.py +0 -0
  36. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/report.py +0 -0
  37. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/runner.py +0 -0
  38. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate/trace.py +0 -0
  39. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate.egg-info/dependency_links.txt +0 -0
  40. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate.egg-info/entry_points.txt +0 -0
  41. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate.egg-info/requires.txt +0 -0
  42. {faultgate-0.4.0 → faultgate-0.4.2}/src/faultgate.egg-info/top_level.txt +0 -0
  43. {faultgate-0.4.0 → faultgate-0.4.2}/tests/__init__.py +0 -0
  44. {faultgate-0.4.0 → faultgate-0.4.2}/tests/conftest.py +0 -0
  45. {faultgate-0.4.0 → faultgate-0.4.2}/tests/fixtures/make_retail_gaps.py +0 -0
  46. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_band.py +0 -0
  47. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_checks.py +0 -0
  48. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_cli.py +0 -0
  49. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_formats.py +0 -0
  50. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_golden.py +0 -0
  51. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_judge.py +0 -0
  52. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_policy.py +0 -0
  53. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_redact.py +0 -0
  54. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_report.py +0 -0
  55. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_runner.py +0 -0
  56. {faultgate-0.4.0 → faultgate-0.4.2}/tests/test_trace.py +0 -0
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 — 2026-10-26
4
+
5
+ - Update package metadata and bundled README with the FAULTLINE product page and current source repository. Runtime behavior is unchanged.
6
+
3
7
  ## 0.4.0 — 2026-10-14
4
8
 
5
9
  Closes the six policy gaps an outside client found in 0.3.1 (17 retail probes: 6 caught, 6 missed) and adds an outcome input.
@@ -1,10 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: faultgate
3
- Version: 0.4.0
4
- Summary: Local-first release gate for AI agents: check recorded traces for known failure modes through a pluggable judge.
3
+ Version: 0.4.2
4
+ Summary: FAULTLINE faultgate: open-source release gate for AI agents, trace checks, and regression decisions.
5
5
  Author: Samir Sawarkar
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/Faultline-lab/faultgate
7
+ Project-URL: Homepage, https://www.faultline-labs.com/products/faultgate/
8
+ Project-URL: Repository, https://github.com/Faultline-lab/faultgate
9
+ Project-URL: Issues, https://github.com/Faultline-lab/faultgate/issues
8
10
  Project-URL: Evidence, https://github.com/samirsawarkar/faultline-ai-reliability
9
11
  Keywords: ai,agents,evaluation,reliability,opentelemetry,llm
10
12
  Classifier: Development Status :: 3 - Alpha
@@ -34,6 +36,10 @@ Dynamic: license-file
34
36
 
35
37
  **A local-first release gate for AI agents.** Hand it the traces your agent recorded, it checks every run for known failure modes, measures your run-to-run noise, and exits non-zero only when a candidate is a real regression. Free, offline, no model required for the default checks.
36
38
 
39
+ **Product and examples:** [FAULTLINE faultgate](https://www.faultline-labs.com/products/faultgate/) · **Documentation:** [faultgate docs](https://www.faultline-labs.com/docs/) · **PyPI:** [`faultgate`](https://pypi.org/project/faultgate/). Use faultgate to test recorded agent runs against business rules and measured baseline variation before a CI release.
40
+
41
+ **Why measure a baseline first?** [Regression or noise? A release gate that measures first](https://www.faultline-labs.com/blog/faultgate-regression-or-noise/) explains run-to-run variation and the current PASS, FAIL, and INCONCLUSIVE verdicts.
42
+
37
43
  ```
38
44
  $ faultgate check examples/traces.json
39
45
  trace search_loop abstention policy repeated_call unrecovered_tool_error context_overflow
@@ -2,6 +2,10 @@
2
2
 
3
3
  **A local-first release gate for AI agents.** Hand it the traces your agent recorded, it checks every run for known failure modes, measures your run-to-run noise, and exits non-zero only when a candidate is a real regression. Free, offline, no model required for the default checks.
4
4
 
5
+ **Product and examples:** [FAULTLINE faultgate](https://www.faultline-labs.com/products/faultgate/) · **Documentation:** [faultgate docs](https://www.faultline-labs.com/docs/) · **PyPI:** [`faultgate`](https://pypi.org/project/faultgate/). Use faultgate to test recorded agent runs against business rules and measured baseline variation before a CI release.
6
+
7
+ **Why measure a baseline first?** [Regression or noise? A release gate that measures first](https://www.faultline-labs.com/blog/faultgate-regression-or-noise/) explains run-to-run variation and the current PASS, FAIL, and INCONCLUSIVE verdicts.
8
+
5
9
  ```
6
10
  $ faultgate check examples/traces.json
7
11
  trace search_loop abstention policy repeated_call unrecovered_tool_error context_overflow
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "faultgate"
7
- version = "0.4.0"
8
- description = "Local-first release gate for AI agents: check recorded traces for known failure modes through a pluggable judge."
7
+ version = "0.4.2"
8
+ description = "FAULTLINE faultgate: open-source release gate for AI agents, trace checks, and regression decisions."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
@@ -33,7 +33,9 @@ demo = ["pyarrow", "huggingface_hub"]
33
33
  faultgate = "faultgate.cli:main"
34
34
 
35
35
  [project.urls]
36
- Homepage = "https://github.com/Faultline-lab/faultgate"
36
+ Homepage = "https://www.faultline-labs.com/products/faultgate/"
37
+ Repository = "https://github.com/Faultline-lab/faultgate"
38
+ Issues = "https://github.com/Faultline-lab/faultgate/issues"
37
39
  Evidence = "https://github.com/samirsawarkar/faultline-ai-reliability"
38
40
 
39
41
  [tool.setuptools.packages.find]
@@ -1,2 +1,2 @@
1
1
  """faultgate — local-first release gate for AI agents."""
2
- __version__ = "0.4.0"
2
+ __version__ = "0.4.2"
@@ -31,29 +31,52 @@ def _luhn(digits: str) -> bool:
31
31
  return total % 10 == 0
32
32
 
33
33
 
34
- def _card(m: re.Match[str], tag: str) -> str:
35
- """Mask every Luhn-valid 13-19 digit window of whole digit groups in a run of groups.
36
-
37
- The run can carry more than the card: an SSN or an order number written next to it
38
- ("4111 1111 1111 1111 123-45-6789"). Testing only the whole run let the card through.
39
- """
34
+ def _card_spans(m: re.Match[str]) -> list[tuple[int, int]]:
35
+ """Return every Luhn-valid 13-19 digit window of whole digit groups in m as (start, end)."""
40
36
  text = m.group(0)
37
+ base = m.start()
41
38
  groups = [(g.start(), g.end()) for g in re.finditer(r"\d+", text)]
42
- out, done, i = [], 0, 0
39
+ spans: list[tuple[int, int]] = []
40
+ i = 0
43
41
  while i < len(groups):
44
42
  for j in range(len(groups), i, -1):
45
43
  digits = "".join(text[a:b] for a, b in groups[i:j])
46
44
  if 13 <= len(digits) <= 19 and _luhn(digits):
47
- out += [text[done:groups[i][0]], tag]
48
- done, i = groups[j - 1][1], j
45
+ spans.append((base + groups[i][0], base + groups[j - 1][1]))
46
+ i = j
49
47
  break
50
48
  else:
51
49
  i += 1
50
+ return spans
51
+
52
+
53
+ def _card(m: re.Match[str], tag: str) -> str:
54
+ """Mask every Luhn-valid 13-19 digit window of whole digit groups in a run of groups.
55
+
56
+ The run can carry more than the card: an SSN or an order number written next to it
57
+ ("4111 1111 1111 1111 123-45-6789"). Testing only the whole run let the card through.
58
+ """
59
+ text = m.group(0)
60
+ base = m.start()
61
+ out, done = [], 0
62
+ for start, end in _card_spans(m):
63
+ out += [text[done:start - base], tag]
64
+ done = end - base
52
65
  return "".join(out) + text[done:]
53
66
 
54
67
 
68
+ def _phone_spans(m: re.Match[str]) -> list[tuple[int, int]]:
69
+ return [(m.start(), m.end())] if 9 <= len(re.sub(r"\D", "", m.group(0))) <= 15 else []
70
+
71
+
55
72
  def _phone(m: re.Match[str], tag: str) -> str:
56
- return tag if 9 <= len(re.sub(r"\D", "", m.group(0))) <= 15 else m.group(0)
73
+ return tag if _phone_spans(m) else m.group(0)
74
+
75
+
76
+ _SPANS_FN: dict[Callable[[re.Match[str], str], str], Callable[[re.Match[str]], list[tuple[int, int]]]] = {
77
+ _card: _card_spans,
78
+ _phone: _phone_spans,
79
+ }
57
80
 
58
81
 
59
82
  # Order matters: secrets, SSNs and cards before the looser phone / IP patterns.
@@ -88,6 +111,35 @@ PATTERNS: list[tuple[str, re.Pattern[str], Callable[[re.Match[str], str], str] |
88
111
  ]
89
112
 
90
113
 
114
+ def find(text: str) -> list[tuple[int, int, str]]:
115
+ """Return every private value in text as (start, end, kind), in PATTERNS order with overlaps removed."""
116
+ if not text:
117
+ return []
118
+ taken: list[tuple[int, int, str]] = []
119
+ work = text
120
+ for kind, rx, sub in PATTERNS:
121
+ found: list[tuple[int, int]] = []
122
+ for m in rx.finditer(work):
123
+ if sub is None:
124
+ found.append((m.start(), m.end()))
125
+ elif sub in _SPANS_FN:
126
+ found.extend(_SPANS_FN[sub](m))
127
+ else:
128
+ found.append((m.start(), m.end()))
129
+ found = [(s, e) for s, e in found if e > s]
130
+ if not found:
131
+ continue
132
+ taken.extend((s, e, kind) for s, e in found)
133
+ # Later patterns see earlier matches as masked (same length, so offsets stay valid): an SSN written
134
+ # beside a card must not join the card's digit run, exactly as sequential replacement behaved.
135
+ chars = list(work)
136
+ for s, e in found:
137
+ chars[s:e] = "\x00" * (e - s)
138
+ work = "".join(chars)
139
+ taken.sort(key=lambda s: s[0])
140
+ return taken
141
+
142
+
91
143
  @dataclass(frozen=True)
92
144
  class Redactor:
93
145
  name: str
@@ -98,9 +150,8 @@ class Redactor:
98
150
 
99
151
 
100
152
  def _builtin(text: str) -> str:
101
- for kind, rx, sub in PATTERNS:
102
- tag = f"<{kind}>"
103
- text = rx.sub(lambda m: tag if sub is None else sub(m, tag), text)
153
+ for start, end, kind in reversed(find(text)):
154
+ text = text[:start] + f"<{kind}>" + text[end:]
104
155
  return text
105
156
 
106
157
 
@@ -1,10 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: faultgate
3
- Version: 0.4.0
4
- Summary: Local-first release gate for AI agents: check recorded traces for known failure modes through a pluggable judge.
3
+ Version: 0.4.2
4
+ Summary: FAULTLINE faultgate: open-source release gate for AI agents, trace checks, and regression decisions.
5
5
  Author: Samir Sawarkar
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/Faultline-lab/faultgate
7
+ Project-URL: Homepage, https://www.faultline-labs.com/products/faultgate/
8
+ Project-URL: Repository, https://github.com/Faultline-lab/faultgate
9
+ Project-URL: Issues, https://github.com/Faultline-lab/faultgate/issues
8
10
  Project-URL: Evidence, https://github.com/samirsawarkar/faultline-ai-reliability
9
11
  Keywords: ai,agents,evaluation,reliability,opentelemetry,llm
10
12
  Classifier: Development Status :: 3 - Alpha
@@ -34,6 +36,10 @@ Dynamic: license-file
34
36
 
35
37
  **A local-first release gate for AI agents.** Hand it the traces your agent recorded, it checks every run for known failure modes, measures your run-to-run noise, and exits non-zero only when a candidate is a real regression. Free, offline, no model required for the default checks.
36
38
 
39
+ **Product and examples:** [FAULTLINE faultgate](https://www.faultline-labs.com/products/faultgate/) · **Documentation:** [faultgate docs](https://www.faultline-labs.com/docs/) · **PyPI:** [`faultgate`](https://pypi.org/project/faultgate/). Use faultgate to test recorded agent runs against business rules and measured baseline variation before a CI release.
40
+
41
+ **Why measure a baseline first?** [Regression or noise? A release gate that measures first](https://www.faultline-labs.com/blog/faultgate-regression-or-noise/) explains run-to-run variation and the current PASS, FAIL, and INCONCLUSIVE verdicts.
42
+
37
43
  ```
38
44
  $ faultgate check examples/traces.json
39
45
  trace search_loop abstention policy repeated_call unrecovered_tool_error context_overflow
@@ -43,9 +43,11 @@ tests/test_checks.py
43
43
  tests/test_cli.py
44
44
  tests/test_formats.py
45
45
  tests/test_golden.py
46
+ tests/test_integrations.py
46
47
  tests/test_judge.py
47
48
  tests/test_policy.py
48
49
  tests/test_redact.py
50
+ tests/test_redact_find.py
49
51
  tests/test_report.py
50
52
  tests/test_runner.py
51
53
  tests/test_trace.py
@@ -0,0 +1,34 @@
1
+ """Check every integrations/*/expected.json against faultgate's real output.
2
+
3
+ Reads only the saved trace files. Needs no internet, API key or framework.
4
+ """
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ ROOT = Path(__file__).resolve().parent.parent
13
+ CASES = [
14
+ (path.parent, trace)
15
+ for path in sorted((ROOT / "integrations").glob("*/expected.json"))
16
+ for trace in json.loads(path.read_text())
17
+ ]
18
+
19
+
20
+ @pytest.mark.parametrize(
21
+ "recipe,trace", CASES, ids=[f"{r.name}/{t}" for r, t in CASES]
22
+ )
23
+ def test_recipe_trace(recipe, trace, tmp_path):
24
+ expected = json.loads((recipe / "expected.json").read_text())[trace]
25
+ report = tmp_path / "report.json"
26
+ run = subprocess.run(
27
+ [shutil.which("faultgate"), "check", str(recipe / "traces" / trace),
28
+ "--policy", str(recipe / "policy.json"), "--json", str(report)],
29
+ capture_output=True, text=True,
30
+ )
31
+ assert run.returncode == expected["exit_code"], run.stdout + run.stderr
32
+ data = json.loads(report.read_text())
33
+ fired = sorted(name for name, count in data["fired"].items() if count)
34
+ assert fired == sorted(expected["fired"])
@@ -0,0 +1,129 @@
1
+ import pytest
2
+
3
+ from faultgate.redact import builtin, find
4
+
5
+ R = builtin()
6
+
7
+
8
+ @pytest.mark.parametrize("text, expected_kind, expected_val", [
9
+ ("jwt token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U in header",
10
+ "SECRET", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"),
11
+ ("Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345",
12
+ "SECRET", "abcdefghijklmnopqrstuvwxyz012345"),
13
+ ("api key sk-proj-abcdefghijklmnopqrstuvwx",
14
+ "SECRET", "sk-proj-abcdefghijklmnopqrstuvwx"),
15
+ ("aws key AKIAIOSFODNN7EXAMPLE",
16
+ "SECRET", "AKIAIOSFODNN7EXAMPLE"),
17
+ ("contact user at jane.doe+x@corp.example.com today",
18
+ "EMAIL", "jane.doe+x@corp.example.com"),
19
+ ("ssn is 123-45-6789 here",
20
+ "US_SSN", "123-45-6789"),
21
+ ("card 4111 1111 1111 1111 on file",
22
+ "CREDIT_CARD", "4111 1111 1111 1111"),
23
+ ("card 4111-1111-1111-1111",
24
+ "CREDIT_CARD", "4111-1111-1111-1111"),
25
+ ("amex 3782 822463 10005 charged",
26
+ "CREDIT_CARD", "3782 822463 10005"),
27
+ ("connect to 192.168.10.24 now",
28
+ "IP_ADDRESS", "192.168.10.24"),
29
+ ("connect to 2001:0db8:85a3:0000:0000:8a2e:0370:7334 now",
30
+ "IP_ADDRESS", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
31
+ ("call +1 (415) 555-0132 today",
32
+ "PHONE", "+1 (415) 555-0132"),
33
+ ("call 020 7946 0958",
34
+ "PHONE", "020 7946 0958"),
35
+ ])
36
+ def test_find_each_kind(text, expected_kind, expected_val):
37
+ spans = find(text)
38
+ assert len(spans) == 1
39
+ start, end, kind = spans[0]
40
+ assert kind == expected_kind
41
+ assert text[start:end] == expected_val
42
+
43
+
44
+ def test_find_luhn_invalid_card_not_returned():
45
+ assert find("card 4111 1111 1111 1112") == []
46
+ assert find("card 4111 1111 1111 1112 rejected by gateway") == []
47
+
48
+
49
+ def test_find_card_plus_ssn_in_one_run():
50
+ text = "4111 1111 1111 1111 123-45-6789"
51
+ spans = find(text)
52
+ assert len(spans) == 2
53
+ assert spans[0] == (0, 19, "CREDIT_CARD")
54
+ assert spans[1] == (20, 31, "US_SSN")
55
+ assert text[spans[0][0]:spans[0][1]] == "4111 1111 1111 1111"
56
+ assert text[spans[1][0]:spans[1][1]] == "123-45-6789"
57
+
58
+ # Non-card digits in a run are not included in the card span
59
+ text_extra = "4111 1111 1111 1111 1234"
60
+ spans_extra = find(text_extra)
61
+ assert spans_extra == [(0, 19, "CREDIT_CARD")]
62
+ assert text_extra[spans_extra[0][0]:spans_extra[0][1]] == "4111 1111 1111 1111"
63
+
64
+ # Multiple Luhn-valid cards in one digit run
65
+ text_multi = "4111 1111 1111 1111 5555 4444 3333 1111"
66
+ spans_multi = find(text_multi)
67
+ assert len(spans_multi) == 2
68
+ assert spans_multi[0] == (0, 19, "CREDIT_CARD")
69
+ assert spans_multi[1] == (20, 39, "CREDIT_CARD")
70
+ assert text_multi[spans_multi[0][0]:spans_multi[0][1]] == "4111 1111 1111 1111"
71
+ assert text_multi[spans_multi[1][0]:spans_multi[1][1]] == "5555 4444 3333 1111"
72
+
73
+
74
+ def test_find_overlap_removed():
75
+ # Bearer secret containing an SSN pattern: Bearer (SECRET) precedes US_SSN in PATTERNS
76
+ text = "Bearer 123-45-6789-abcdef0123456789"
77
+ spans = find(text)
78
+ assert len(spans) == 1
79
+ start, end, kind = spans[0]
80
+ assert kind == "SECRET"
81
+ assert text[start:end] == "123-45-6789-abcdef0123456789"
82
+
83
+
84
+ def test_find_positions_slice_back_to_value():
85
+ text = (
86
+ "Refund for jane@corp.example.com of card 4111 1111 1111 1111 "
87
+ "verified with ssn 123-45-6789 on server 192.168.1.50 now. "
88
+ "Call +1 (415) 555-0132 or use token sk-proj-abcdefghijklmnopqrstuvwx."
89
+ )
90
+ spans = find(text)
91
+ assert len(spans) == 6
92
+
93
+ # Verify sorted strictly by start and non-overlapping
94
+ for i in range(len(spans) - 1):
95
+ assert spans[i][0] < spans[i + 1][0]
96
+ assert spans[i][1] <= spans[i + 1][0]
97
+
98
+ # Verify each position slices back to the exact value
99
+ expected_values = [
100
+ "jane@corp.example.com",
101
+ "4111 1111 1111 1111",
102
+ "123-45-6789",
103
+ "192.168.1.50",
104
+ "+1 (415) 555-0132",
105
+ "sk-proj-abcdefghijklmnopqrstuvwx",
106
+ ]
107
+ for (start, end, _), expected in zip(spans, expected_values):
108
+ assert text[start:end] == expected
109
+
110
+
111
+ def test_find_empty_string():
112
+ assert find("") == []
113
+
114
+
115
+ def test_find_phone_digit_count_filter():
116
+ # 8 digits: not kept
117
+ assert find("call 123-45678") == []
118
+ # 9 digits: kept
119
+ assert len(find("call 123-456-789")) == 1
120
+ # 11 digits: kept
121
+ assert len(find("call +1 (415) 555-0132")) == 1
122
+
123
+
124
+ def test_builtin_replaces_spans_right_to_left():
125
+ text = "4111 1111 1111 1111 123-45-6789"
126
+ assert R(text) == "<CREDIT_CARD> <US_SSN>"
127
+
128
+ text2 = "call +1 (415) 555-0132 and mail test@example.com"
129
+ assert R(text2) == "call <PHONE> and mail <EMAIL>"
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