grudge-cli 0.1.0__py3-none-any.whl

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 (89) hide show
  1. examples/__init__.py +1 -0
  2. examples/leaky_agent/__init__.py +1 -0
  3. examples/leaky_agent/agent.py +250 -0
  4. examples/leaky_agent/agent_fixed.py +183 -0
  5. examples/leaky_agent/tools.py +153 -0
  6. grudge/__init__.py +5 -0
  7. grudge/__main__.py +6 -0
  8. grudge/_stubs.py +8 -0
  9. grudge/admission/__init__.py +6 -0
  10. grudge/admission/court.py +239 -0
  11. grudge/admission/verdict.py +26 -0
  12. grudge/cli/__init__.py +1 -0
  13. grudge/cli/_common.py +126 -0
  14. grudge/cli/admit.py +93 -0
  15. grudge/cli/capture.py +39 -0
  16. grudge/cli/compile.py +37 -0
  17. grudge/cli/demo.py +136 -0
  18. grudge/cli/main.py +54 -0
  19. grudge/cli/run.py +60 -0
  20. grudge/cli/scars.py +91 -0
  21. grudge/cli/shrink.py +108 -0
  22. grudge/config.py +35 -0
  23. grudge/demo/__init__.py +1 -0
  24. grudge/demo/events.py +228 -0
  25. grudge/demo/orchestrator.py +326 -0
  26. grudge/demo/server.py +222 -0
  27. grudge/errors.py +25 -0
  28. grudge/fuzzer/__init__.py +1 -0
  29. grudge/fuzzer/fingerprint_guard.py +10 -0
  30. grudge/fuzzer/mutators.py +10 -0
  31. grudge/gate/__init__.py +5 -0
  32. grudge/gate/report.py +125 -0
  33. grudge/gate/runner.py +323 -0
  34. grudge/gate/selector.py +10 -0
  35. grudge/gate/wilson.py +36 -0
  36. grudge/guard/__init__.py +5 -0
  37. grudge/guard/compile.py +251 -0
  38. grudge/guard/import_session.py +106 -0
  39. grudge/integrations/__init__.py +1 -0
  40. grudge/integrations/github_comment.py +10 -0
  41. grudge/integrations/pytest_plugin.py +10 -0
  42. grudge/llm/__init__.py +6 -0
  43. grudge/llm/cache.py +92 -0
  44. grudge/llm/client.py +82 -0
  45. grudge/locator/__init__.py +1 -0
  46. grudge/locator/fork.py +10 -0
  47. grudge/locator/point_of_no_return.py +10 -0
  48. grudge/predicates/__init__.py +25 -0
  49. grudge/predicates/call_shape.py +116 -0
  50. grudge/predicates/compiler.py +116 -0
  51. grudge/predicates/dsl.py +295 -0
  52. grudge/predicates/fallback_rules.py +10 -0
  53. grudge/predicates/ordering.py +33 -0
  54. grudge/predicates/outcome.py +14 -0
  55. grudge/predicates/prompts/synthesize.md +15 -0
  56. grudge/predicates/synthesizer.py +127 -0
  57. grudge/predicates/taint_flow.py +84 -0
  58. grudge/recorder/__init__.py +5 -0
  59. grudge/recorder/adapters/__init__.py +1 -0
  60. grudge/recorder/adapters/langgraph.py +8 -0
  61. grudge/recorder/adapters/mcp_proxy.py +8 -0
  62. grudge/recorder/adapters/raw_sdk.py +159 -0
  63. grudge/recorder/recorder.py +374 -0
  64. grudge/recorder/redact.py +105 -0
  65. grudge/registry/__init__.py +1 -0
  66. grudge/registry/db.py +10 -0
  67. grudge/registry/fingerprint.py +10 -0
  68. grudge/registry/flakiness.py +10 -0
  69. grudge/registry/retirement.py +10 -0
  70. grudge/replay/__init__.py +19 -0
  71. grudge/replay/cassette_player.py +159 -0
  72. grudge/replay/sandbox.py +340 -0
  73. grudge/replay/scripted_llm.py +382 -0
  74. grudge/schema/__init__.py +45 -0
  75. grudge/schema/cassette.py +206 -0
  76. grudge/schema/predicate.py +263 -0
  77. grudge/schema/scar.py +113 -0
  78. grudge/schema/trace.py +243 -0
  79. grudge/shrinker/__init__.py +12 -0
  80. grudge/shrinker/cassette_builder.py +328 -0
  81. grudge/shrinker/ddmin.py +243 -0
  82. grudge/shrinker/oracle.py +230 -0
  83. grudge/shrinker/reducers.py +331 -0
  84. grudge_cli-0.1.0.dist-info/LICENSE +21 -0
  85. grudge_cli-0.1.0.dist-info/METADATA +313 -0
  86. grudge_cli-0.1.0.dist-info/RECORD +89 -0
  87. grudge_cli-0.1.0.dist-info/WHEEL +5 -0
  88. grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  89. grudge_cli-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,230 @@
1
+ """Deterministic failure-reproduction oracles for cassette shrinking.
2
+
3
+ The oracle is the seam between the pure shrink search and Phase 4 replay. A
4
+ candidate cassette is evaluated by running the live policy in the hermetic
5
+ sandbox and asking a small, deterministic seed predicate whether the failure
6
+ is still present.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ from collections.abc import Callable, Mapping, Sequence
13
+ from dataclasses import dataclass
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+ from grudge.errors import OracleError
17
+ from grudge.replay.sandbox import run_agent
18
+ from grudge.schema.cassette import Cassette
19
+ from grudge.schema.predicate import (
20
+ CallShape,
21
+ Outcome,
22
+ PredicateSet,
23
+ TaintFlow,
24
+ )
25
+ from grudge.schema.trace import ToolResult, Trace
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class OracleResult:
30
+ """The result of one candidate evaluation."""
31
+
32
+ reproduced: bool
33
+ trace: Trace | None
34
+ reason: str
35
+
36
+
37
+ @runtime_checkable
38
+ class Oracle(Protocol):
39
+ """Protocol implemented by objects that can test a cassette candidate."""
40
+
41
+ calls: int
42
+ cache_hits: int
43
+
44
+ def reproduces(self, cassette: Cassette) -> OracleResult:
45
+ """Return whether ``cassette`` still reproduces the seed failure."""
46
+
47
+
48
+ SeedPredicate = Callable[[Trace], bool] | CallShape | TaintFlow | Outcome | PredicateSet
49
+
50
+
51
+ def _value_contains(value: Any, needle: Any) -> bool:
52
+ """Return whether ``needle`` occurs in a JSON-like value."""
53
+
54
+ if needle is None:
55
+ return False
56
+ if isinstance(value, Mapping):
57
+ return any(_value_contains(item, needle) for item in value.values())
58
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
59
+ return any(_value_contains(item, needle) for item in value)
60
+ if isinstance(needle, str):
61
+ return needle in str(value)
62
+ return bool(value == needle)
63
+
64
+
65
+ def _field(value: Any, name: str | None) -> Any:
66
+ """Read an optional field from a mapping, leaving scalar results intact."""
67
+
68
+ if name is None:
69
+ return value
70
+ if isinstance(value, Mapping):
71
+ return value.get(name)
72
+ return None
73
+
74
+
75
+ def _schema_predicate(predicate: Any, trace: Trace) -> bool:
76
+ """Evaluate the Phase 1 predicate shapes used by the seed detectors."""
77
+
78
+ if isinstance(predicate, CallShape):
79
+ return any(
80
+ step.tool == predicate.tool
81
+ and all(
82
+ step.args.get(key) == value for key, value in predicate.args.items()
83
+ )
84
+ for step in trace.tool_calls()
85
+ )
86
+ if isinstance(predicate, TaintFlow):
87
+ calls = trace.tool_calls()
88
+ results = {
89
+ step.call_id: step for step in trace.steps if isinstance(step, ToolResult)
90
+ }
91
+ for source_index, source_call in enumerate(calls):
92
+ if source_call.tool != predicate.source:
93
+ continue
94
+ source_result = results.get(source_call.call_id)
95
+ if source_result is None:
96
+ continue
97
+ source_value = _field(source_result.content, predicate.source_field)
98
+ for sink_call in calls[source_index + 1 :]:
99
+ if sink_call.tool != predicate.sink:
100
+ continue
101
+ sink_value = _field(sink_call.args, predicate.sink_field)
102
+ if _value_contains(sink_value, source_value):
103
+ return True
104
+ return False
105
+ if isinstance(predicate, Outcome):
106
+ return trace.outcome == predicate.outcome
107
+ if isinstance(predicate, PredicateSet):
108
+ values = [_schema_predicate(item, trace) for item in predicate.predicates]
109
+ if predicate.kind == "all_of":
110
+ return all(values)
111
+ if predicate.kind == "any_of":
112
+ return any(values)
113
+ return not values[0]
114
+ raise TypeError(f"unsupported seed predicate: {type(predicate).__name__}")
115
+
116
+
117
+ def _evaluate(predicate: Any, trace: Trace, cassette: Cassette) -> bool:
118
+ """Evaluate a callable, schema predicate, or small checker-like object."""
119
+
120
+ if isinstance(predicate, CallShape | TaintFlow | Outcome | PredicateSet):
121
+ return _schema_predicate(predicate, trace)
122
+ checker = getattr(predicate, "check", None)
123
+ if callable(checker):
124
+ result = checker(trace)
125
+ return bool(getattr(result, "violated", result))
126
+ evaluator = getattr(predicate, "evaluate", None)
127
+ if callable(evaluator):
128
+ result = evaluator(trace)
129
+ return bool(getattr(result, "violated", result))
130
+ if not callable(predicate):
131
+ raise TypeError(f"unsupported seed predicate: {type(predicate).__name__}")
132
+ try:
133
+ parameters = dict(inspect.signature(predicate).parameters)
134
+ except (TypeError, ValueError):
135
+ parameters = {}
136
+ positional = [
137
+ parameter
138
+ for parameter in parameters.values()
139
+ if parameter.kind
140
+ in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
141
+ ]
142
+ if len(positional) >= 2:
143
+ return bool(predicate(trace, cassette))
144
+ return bool(predicate(trace))
145
+
146
+
147
+ class PredicateOracle:
148
+ """Run an agent in the replay sandbox and memoize predicate outcomes."""
149
+
150
+ def __init__(
151
+ self,
152
+ agent_fn: Callable[..., Any],
153
+ seed_predicate: SeedPredicate | Any,
154
+ *,
155
+ llm: Any = None,
156
+ repeats: int = 1,
157
+ max_oracle_calls: int | None = 1000,
158
+ timeout: float = 2.0,
159
+ max_steps: int = 128,
160
+ ) -> None:
161
+ if repeats < 1:
162
+ raise ValueError("repeats must be at least 1")
163
+ if max_oracle_calls is not None and max_oracle_calls < 1:
164
+ raise ValueError("max_oracle_calls must be at least 1")
165
+ if timeout <= 0 or max_steps < 0:
166
+ raise ValueError("timeout must be positive and max_steps non-negative")
167
+ self.agent_fn = agent_fn
168
+ self.seed_predicate = seed_predicate
169
+ self.llm = llm
170
+ self.repeats = repeats
171
+ self.max_oracle_calls = max_oracle_calls
172
+ self.timeout = timeout
173
+ self.max_steps = max_steps
174
+ self.calls = 0
175
+ self.cache_hits = 0
176
+ self._cache: dict[str, OracleResult] = {}
177
+
178
+ def reproduces(self, cassette: Cassette) -> OracleResult:
179
+ """Evaluate ``cassette`` once, or return its memoized result."""
180
+
181
+ cache_key = cassette.content_hash
182
+ cached = self._cache.get(cache_key)
183
+ if cached is not None:
184
+ self.cache_hits += 1
185
+ return cached
186
+ if self.max_oracle_calls is not None and self.calls >= self.max_oracle_calls:
187
+ raise OracleError(
188
+ "oracle call budget exhausted: "
189
+ f"{self.calls} calls used, max_oracle_calls="
190
+ f"{self.max_oracle_calls}"
191
+ )
192
+
193
+ self.calls += 1
194
+ traces: list[Trace] = []
195
+ failures: list[str] = []
196
+ reproduced_runs = 0
197
+ for _ in range(self.repeats):
198
+ trace = run_agent(
199
+ self.agent_fn,
200
+ cassette,
201
+ self.llm,
202
+ timeout=self.timeout,
203
+ max_steps=self.max_steps,
204
+ )
205
+ traces.append(trace)
206
+ try:
207
+ reproduced = _evaluate(self.seed_predicate, trace, cassette)
208
+ except Exception as exc:
209
+ reproduced = False
210
+ failures.append(f"predicate error: {type(exc).__name__}: {exc}")
211
+ if reproduced:
212
+ reproduced_runs += 1
213
+ if not reproduced:
214
+ continue
215
+
216
+ if failures:
217
+ reason = failures[0]
218
+ else:
219
+ run_summary = f"{reproduced_runs}/{self.repeats}"
220
+ reason = f"seed predicate reproduced in {run_summary} runs"
221
+ result = OracleResult(
222
+ reproduced_runs == self.repeats,
223
+ traces[0] if traces else None,
224
+ reason,
225
+ )
226
+ self._cache[cache_key] = result
227
+ return result
228
+
229
+
230
+ __all__ = ["Oracle", "OracleResult", "PredicateOracle", "SeedPredicate"]
@@ -0,0 +1,331 @@
1
+ """Pure content reducers used after cassette-entry delta debugging.
2
+
3
+ Each reducer accepts a JSON-compatible value (including a validated Pydantic
4
+ model), makes copies before trying candidates, and keeps a candidate only when
5
+ the supplied reproduction test still passes. That makes the reducers safe to
6
+ compose and keeps cassette validation at the model boundary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import re
13
+ from collections.abc import Callable, Iterable, Mapping, Sequence
14
+ from typing import Any, Protocol
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from grudge.shrinker.ddmin import minimize
19
+
20
+ PathPart = str | int
21
+ Path = tuple[PathPart, ...]
22
+ ContentTest = Callable[[Any], bool]
23
+
24
+
25
+ class ContentReducer(Protocol):
26
+ """The small common interface implemented by all content reducers."""
27
+
28
+ def reduce(self, value: Any, test: ContentTest) -> Any:
29
+ """Return the smallest accepted value this reducer can find."""
30
+
31
+
32
+ def _passes(test: ContentTest, value: Any) -> bool:
33
+ """Treat structural lookup failures as a rejected candidate."""
34
+
35
+ try:
36
+ return bool(test(value))
37
+ except (AttributeError, IndexError, KeyError, TypeError):
38
+ return False
39
+
40
+
41
+ def _children(value: Any) -> Iterable[tuple[PathPart, Any]]:
42
+ if isinstance(value, BaseModel):
43
+ for name in type(value).model_fields:
44
+ yield name, getattr(value, name)
45
+ elif isinstance(value, Mapping):
46
+ for key, child in value.items():
47
+ if isinstance(key, str | int):
48
+ yield key, child
49
+ elif isinstance(value, list):
50
+ yield from enumerate(value)
51
+
52
+
53
+ def _paths(value: Any, prefix: Path = ()) -> Iterable[Path]:
54
+ for part, child in _children(value):
55
+ path = (*prefix, part)
56
+ yield path
57
+ yield from _paths(child, path)
58
+
59
+
60
+ def _get_path(value: Any, path: Path) -> Any:
61
+ current = value
62
+ for part in path:
63
+ if isinstance(current, BaseModel):
64
+ current = getattr(current, str(part))
65
+ else:
66
+ current = current[part]
67
+ return current
68
+
69
+
70
+ def _set_path(value: Any, path: Path, replacement: Any) -> None:
71
+ if not path:
72
+ raise ValueError("a reducer path must not be empty")
73
+ parent = _get_path(value, path[:-1])
74
+ part = path[-1]
75
+ if isinstance(parent, BaseModel):
76
+ setattr(parent, str(part), replacement)
77
+ else:
78
+ parent[part] = replacement
79
+
80
+
81
+ def _candidate(value: Any, path: Path, replacement: Any) -> Any | None:
82
+ try:
83
+ result = copy.deepcopy(value)
84
+ _set_path(result, path, replacement)
85
+ except (AttributeError, KeyError, IndexError, TypeError, ValueError):
86
+ # Invalid assignments are expected for constrained Cassette fields
87
+ # (for example, replacing a required tool name with "").
88
+ return None
89
+ return result
90
+
91
+
92
+ def _sentences(text: str) -> list[str]:
93
+ """Split prose without dropping its terminal punctuation."""
94
+
95
+ parts = re.split(r"(?<=[.!?])\s+", text)
96
+ return [part for part in parts if part]
97
+
98
+
99
+ def _join_chunks(chunks: Sequence[str], *, lines: bool) -> str:
100
+ return ("" if lines else " ").join(chunks)
101
+
102
+
103
+ def _tests_text(value: Any, path: Path, text: str, test: ContentTest) -> bool:
104
+ candidate = _candidate(value, path, text)
105
+ return candidate is not None and _passes(test, candidate)
106
+
107
+
108
+ class TrimDocument:
109
+ """Remove irrelevant lines and sentences from text fields via ddmin."""
110
+
111
+ def reduce(self, value: Any, test: ContentTest) -> Any:
112
+ """Try every text field in stable traversal order."""
113
+
114
+ result = copy.deepcopy(value)
115
+ for path in list(_paths(result)):
116
+ try:
117
+ text = _get_path(result, path)
118
+ except (AttributeError, KeyError, IndexError, TypeError):
119
+ continue
120
+ if not isinstance(text, str) or len(text) < 2:
121
+ continue
122
+
123
+ lines = text.splitlines(keepends=True)
124
+ chunks = lines if len(lines) > 1 else _sentences(text)
125
+ if len(chunks) > 1:
126
+
127
+ def accepts_lines(
128
+ candidate: list[str],
129
+ base: Any = result,
130
+ field_path: Path = path,
131
+ line_mode: bool = len(lines) > 1,
132
+ ) -> bool:
133
+ return _tests_text(
134
+ base,
135
+ field_path,
136
+ _join_chunks(candidate, lines=line_mode),
137
+ test,
138
+ )
139
+
140
+ reduced_chunks, _ = minimize(
141
+ chunks,
142
+ accepts_lines,
143
+ )
144
+ replacement = _join_chunks(reduced_chunks, lines=len(lines) > 1)
145
+ candidate = _candidate(result, path, replacement)
146
+ if candidate is not None and _passes(test, candidate):
147
+ result = candidate
148
+
149
+ # Once line noise is removed, sentence-level reduction can still
150
+ # remove irrelevant prose within the surviving line(s).
151
+ try:
152
+ current_text = _get_path(result, path)
153
+ except (AttributeError, KeyError, IndexError, TypeError):
154
+ continue
155
+ if not isinstance(current_text, str):
156
+ continue
157
+ sentences = _sentences(current_text)
158
+ if len(sentences) <= 1:
159
+ continue
160
+
161
+ def accepts_sentences(
162
+ candidate: list[str],
163
+ base: Any = result,
164
+ field_path: Path = path,
165
+ ) -> bool:
166
+ return _tests_text(
167
+ base,
168
+ field_path,
169
+ _join_chunks(candidate, lines=False),
170
+ test,
171
+ )
172
+
173
+ reduced_sentences, _ = minimize(
174
+ sentences,
175
+ accepts_sentences,
176
+ )
177
+ replacement = _join_chunks(reduced_sentences, lines=False)
178
+ candidate = _candidate(result, path, replacement)
179
+ if candidate is not None and _passes(test, candidate):
180
+ result = candidate
181
+ return result
182
+
183
+ apply = reduce
184
+
185
+ def __call__(self, value: Any, test: ContentTest) -> Any:
186
+ return self.reduce(value, test)
187
+
188
+
189
+ def _neutral(value: Any) -> Any:
190
+ if isinstance(value, str):
191
+ return ""
192
+ if isinstance(value, bool):
193
+ return False
194
+ if isinstance(value, int | float):
195
+ return 0
196
+ if isinstance(value, list):
197
+ return []
198
+ if isinstance(value, dict):
199
+ return {}
200
+ return None
201
+
202
+
203
+ class BlankField:
204
+ """Blank fields one at a time whenever reproduction remains sound."""
205
+
206
+ def __init__(self, fields: Sequence[str] | None = None) -> None:
207
+ self.fields = frozenset(fields) if fields is not None else None
208
+
209
+ def reduce(self, value: Any, test: ContentTest) -> Any:
210
+ result = copy.deepcopy(value)
211
+ for path in list(_paths(result)):
212
+ if self.fields is not None and str(path[-1]) not in self.fields:
213
+ continue
214
+ try:
215
+ old = _get_path(result, path)
216
+ except (AttributeError, KeyError, IndexError, TypeError):
217
+ continue
218
+ replacement = _neutral(old)
219
+ if replacement == old:
220
+ continue
221
+ candidate = _candidate(result, path, replacement)
222
+ if candidate is not None and _passes(test, candidate):
223
+ result = candidate
224
+ return result
225
+
226
+ apply = reduce
227
+
228
+ def __call__(self, value: Any, test: ContentTest) -> Any:
229
+ return self.reduce(value, test)
230
+
231
+
232
+ _ID_TOKEN = re.compile(
233
+ r"\b(?P<prefix>email|user|account|order|ticket|message|document|file)"
234
+ r"[-_][A-Za-z0-9]+\b",
235
+ re.IGNORECASE,
236
+ )
237
+
238
+
239
+ def _id_tokens(value: Any) -> list[str]:
240
+ tokens: list[str] = []
241
+ for path in _paths(value):
242
+ try:
243
+ child = _get_path(value, path)
244
+ except (AttributeError, KeyError, IndexError, TypeError):
245
+ continue
246
+ if not isinstance(child, str):
247
+ continue
248
+ for match in _ID_TOKEN.finditer(child):
249
+ token = match.group(0)
250
+ if token not in tokens:
251
+ tokens.append(token)
252
+ return tokens
253
+
254
+
255
+ def _replace_ids(value: Any, replacements: Mapping[str, str]) -> Any | None:
256
+ result = copy.deepcopy(value)
257
+ for path in list(_paths(result)):
258
+ try:
259
+ child = _get_path(result, path)
260
+ except (AttributeError, KeyError, IndexError, TypeError):
261
+ continue
262
+ if not isinstance(child, str):
263
+ continue
264
+ replacement = child
265
+ for old, new in replacements.items():
266
+ replacement = re.sub(
267
+ rf"(?<![A-Za-z0-9_-]){re.escape(old)}(?![A-Za-z0-9_-])",
268
+ new,
269
+ replacement,
270
+ )
271
+ if replacement != child:
272
+ try:
273
+ _set_path(result, path, replacement)
274
+ except (AttributeError, KeyError, IndexError, TypeError, ValueError):
275
+ return None
276
+ return result
277
+
278
+
279
+ class NormalizeIds:
280
+ """Canonicalize stable entity IDs only when the reproduction test passes."""
281
+
282
+ def reduce(self, value: Any, test: ContentTest) -> Any:
283
+ tokens = _id_tokens(value)
284
+ if not tokens:
285
+ return copy.deepcopy(value)
286
+
287
+ counters: dict[str, int] = {}
288
+ replacements: dict[str, str] = {}
289
+ for token in tokens:
290
+ prefix = token.split("-", 1)[0].split("_", 1)[0].lower()
291
+ counters[prefix] = counters.get(prefix, 0) + 1
292
+ replacements[token] = f"{prefix}_{counters[prefix]}"
293
+
294
+ all_ids = _replace_ids(value, replacements)
295
+ if all_ids is not None and _passes(test, all_ids):
296
+ return all_ids
297
+
298
+ result = copy.deepcopy(value)
299
+ for token, replacement in replacements.items():
300
+ candidate = _replace_ids(result, {token: replacement})
301
+ if candidate is not None and _passes(test, candidate):
302
+ result = candidate
303
+ return result
304
+
305
+ apply = reduce
306
+
307
+ def __call__(self, value: Any, test: ContentTest) -> Any:
308
+ return self.reduce(value, test)
309
+
310
+
311
+ def reduce_content(
312
+ value: Any,
313
+ test: ContentTest,
314
+ *,
315
+ reducers: Sequence[ContentReducer] | None = None,
316
+ ) -> Any:
317
+ """Apply the content-reducer pipeline in deterministic order."""
318
+
319
+ current = value
320
+ selected = reducers or (TrimDocument(), BlankField(), NormalizeIds())
321
+ for reducer in selected:
322
+ current = reducer.reduce(current, test)
323
+ return current
324
+
325
+
326
+ __all__ = [
327
+ "BlankField",
328
+ "NormalizeIds",
329
+ "TrimDocument",
330
+ "reduce_content",
331
+ ]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Grudge contributors
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.