agentforge-framework 0.2.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. agentforge_framework/.claude-plugin/plugin.json +4 -0
  2. agentforge_framework/__init__.py +3 -0
  3. agentforge_framework/agents/__init__.py +92 -0
  4. agentforge_framework/agents/architect.py +146 -0
  5. agentforge_framework/agents/implementer.py +162 -0
  6. agentforge_framework/agents/orchestrator.py +588 -0
  7. agentforge_framework/agents/reviewer.py +335 -0
  8. agentforge_framework/agents/security.py +138 -0
  9. agentforge_framework/agents/tester.py +125 -0
  10. agentforge_framework/cli.py +461 -0
  11. agentforge_framework/context/__init__.py +1 -0
  12. agentforge_framework/context/extractors/__init__.py +76 -0
  13. agentforge_framework/context/extractors/base.py +47 -0
  14. agentforge_framework/context/extractors/python.py +65 -0
  15. agentforge_framework/context/extractors/sql.py +121 -0
  16. agentforge_framework/context/extractors/yaml.py +59 -0
  17. agentforge_framework/context/prompt.py +104 -0
  18. agentforge_framework/context/resolver.py +185 -0
  19. agentforge_framework/core/__init__.py +1 -0
  20. agentforge_framework/core/commands.py +170 -0
  21. agentforge_framework/core/config.py +90 -0
  22. agentforge_framework/core/contracts.py +875 -0
  23. agentforge_framework/core/gates.py +333 -0
  24. agentforge_framework/core/issues.py +697 -0
  25. agentforge_framework/core/plan_format.py +272 -0
  26. agentforge_framework/core/process.py +141 -0
  27. agentforge_framework/core/project.py +262 -0
  28. agentforge_framework/core/registry.py +455 -0
  29. agentforge_framework/core/repo.py +185 -0
  30. agentforge_framework/core/router.py +1 -0
  31. agentforge_framework/core/runtime.py +639 -0
  32. agentforge_framework/core/skills.py +255 -0
  33. agentforge_framework/core/workflow.py +215 -0
  34. agentforge_framework/plugins/__init__.py +35 -0
  35. agentforge_framework/plugins/databricks/__init__.py +86 -0
  36. agentforge_framework/plugins/pyspark/__init__.py +57 -0
  37. agentforge_framework/plugins/python/__init__.py +45 -0
  38. agentforge_framework/plugins/sql/__init__.py +377 -0
  39. agentforge_framework/providers/__init__.py +48 -0
  40. agentforge_framework/providers/base.py +248 -0
  41. agentforge_framework/providers/claude.py +159 -0
  42. agentforge_framework/providers/codex.py +139 -0
  43. agentforge_framework/skills/MANIFEST.yaml +157 -0
  44. agentforge_framework/skills/NOTICE +49 -0
  45. agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
  46. agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  47. agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
  48. agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
  49. agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
  50. agentforge_framework/skills/grilling/SKILL.md +28 -0
  51. agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
  52. agentforge_framework/skills/to-spec/SKILL.md +75 -0
  53. agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
  54. agentforge_framework/skills/to-tickets/SKILL.md +105 -0
  55. agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
  56. agentforge_framework/skills/unslop/SKILL.md +131 -0
  57. agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
  58. agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
  59. agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
  60. agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
  61. agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
  62. agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
  63. agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
  64. agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
  65. agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
  66. agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
  67. agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
  68. agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
  69. agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
  70. agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
  71. agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
  72. agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
  73. agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
  74. agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
  75. agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
  76. agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
  77. agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
  78. agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
  79. agentforge_framework/skills/write-plainly/SKILL.md +94 -0
  80. agentforge_framework/workflows/bugfix.yaml +8 -0
  81. agentforge_framework/workflows/feature.yaml +16 -0
  82. agentforge_framework/workflows/review.yaml +10 -0
  83. agentforge_framework-0.2.0.dist-info/METADATA +321 -0
  84. agentforge_framework-0.2.0.dist-info/RECORD +89 -0
  85. agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
  86. agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
  87. agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
  88. agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
  89. agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env python3
2
+ """Offline contribution scaffolder for new unslop AI-isms."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ from datetime import date, datetime
13
+ from pathlib import Path
14
+
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ CONTRIB_ROOT = ROOT / ".unslop" / "contrib"
17
+ TODO_MARKER = "TODO:"
18
+ SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
19
+
20
+ GATE_COMMANDS = [
21
+ ["python3", "evals/check.py", "--full"],
22
+ ]
23
+
24
+
25
+ def read_text(path: Path) -> str:
26
+ try:
27
+ return path.read_text(encoding="utf-8")
28
+ except OSError as exc:
29
+ print(json.dumps({"error": f"could not read {path}: {exc}"}))
30
+ raise SystemExit(2) from exc
31
+
32
+
33
+ def write_json(path: Path, data: object) -> None:
34
+ path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
35
+
36
+
37
+ def sha256_text(text: str) -> str:
38
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
39
+
40
+
41
+ def word_count(text: str) -> int:
42
+ return len(re.findall(r"[A-Za-z0-9']+", text))
43
+
44
+
45
+ def shorten(text: str, limit: int = 72) -> str:
46
+ one_line = " ".join(text.split())
47
+ if len(one_line) <= limit:
48
+ return one_line
49
+ return one_line[: limit - 1].rstrip() + "..."
50
+
51
+
52
+ def run_scanner(command: list[str], text: str) -> subprocess.CompletedProcess[str]:
53
+ return subprocess.run(command, input=text, capture_output=True, text=True, cwd=ROOT, timeout=30)
54
+
55
+
56
+ def scanner_findings(text: str) -> list[dict[str, object]]:
57
+ findings: list[dict[str, object]] = []
58
+ for name, command in (
59
+ ("banned_phrase", ["python3", "scripts/banned_phrase_scan.py"]),
60
+ ("structure", ["python3", "scripts/structure_scan.py"]),
61
+ ):
62
+ proc = run_scanner(command, text)
63
+ try:
64
+ data = json.loads(proc.stdout)
65
+ except json.JSONDecodeError:
66
+ data = {}
67
+ violations = data.get("violations", [])
68
+ if name == "structure":
69
+ violations = [
70
+ {
71
+ "phrase": flag.get("metric", ""),
72
+ "category": flag.get("metric", ""),
73
+ "severity": flag.get("severity", ""),
74
+ }
75
+ for flag in data.get("flags", [])
76
+ ]
77
+ for violation in violations:
78
+ findings.append(
79
+ {
80
+ "scanner": name,
81
+ "phrase": violation.get("phrase", ""),
82
+ "category": violation.get("category", ""),
83
+ "severity": violation.get("severity", ""),
84
+ }
85
+ )
86
+ return findings
87
+
88
+
89
+ def redaction_pairs(values: list[str]) -> list[tuple[str, str]]:
90
+ pairs = []
91
+ for value in values:
92
+ if "=" not in value:
93
+ print(json.dumps({"error": "--redact values must use orig=REPL"}))
94
+ raise SystemExit(2)
95
+ orig, repl = value.split("=", 1)
96
+ pairs.append((orig, repl))
97
+ return pairs
98
+
99
+
100
+ def apply_redactions(text: str, tell: str, pairs: list[tuple[str, str]]) -> str:
101
+ before_count = text.count(tell)
102
+ redacted = text
103
+ for orig, repl in pairs:
104
+ redacted = redacted.replace(orig, repl)
105
+ if before_count == 0 or redacted.count(tell) != before_count:
106
+ print(json.dumps({"error": "redaction alters the specimen", "tell": tell}))
107
+ raise SystemExit(4)
108
+ return redacted
109
+
110
+
111
+ def row_fn(slug: str, tell: str, snippet: str) -> dict[str, object]:
112
+ assertion_tell = tell.casefold()
113
+ return {
114
+ "id": f"CONTRIB-FN-{slug}",
115
+ "target": "script",
116
+ "category": "scanner_false_negative",
117
+ "stdin": snippet,
118
+ "assertions": [
119
+ {"type": "json", "path": "total_violations", "gte": 1},
120
+ {"type": "violation_phrase_contains", "value": assertion_tell},
121
+ ],
122
+ }
123
+
124
+
125
+ def row_fp_template(slug: str, category: str, tell: str) -> dict[str, object]:
126
+ return {
127
+ "id": f"CONTRIB-FP-{slug}",
128
+ "category": "scanner_false_positive",
129
+ "protects": category,
130
+ "target": "script",
131
+ "stdin": "TODO: add a literal or domain-specific use that should remain clean.",
132
+ "assertions": [{"type": "json", "path": "total_violations", "equals": 0}],
133
+ }
134
+
135
+
136
+ def render_report(
137
+ manifest: dict[str, object],
138
+ snippet: str,
139
+ gate_results: str = "",
140
+ include_rec: bool = False,
141
+ ) -> str:
142
+ redactions = manifest.get("redactions", [])
143
+ redaction_note = "names/numbers redacted; tell verbatim" if redactions else "none"
144
+ rows = [
145
+ f"| CONTRIB-FN-{manifest['pattern_name']} | FN | exact specimen flags `{manifest['tell']}` |",
146
+ f"| CONTRIB-FP-{manifest['pattern_name']} | FP | literal-use protection for `{manifest['tell']}` |",
147
+ ]
148
+ if include_rec:
149
+ rows.append("| CONTRIB-REC | REC | existing-word recall still flags |")
150
+ quoted = "\n".join(f"> {line}" if line else ">" for line in snippet.splitlines())
151
+ pattern_added = manifest.get(
152
+ "pattern_added",
153
+ f"TODO: regex or phrase for `{manifest['tell']}`",
154
+ )
155
+ return (
156
+ f"# Add {manifest['category']} pattern: {shorten(str(manifest['tell']))}\n\n"
157
+ "## The specimen\n\n"
158
+ f"{quoted}\n\n"
159
+ f"- Source genre: {manifest.get('source_genre', 'TODO: source genre')}\n"
160
+ f"- Date: {manifest['date']}\n"
161
+ f"- Redaction note: {redaction_note}\n\n"
162
+ "## Why it's an AI-ism\n\n"
163
+ f"{manifest.get('rationale', 'TODO: explain why this phrase is a reusable AI-writing tell in 2-4 sentences.')}\n\n"
164
+ "## Detection\n\n"
165
+ f"- Pattern added: {pattern_added}\n"
166
+ f"- Severity: {manifest.get('severity', 'TODO: hard or soft')}\n"
167
+ f"- Gating rationale: {manifest.get('gating_rationale', 'TODO: explain literal-use boundary')}\n"
168
+ "- Catalog entry location: references/taboo-phrases.md\n\n"
169
+ "## Evals\n\n"
170
+ "| row id | kind | what it pins |\n"
171
+ "|---|---|---|\n"
172
+ + "\n".join(rows)
173
+ + "\n\n"
174
+ "The FN stdin is the unmodified specimen after approved redaction.\n\n"
175
+ "## Gate results\n\n"
176
+ f"{gate_results or 'TODO: paste gate tails from verify.'}\n\n"
177
+ "## Checklist\n\n"
178
+ "- [ ] eval-first (row was red before the pattern)\n"
179
+ "- [ ] literal-use FP row included\n"
180
+ "- [ ] REC row if an existing word was gated\n"
181
+ "- [ ] catalog + scanner parity green\n"
182
+ "- [ ] coverage gate green (pattern exercised)\n"
183
+ "- [ ] snippet publication approved by the user\n"
184
+ )
185
+
186
+
187
+ def cmd_precheck(args: argparse.Namespace) -> int:
188
+ snippet = read_text(Path(args.snippet_file))
189
+ findings = scanner_findings(snippet)
190
+ if findings:
191
+ print(json.dumps({"status": "already_covered", "findings": findings}, indent=2, sort_keys=True))
192
+ return 3
193
+ print(json.dumps({"status": "clean", "words": word_count(snippet)}, indent=2, sort_keys=True))
194
+ return 0
195
+
196
+
197
+ def cmd_scaffold(args: argparse.Namespace) -> int:
198
+ if not SLUG_RE.match(args.pattern_name):
199
+ print(
200
+ json.dumps(
201
+ {
202
+ "error": f"invalid pattern name: {args.pattern_name!r}; use lowercase letters, digits, - or _",
203
+ }
204
+ )
205
+ )
206
+ return 2
207
+ source = Path(args.snippet)
208
+ snippet = read_text(source)
209
+ if args.tell not in snippet:
210
+ print(json.dumps({"error": "tell substring not found", "tell": args.tell}))
211
+ return 2
212
+ try:
213
+ manifest_date = datetime.strptime(args.date, "%Y-%m-%d").date().isoformat()
214
+ except ValueError:
215
+ print(json.dumps({"error": "--date must use YYYY-MM-DD", "date": args.date}))
216
+ return 2
217
+ redactions = redaction_pairs(args.redact or [])
218
+ redacted = apply_redactions(snippet, args.tell, redactions)
219
+ bundle = CONTRIB_ROOT / args.pattern_name
220
+ bundle.mkdir(parents=True, exist_ok=True)
221
+ manifest = {
222
+ "category": args.category,
223
+ "date": manifest_date,
224
+ "pattern_name": args.pattern_name,
225
+ "pre_redaction_sha256": sha256_text(snippet),
226
+ "post_redaction_sha256": sha256_text(redacted),
227
+ "redactions": [{"from": orig, "to": repl} for orig, repl in redactions],
228
+ "tell": args.tell,
229
+ "word_count": word_count(redacted),
230
+ }
231
+ write_json(bundle / "row_fn.json", row_fn(args.pattern_name, args.tell, redacted))
232
+ write_json(bundle / "row_fp_TEMPLATE.json", row_fp_template(args.pattern_name, args.category, args.tell))
233
+ write_json(bundle / "manifest.json", manifest)
234
+ (bundle / "snippet.txt").write_text(redacted, encoding="utf-8")
235
+ (bundle / "report.md").write_text(render_report(manifest, redacted), encoding="utf-8")
236
+ print(json.dumps({"bundle": str(bundle.relative_to(ROOT)), "status": "scaffolded"}, indent=2, sort_keys=True))
237
+ return 0
238
+
239
+
240
+ def run_row(row: dict[str, object]) -> subprocess.CompletedProcess[str]:
241
+ return subprocess.run(
242
+ ["python3", "scripts/banned_phrase_scan.py"],
243
+ input=str(row.get("stdin", "")),
244
+ capture_output=True,
245
+ text=True,
246
+ cwd=ROOT,
247
+ timeout=30,
248
+ )
249
+
250
+
251
+ def row_assertions_pass(row: dict[str, object], proc: subprocess.CompletedProcess[str]) -> bool:
252
+ try:
253
+ data = json.loads(proc.stdout)
254
+ except json.JSONDecodeError:
255
+ return False
256
+ for assertion in row.get("assertions", []):
257
+ if assertion.get("type") == "json":
258
+ cur = data
259
+ for part in assertion["path"].split("."):
260
+ cur = cur[int(part)] if isinstance(cur, list) else cur[part]
261
+ if "gte" in assertion and not cur >= assertion["gte"]:
262
+ return False
263
+ if "equals" in assertion and cur != assertion["equals"]:
264
+ return False
265
+ elif assertion.get("type") == "violation_phrase_contains":
266
+ phrases = [v.get("phrase", "") for v in data.get("violations", [])]
267
+ needle = str(assertion["value"]).casefold()
268
+ if not any(needle in str(phrase).casefold() for phrase in phrases):
269
+ return False
270
+ else:
271
+ return False
272
+ return True
273
+
274
+
275
+ def tail(text: str, lines: int = 12) -> str:
276
+ split = text.splitlines()
277
+ return "\n".join(split[-lines:])
278
+
279
+
280
+ def verify_bundle(bundle: Path, run_gates: bool = True) -> tuple[bool, str]:
281
+ if not bundle.exists():
282
+ return False, "missing bundle"
283
+ row_path = bundle / "row_fn.json"
284
+ report_path = bundle / "report.md"
285
+ manifest_path = bundle / "manifest.json"
286
+ snippet_path = bundle / "snippet.txt"
287
+ for path in (row_path, report_path, manifest_path, snippet_path):
288
+ if not path.exists():
289
+ return False, f"missing {path.name}"
290
+ report = report_path.read_text(encoding="utf-8")
291
+ if TODO_MARKER in report:
292
+ return False, "report contains TODO markers"
293
+ row = json.loads(row_path.read_text(encoding="utf-8"))
294
+ snippet = snippet_path.read_text(encoding="utf-8")
295
+ if row.get("stdin") != snippet:
296
+ return False, "row_fn stdin does not equal snippet.txt byte-for-byte"
297
+ proc = run_row(row)
298
+ assertion_ok = row_assertions_pass(row, proc)
299
+ red_first = not assertion_ok
300
+ gate_results = [f"red-first: {'ok' if red_first else 'already green'}"]
301
+ ok = red_first
302
+ if not red_first:
303
+ ok = True
304
+ gate_results[0] = "red-first: already green; proposed pattern appears active"
305
+ if run_gates:
306
+ for command in GATE_COMMANDS:
307
+ proc = subprocess.run(command, capture_output=True, text=True, cwd=ROOT, timeout=120)
308
+ gate_results.append(
309
+ "### "
310
+ + " ".join(command)
311
+ + f"\n\n```text\nexit={proc.returncode}\n{tail(proc.stdout or proc.stderr)}\n```"
312
+ )
313
+ ok = ok and proc.returncode == 0
314
+ final = "\n\n".join(gate_results)
315
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
316
+ report_path.write_text(render_report(manifest, snippet, final, (bundle / "row_rec.json").exists()), encoding="utf-8")
317
+ return ok, final
318
+
319
+
320
+ def cmd_verify(args: argparse.Namespace) -> int:
321
+ ok, message = verify_bundle(Path(args.bundle), run_gates=not args.no_gates)
322
+ print(message)
323
+ return 0 if ok else 1
324
+
325
+
326
+ def cmd_report(args: argparse.Namespace) -> int:
327
+ bundle = Path(args.bundle)
328
+ if not bundle.exists():
329
+ print(json.dumps({"error": "missing bundle", "bundle": str(bundle)}))
330
+ return 2
331
+ manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
332
+ snippet = (bundle / "snippet.txt").read_text(encoding="utf-8")
333
+ report = (bundle / "report.md").read_text(encoding="utf-8")
334
+ gate_marker = "## Gate results\n\n"
335
+ checklist_marker = "\n\n## Checklist"
336
+ gate_results = ""
337
+ if gate_marker in report and checklist_marker in report:
338
+ gate_results = report.split(gate_marker, 1)[1].split(checklist_marker, 1)[0]
339
+ print(render_report(manifest, snippet, gate_results, (bundle / "row_rec.json").exists()).rstrip())
340
+ return 0
341
+
342
+
343
+ def parse_args(argv: list[str]) -> argparse.Namespace:
344
+ parser = argparse.ArgumentParser(description=__doc__)
345
+ sub = parser.add_subparsers(dest="command", required=True)
346
+ precheck = sub.add_parser("precheck")
347
+ precheck.add_argument("snippet_file")
348
+ precheck.set_defaults(func=cmd_precheck)
349
+ scaffold = sub.add_parser("scaffold")
350
+ scaffold.add_argument("--snippet", required=True)
351
+ scaffold.add_argument("--tell", required=True)
352
+ scaffold.add_argument("--category", required=True)
353
+ scaffold.add_argument("--pattern-name", required=True)
354
+ scaffold.add_argument("--redact", action="append")
355
+ scaffold.add_argument("--date", default=date.today().isoformat())
356
+ scaffold.set_defaults(func=cmd_scaffold)
357
+ verify = sub.add_parser("verify")
358
+ verify.add_argument("--bundle", required=True)
359
+ verify.add_argument("--no-gates", action="store_true")
360
+ verify.set_defaults(func=cmd_verify)
361
+ report = sub.add_parser("report")
362
+ report.add_argument("--bundle", required=True)
363
+ report.set_defaults(func=cmd_report)
364
+ return parser.parse_args(argv)
365
+
366
+
367
+ def main(argv: list[str]) -> int:
368
+ args = parse_args(argv)
369
+ return args.func(args)
370
+
371
+
372
+ if __name__ == "__main__":
373
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Check change percentage between original and transformed text.
4
+
5
+ Flags if >40% of words changed (may indicate over-editing).
6
+
7
+ Pure reordering is exempt: change_percentage still reflects the raw
8
+ delete+insert diff, but excessive_change stays false when the transformed
9
+ text is a word-level permutation of the original (moved sentences are not
10
+ over-editing). SKILL.md's 40% guidance refers to the excessive_change flag.
11
+
12
+ Usage:
13
+ python diff_check.py original.txt transformed.txt
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ import json
20
+ import re
21
+ from collections import Counter
22
+ from difflib import SequenceMatcher
23
+ from typing import TypedDict
24
+
25
+
26
+ class DiffResult(TypedDict):
27
+ original_word_count: int
28
+ transformed_word_count: int
29
+ similarity_ratio: float
30
+ change_percentage: float
31
+ words_added: int
32
+ words_removed: int
33
+ words_changed: int
34
+ excessive_change: bool
35
+ flags: list[str]
36
+
37
+
38
+ def split_words(text: str) -> list[str]:
39
+ """Split text into comparison tokens.
40
+
41
+ Punctuation is tokenized separately (not stripped) so that structural edits
42
+ like repunctuation register as change instead of reading as identical.
43
+ """
44
+ return re.findall(r'\w+|[^\w\s]', text.lower())
45
+
46
+
47
+ def calculate_diff(original: str, transformed: str) -> DiffResult:
48
+ """Calculate difference metrics between two texts."""
49
+ original_words = split_words(original)
50
+ transformed_words = split_words(transformed)
51
+
52
+ # Use SequenceMatcher for word-level diff
53
+ matcher = SequenceMatcher(None, original_words, transformed_words)
54
+ similarity = matcher.ratio()
55
+
56
+ # Count operations
57
+ words_added = 0
58
+ words_removed = 0
59
+ words_changed = 0
60
+
61
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
62
+ if tag == 'replace':
63
+ words_changed += max(i2 - i1, j2 - j1)
64
+ elif tag == 'delete':
65
+ words_removed += i2 - i1
66
+ elif tag == 'insert':
67
+ words_added += j2 - j1
68
+
69
+ # Change percentage (relative to original)
70
+ total_changes = words_added + words_removed + words_changed
71
+ change_percentage = (
72
+ (total_changes / len(original_words) * 100)
73
+ if original_words else 0
74
+ )
75
+ shared_tokens = sum((Counter(original_words) & Counter(transformed_words)).values())
76
+ token_overlap = shared_tokens / len(original_words) if original_words else 0
77
+
78
+ # Flags
79
+ flags: list[str] = []
80
+ excessive = False
81
+
82
+ if change_percentage > 40:
83
+ if token_overlap >= 0.9:
84
+ flags.append(f"Mostly reordered ({token_overlap:.0%} tokens shared)")
85
+ else:
86
+ flags.append(f"Excessive change ({change_percentage:.1f}% > 40% threshold)")
87
+ excessive = True
88
+
89
+ if len(transformed_words) < len(original_words) * 0.3:
90
+ flags.append("Transformed text is less than 30% of original length")
91
+ excessive = True
92
+
93
+ if len(transformed_words) > len(original_words) * 1.5:
94
+ flags.append("Transformed text is 50%+ longer than original")
95
+
96
+ # Length change ratio
97
+ length_ratio = len(transformed_words) / len(original_words) if original_words else 0
98
+ if length_ratio < 0.5:
99
+ flags.append(f"Significant condensation ({length_ratio:.0%} of original)")
100
+ elif length_ratio > 1.2:
101
+ flags.append(f"Text expanded ({length_ratio:.0%} of original)")
102
+
103
+ return {
104
+ "original_word_count": len(original_words),
105
+ "transformed_word_count": len(transformed_words),
106
+ "similarity_ratio": round(similarity, 3),
107
+ "change_percentage": round(change_percentage, 1),
108
+ "words_added": words_added,
109
+ "words_removed": words_removed,
110
+ "words_changed": words_changed,
111
+ "excessive_change": excessive,
112
+ "flags": flags
113
+ }
114
+
115
+
116
+ def main() -> None:
117
+ if len(sys.argv) < 3:
118
+ print("Usage: diff_check.py <original.txt> <transformed.txt>")
119
+ sys.exit(1)
120
+
121
+ # Read inputs
122
+ try:
123
+ with open(sys.argv[1], 'r') as f:
124
+ original = f.read()
125
+ with open(sys.argv[2], 'r') as f:
126
+ transformed = f.read()
127
+ except OSError as e:
128
+ print(json.dumps({"error": f"Could not read input: {e}"}))
129
+ sys.exit(2)
130
+
131
+ result = calculate_diff(original, transformed)
132
+ print(json.dumps(result, indent=2))
133
+
134
+ # Exit with 1 if excessive change
135
+ sys.exit(1 if result["excessive_change"] else 0)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()