@iseoane/agent-depot 0.2.0 → 0.3.0

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 (86) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +117 -3
  3. package/dist/src/app-cli.js +172 -0
  4. package/dist/src/app-flow.js +457 -0
  5. package/dist/src/app-latest.js +63 -0
  6. package/dist/src/app-owned-skills.js +42 -0
  7. package/dist/src/app-recipes.js +116 -0
  8. package/dist/src/app-schema.js +45 -0
  9. package/dist/src/app-version.js +46 -0
  10. package/dist/src/atomic-file.js +26 -0
  11. package/dist/src/cli.js +1256 -0
  12. package/dist/src/git-source.js +409 -0
  13. package/dist/src/path-safety.js +31 -0
  14. package/dist/src/process-runner.js +41 -0
  15. package/dist/src/project-installation.js +1411 -0
  16. package/dist/src/project-manifest.js +511 -0
  17. package/dist/src/skill-adoption.js +77 -0
  18. package/dist/src/skill-discovery.js +467 -0
  19. package/dist/src/skill-hosts.js +103 -0
  20. package/dist/src/skill-install.js +374 -0
  21. package/dist/src/skill-removal.js +119 -0
  22. package/dist/src/skill-update.js +244 -0
  23. package/dist/src/source-state.js +240 -0
  24. package/dist/src/sources.js +391 -0
  25. package/dist/src/tui/adoption-actions.js +144 -0
  26. package/dist/src/tui/app-actions.js +157 -0
  27. package/dist/src/tui/app.js +63 -0
  28. package/dist/src/tui/batch.js +17 -0
  29. package/dist/src/tui/bulk-actions.js +243 -0
  30. package/dist/src/tui/catalog-actions.js +9 -0
  31. package/dist/src/tui/catalog-browse.js +59 -0
  32. package/dist/src/tui/catalog-filter.js +20 -0
  33. package/dist/src/tui/catalog-flow.js +107 -0
  34. package/dist/src/tui/catalog-installs.js +193 -0
  35. package/dist/src/tui/catalog-panel.js +47 -0
  36. package/dist/src/tui/catalog-state.js +92 -0
  37. package/dist/src/tui/catalog-tree.js +35 -0
  38. package/dist/src/tui/catalog-view.js +89 -0
  39. package/dist/src/tui/environment.js +1 -0
  40. package/dist/src/tui/installations-browse.js +136 -0
  41. package/dist/src/tui/installations-mode.js +1 -0
  42. package/dist/src/tui/installations-panel.js +27 -0
  43. package/dist/src/tui/installations-rows.js +11 -0
  44. package/dist/src/tui/installations-tree.js +80 -0
  45. package/dist/src/tui/installations-view.js +154 -0
  46. package/dist/src/tui/installations.js +172 -0
  47. package/dist/src/tui/keys.js +31 -0
  48. package/dist/src/tui/manage-actions.js +174 -0
  49. package/dist/src/tui/manage-panel.js +65 -0
  50. package/dist/src/tui/mode-keys.js +61 -0
  51. package/dist/src/tui/panel-parts.js +23 -0
  52. package/dist/src/tui/render.js +8 -0
  53. package/dist/src/tui/sources-mode.js +17 -0
  54. package/dist/src/tui/sources-view.js +276 -0
  55. package/dist/src/tui/theme.js +29 -0
  56. package/dist/src/tui/tree-navigation.js +68 -0
  57. package/dist/src/tui/tree.js +53 -0
  58. package/dist/src/tui/unmanaged-actions.js +178 -0
  59. package/dist/src/tui/updates-flow.js +89 -0
  60. package/dist/src/tui/updates-keys.js +104 -0
  61. package/dist/src/tui/updates-mode.js +1 -0
  62. package/dist/src/tui/updates-model.js +48 -0
  63. package/dist/src/tui/updates-panel.js +44 -0
  64. package/dist/src/tui/updates-view.js +101 -0
  65. package/dist/src/tui/updates.js +230 -0
  66. package/dist/src/tui/view-state.js +42 -0
  67. package/dist/src/tui/window.js +31 -0
  68. package/dist/src/unmanaged-removal.js +60 -0
  69. package/dist/src/update-batch.js +376 -0
  70. package/dist/src/update-flow.js +135 -0
  71. package/dist/src/usage-error.js +7 -0
  72. package/dist/src/user-global-skill-inventory.js +565 -0
  73. package/dist/src/user-method.js +14 -0
  74. package/package.json +7 -4
  75. package/skills/agent-depot-apprecipe/SKILL.md +44 -0
  76. package/skills/agent-depot-apprecipe/assets/example.md +25 -0
  77. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
  78. package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
  79. package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
  80. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
  81. package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
  82. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
  83. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
  84. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
  85. package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
  86. package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
@@ -1,343 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Tests for scan_corrections.py."""
3
-
4
- import json
5
- import os
6
- import tempfile
7
- import unittest
8
- from datetime import datetime, timedelta, timezone
9
- from pathlib import Path
10
-
11
- import scan_corrections as sc
12
-
13
-
14
- def write_jsonl(path, records):
15
- path.parent.mkdir(parents=True, exist_ok=True)
16
- path.write_text("\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8")
17
-
18
-
19
- def run_scan(claude_home, out_dir, **kwargs):
20
- argv = ["--out", str(out_dir), "--claude-home", str(claude_home),
21
- "--codex-home", str(Path(out_dir) / "no-codex-here")]
22
- for key, value in kwargs.items():
23
- flag = "--" + key.replace("_", "-")
24
- if value is True:
25
- argv.append(flag)
26
- elif value is False:
27
- continue
28
- else:
29
- argv.extend([flag, str(value)])
30
- sc.main(argv)
31
- return json.loads((Path(out_dir) / "corrections.json").read_text())
32
-
33
-
34
- def now_iso(offset_days=0):
35
- return (datetime.now(timezone.utc) - timedelta(days=offset_days)).strftime("%Y-%m-%dT%H:%M:%SZ")
36
-
37
-
38
- class BreakingCaseTests(unittest.TestCase):
39
- def test_tool_result_only_user_record_is_ignored(self):
40
- with tempfile.TemporaryDirectory() as tmp:
41
- claude_home = Path(tmp) / "claude"
42
- path = claude_home / "projects" / "p1" / "s1.jsonl"
43
- write_jsonl(path, [
44
- {"type": "user", "sessionId": "s1", "cwd": "/repo", "timestamp": now_iso(),
45
- "uuid": "u1",
46
- "message": {"role": "user", "content": [
47
- {"type": "tool_result", "content": "some tool output"}
48
- ]}},
49
- ])
50
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
51
- self.assertEqual(report["stats"]["user_turns"], 0)
52
-
53
- def test_command_name_turn_is_ignored(self):
54
- with tempfile.TemporaryDirectory() as tmp:
55
- claude_home = Path(tmp) / "claude"
56
- path = claude_home / "projects" / "p1" / "s1.jsonl"
57
- write_jsonl(path, [
58
- {"type": "user", "sessionId": "s1", "cwd": "/repo", "timestamp": now_iso(),
59
- "uuid": "u1",
60
- "message": {"role": "user",
61
- "content": "<command-name>/clear</command-name>\nno uses npm nunca"}},
62
- ])
63
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
64
- self.assertEqual(report["stats"]["user_turns"], 0)
65
- self.assertEqual(report["stats"]["corrections_found"], 0)
66
-
67
- def test_sidechain_turn_is_ignored(self):
68
- with tempfile.TemporaryDirectory() as tmp:
69
- claude_home = Path(tmp) / "claude"
70
- path = claude_home / "projects" / "p1" / "s1.jsonl"
71
- write_jsonl(path, [
72
- {"type": "user", "sessionId": "s1", "cwd": "/repo", "timestamp": now_iso(),
73
- "uuid": "u1", "isSidechain": True,
74
- "message": {"role": "user", "content": "no uses npm, usa pnpm"}},
75
- ])
76
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
77
- self.assertEqual(report["stats"]["user_turns"], 0)
78
-
79
- def test_same_session_id_across_two_files_counted_once(self):
80
- with tempfile.TemporaryDirectory() as tmp:
81
- claude_home = Path(tmp) / "claude"
82
- ts = now_iso()
83
- record = {
84
- "type": "user", "sessionId": "shared-session", "cwd": "/repo", "timestamp": ts,
85
- "uuid": "same-uuid",
86
- "message": {"role": "user", "content": "hello there, nothing special"},
87
- }
88
- write_jsonl(claude_home / "projects" / "p1" / "a.jsonl", [record])
89
- write_jsonl(claude_home / "projects" / "p1" / "b.jsonl", [record])
90
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
91
- self.assertEqual(report["stats"]["sessions_scanned"], 1)
92
- self.assertEqual(report["stats"]["user_turns"], 1)
93
-
94
- def test_real_correction_is_found_with_pattern_and_preceding_context(self):
95
- with tempfile.TemporaryDirectory() as tmp:
96
- claude_home = Path(tmp) / "claude"
97
- path = claude_home / "projects" / "p1" / "s1.jsonl"
98
- write_jsonl(path, [
99
- {"type": "assistant", "sessionId": "s1", "cwd": "/repo", "timestamp": now_iso(),
100
- "uuid": "a1",
101
- "message": {"role": "assistant", "id": "m1",
102
- "content": [{"type": "text", "text": "Installing with npm install."}]}},
103
- {"type": "user", "sessionId": "s1", "cwd": "/repo", "timestamp": now_iso(),
104
- "uuid": "u1",
105
- "message": {"role": "user", "content": "no, usa pnpm no npm"}},
106
- ])
107
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
108
- self.assertEqual(len(report["corrections"]), 1)
109
- corr = report["corrections"][0]
110
- self.assertEqual(corr["pattern"], "no, usa")
111
- self.assertIn("pnpm", corr["text"])
112
- self.assertIn("npm install", corr["preceding"])
113
-
114
-
115
- class WindowAndRepoTests(unittest.TestCase):
116
- def test_days_excludes_old_session(self):
117
- with tempfile.TemporaryDirectory() as tmp:
118
- claude_home = Path(tmp) / "claude"
119
- path = claude_home / "projects" / "p1" / "old.jsonl"
120
- old_ts = (datetime.now(timezone.utc) - timedelta(days=200)).strftime("%Y-%m-%dT%H:%M:%SZ")
121
- write_jsonl(path, [
122
- {"type": "user", "sessionId": "old-session", "cwd": "/repo", "timestamp": old_ts,
123
- "uuid": "u1", "message": {"role": "user", "content": "no uses npm nunca mas"}},
124
- ])
125
- old_time = (datetime.now(timezone.utc) - timedelta(days=200)).timestamp()
126
- os.utime(path, (old_time, old_time))
127
- report = run_scan(claude_home, Path(tmp) / "out", days=90, all_conversations=True)
128
- self.assertEqual(report["stats"]["sessions_scanned"], 0)
129
-
130
- def test_repo_filters_by_cwd(self):
131
- with tempfile.TemporaryDirectory() as tmp:
132
- claude_home = Path(tmp) / "claude"
133
- in_repo = Path(tmp) / "repo-a"
134
- out_repo = Path(tmp) / "repo-b"
135
- write_jsonl(claude_home / "projects" / "p1" / "s1.jsonl", [
136
- {"type": "user", "sessionId": "s-in", "cwd": str(in_repo), "timestamp": now_iso(),
137
- "uuid": "u1", "message": {"role": "user", "content": "hi there in repo a"}},
138
- ])
139
- write_jsonl(claude_home / "projects" / "p1" / "s2.jsonl", [
140
- {"type": "user", "sessionId": "s-out", "cwd": str(out_repo), "timestamp": now_iso(),
141
- "uuid": "u2", "message": {"role": "user", "content": "hi there in repo b"}},
142
- ])
143
- report = run_scan(claude_home, Path(tmp) / "out", repo=str(in_repo))
144
- self.assertEqual(report["stats"]["sessions_in_scope"], 1)
145
- self.assertEqual(report["stats"]["sessions_scanned"], 2)
146
-
147
-
148
- class ClusteringTests(unittest.TestCase):
149
- def test_shared_salient_tokens_form_a_cluster(self):
150
- corrections = [
151
- {"session_id": "s1", "text": "no uses npm, usa siempre pnpm para el frontend"},
152
- {"session_id": "s2", "text": "otra vez con npm, usa pnpm en el frontend por favor"},
153
- {"session_id": "s3", "text": "cosa totalmente distinta sobre bandas LTE"},
154
- ]
155
- clusters = sc.build_clusters(corrections)
156
- big = next(c for c in clusters if c["count"] == 2)
157
- self.assertEqual(set(big["sessions"]), {"s1", "s2"})
158
- singleton = next(c for c in clusters if c["count"] == 1)
159
- self.assertEqual(singleton["sessions"], ["s3"])
160
-
161
- def test_clusters_sorted_by_count_descending(self):
162
- corrections = [
163
- {"session_id": "s1", "text": "pnpm frontend siempre pnpm"},
164
- {"session_id": "s2", "text": "pnpm frontend siempre pnpm otra vez"},
165
- {"session_id": "s3", "text": "algo distinto"},
166
- ]
167
- clusters = sc.build_clusters(corrections)
168
- counts = [c["count"] for c in clusters]
169
- self.assertEqual(counts, sorted(counts, reverse=True))
170
-
171
-
172
- class MachineTurnFilterTests(unittest.TestCase):
173
- def test_long_turn_matching_pattern_is_dropped_as_too_long(self):
174
- with tempfile.TemporaryDirectory() as tmp:
175
- claude_home = Path(tmp) / "claude"
176
- long_text = "no, usa pnpm en vez de npm. " + ("relleno de plantilla " * 100)
177
- self.assertGreater(len(long_text), 1200)
178
- path = claude_home / "projects" / "p1" / "s1.jsonl"
179
- write_jsonl(path, [
180
- {"type": "user", "sessionId": "s1", "cwd": "/repo", "timestamp": now_iso(),
181
- "uuid": "u1", "message": {"role": "user", "content": long_text}},
182
- ])
183
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
184
- self.assertEqual(report["stats"]["corrections_found"], 0)
185
- self.assertEqual(report["stats"]["turns_dropped_too_long"], 1)
186
- # Still counted as a real, in-scope user turn -- the arithmetic
187
- # user_turns - turns_dropped_too_long stays auditable.
188
- self.assertEqual(report["stats"]["user_turns"], 1)
189
-
190
- def test_short_text_in_three_distinct_sessions_is_dropped_as_template(self):
191
- with tempfile.TemporaryDirectory() as tmp:
192
- claude_home = Path(tmp) / "claude"
193
- template_text = "no uses npm, usa siempre pnpm en el frontend"
194
- for i in range(3):
195
- path = claude_home / "projects" / "p1" / f"s{i}.jsonl"
196
- write_jsonl(path, [
197
- {"type": "user", "sessionId": f"tmpl-{i}", "cwd": "/repo", "timestamp": now_iso(),
198
- "uuid": "u1", "message": {"role": "user", "content": template_text}},
199
- ])
200
- genuine_path = claude_home / "projects" / "p1" / "genuine.jsonl"
201
- write_jsonl(genuine_path, [
202
- {"type": "user", "sessionId": "genuine-session", "cwd": "/repo", "timestamp": now_iso(),
203
- "uuid": "u1",
204
- "message": {"role": "user", "content": "no, usa argon2 en vez de bcrypt para el hashing"}},
205
- ])
206
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
207
- self.assertEqual(report["stats"]["turns_dropped_templated"], 3)
208
- self.assertEqual(report["stats"]["corrections_found"], 1)
209
- self.assertIn("argon2", report["corrections"][0]["text"])
210
- self.assertEqual(len(report["dropped_templates"]), 1)
211
- self.assertEqual(report["dropped_templates"][0]["sessions"], 3)
212
- self.assertEqual(report["dropped_templates"][0]["occurrences"], 3)
213
- self.assertIn("no uses npm", report["dropped_templates"][0]["prefix"])
214
-
215
- def test_same_text_repeated_in_one_session_is_not_templated(self):
216
- with tempfile.TemporaryDirectory() as tmp:
217
- claude_home = Path(tmp) / "claude"
218
- text = "no uses npm, usa siempre pnpm en el frontend"
219
- path = claude_home / "projects" / "p1" / "s1.jsonl"
220
- records = [
221
- {"type": "user", "sessionId": "one-session", "cwd": "/repo", "timestamp": now_iso(),
222
- "uuid": f"u{i}", "message": {"role": "user", "content": text}}
223
- for i in range(3)
224
- ]
225
- write_jsonl(path, records)
226
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
227
- self.assertEqual(report["stats"]["turns_dropped_templated"], 0)
228
- self.assertEqual(report["stats"]["corrections_found"], 3)
229
- self.assertEqual(report["dropped_templates"], [])
230
-
231
- def test_short_prefix_key_catches_templates_with_diverging_payloads(self):
232
- # Pins the real-world failure: a machine template's grouping key
233
- # must be its opening, not a suffix that reaches into a variable
234
- # payload (an id, a JSON blob) -- otherwise every instance hashes
235
- # differently and the template filter never fires on exactly the
236
- # turns it exists to catch.
237
- with tempfile.TemporaryDirectory() as tmp:
238
- claude_home = Path(tmp) / "claude"
239
- shared_opening = (
240
- "no, usa siempre el snapshot inmutable del commit para intentar refutar el "
241
- "hallazgo critico representado como JSON no confiable a continuacion, sin "
242
- "modificar el arbol de commits objetivo bajo ninguna circunstancia: "
243
- )
244
- self.assertGreaterEqual(len(shared_opening), 160)
245
- payloads = [
246
- '{"finding_id": "aaaa-1111", "severity": "critical", "path": "/repo/a.py"}',
247
- '{"finding_id": "bbbb-2222", "severity": "critical", "path": "/repo/b.py"}',
248
- '{"finding_id": "cccc-3333", "severity": "critical", "path": "/repo/c.py"}',
249
- ]
250
- for i, payload in enumerate(payloads):
251
- text = shared_opening + payload
252
- self.assertLess(len(text), 1200)
253
- path = claude_home / "projects" / "p1" / f"s{i}.jsonl"
254
- write_jsonl(path, [
255
- {"type": "user", "sessionId": f"refuter-{i}", "cwd": "/repo", "timestamp": now_iso(),
256
- "uuid": "u1", "message": {"role": "user", "content": text}},
257
- ])
258
-
259
- # Default --template-prefix-chars (160) keys on the shared
260
- # opening alone: all three collapse into one template and are
261
- # dropped, even though each turn's JSON payload is unique.
262
- report = run_scan(claude_home, Path(tmp) / "out-default", all_conversations=True)
263
- self.assertEqual(report["stats"]["turns_dropped_templated"], 3)
264
- self.assertEqual(report["stats"]["corrections_found"], 0)
265
- self.assertEqual(len(report["dropped_templates"]), 1)
266
- self.assertEqual(report["dropped_templates"][0]["sessions"], 3)
267
- self.assertEqual(report["dropped_templates"][0]["occurrences"], 3)
268
-
269
- # A key long enough to reach into the payload (the pre-fix
270
- # 300-char default) defeats the filter: each turn's key is
271
- # unique because the whole (287-char) turn -- payload included
272
- # -- becomes the key, so nothing repeats and nothing is dropped.
273
- report_wide = run_scan(claude_home, Path(tmp) / "out-wide", all_conversations=True,
274
- template_prefix_chars=300)
275
- self.assertEqual(report_wide["stats"]["turns_dropped_templated"], 0)
276
- self.assertEqual(report_wide["stats"]["corrections_found"], 3)
277
-
278
- def test_custom_thresholds_are_honored(self):
279
- with tempfile.TemporaryDirectory() as tmp:
280
- claude_home = Path(tmp) / "claude"
281
- text = "no uses npm, usa siempre pnpm en el frontend"
282
- for i in range(2):
283
- path = claude_home / "projects" / "p1" / f"s{i}.jsonl"
284
- write_jsonl(path, [
285
- {"type": "user", "sessionId": f"pair-{i}", "cwd": "/repo", "timestamp": now_iso(),
286
- "uuid": "u1", "message": {"role": "user", "content": text}},
287
- ])
288
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True,
289
- template_threshold=2)
290
- self.assertEqual(report["stats"]["turns_dropped_templated"], 2)
291
- self.assertEqual(report["stats"]["corrections_found"], 0)
292
-
293
-
294
- class SelfSessionSentinelTests(unittest.TestCase):
295
- def test_sentinel_turn_drops_the_whole_session(self):
296
- with tempfile.TemporaryDirectory() as tmp:
297
- claude_home = Path(tmp) / "claude"
298
- path = claude_home / "projects" / "p1" / "s1.jsonl"
299
- write_jsonl(path, [
300
- {"type": "user", "sessionId": "self-session", "cwd": "/repo", "timestamp": now_iso(),
301
- "uuid": "u1",
302
- "message": {"role": "user",
303
- "content": sc.SELF_SESSION_SENTINEL + "\njudge this transcript against rule X"}},
304
- {"type": "user", "sessionId": "self-session", "cwd": "/repo", "timestamp": now_iso(),
305
- "uuid": "u2", "message": {"role": "user", "content": "no, usa pnpm no npm"}},
306
- ])
307
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
308
- self.assertEqual(report["stats"]["sessions_skipped_self_session"], 1)
309
- self.assertEqual(report["stats"]["turns_dropped_self_session"], 2)
310
- self.assertEqual(report["stats"]["corrections_found"], 0)
311
- self.assertEqual(report["corrections"], [])
312
-
313
- def test_sentinel_does_not_affect_other_sessions(self):
314
- with tempfile.TemporaryDirectory() as tmp:
315
- claude_home = Path(tmp) / "claude"
316
- self_path = claude_home / "projects" / "p1" / "self.jsonl"
317
- write_jsonl(self_path, [
318
- {"type": "user", "sessionId": "self-session", "cwd": "/repo", "timestamp": now_iso(),
319
- "uuid": "u1", "message": {"role": "user", "content": sc.SELF_SESSION_SENTINEL}},
320
- ])
321
- real_path = claude_home / "projects" / "p1" / "real.jsonl"
322
- write_jsonl(real_path, [
323
- {"type": "user", "sessionId": "real-session", "cwd": "/repo", "timestamp": now_iso(),
324
- "uuid": "u1", "message": {"role": "user", "content": "no, usa pnpm no npm"}},
325
- ])
326
- report = run_scan(claude_home, Path(tmp) / "out", all_conversations=True)
327
- self.assertEqual(report["stats"]["sessions_skipped_self_session"], 1)
328
- self.assertEqual(len(report["corrections"]), 1)
329
- self.assertEqual(report["corrections"][0]["session_id"], "real-session")
330
-
331
-
332
- class PatternMatchingTests(unittest.TestCase):
333
- def test_curly_apostrophe_matches(self):
334
- text = "don’t use npm here"
335
- self.assertEqual(sc.first_match(sc.compile_patterns(None), text), "don't use")
336
-
337
- def test_codex_injected_agents_md_is_filtered(self):
338
- text = "# AGENTS.md instructions for /repo\n\n<INSTRUCTIONS>\nnunca hagas esto\n</INSTRUCTIONS>"
339
- self.assertTrue(sc.looks_injected_codex(text))
340
-
341
-
342
- if __name__ == "__main__":
343
- unittest.main()
@@ -1,236 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Tests for verify_citations.py."""
3
-
4
- import json
5
- import tempfile
6
- import unittest
7
- from pathlib import Path
8
- from unittest import mock
9
-
10
- import verify_citations as vc
11
-
12
-
13
- def write_transcript(path, text):
14
- path.parent.mkdir(parents=True, exist_ok=True)
15
- path.write_text(text, encoding="utf-8")
16
-
17
-
18
- def write_findings(path, findings):
19
- path.parent.mkdir(parents=True, exist_ok=True)
20
- path.write_text(json.dumps({"findings": findings}), encoding="utf-8")
21
-
22
-
23
- def run_verify(findings_path, out_dir, **kwargs):
24
- argv = [str(findings_path), "--out", str(out_dir)]
25
- for key, value in kwargs.items():
26
- argv.extend([f"--{key.replace('_', '-')}", str(value)])
27
- vc.main(argv)
28
- return json.loads((Path(out_dir) / "findings.verified.json").read_text())
29
-
30
-
31
- class WhitespaceFoldingTests(unittest.TestCase):
32
- def test_quote_matching_only_after_whitespace_folding_verifies(self):
33
- with tempfile.TemporaryDirectory() as tmp:
34
- tmp = Path(tmp)
35
- transcript = tmp / "t1.md"
36
- write_transcript(
37
- transcript,
38
- "[user] Please remember: always use pnpm\nfor the frontend build.\n",
39
- )
40
- findings_path = tmp / "findings.json"
41
- write_findings(findings_path, [
42
- {"rule_id": "r1", "transcript_path": str(transcript), "label": "violated",
43
- "class": "non-compliance", "quote": "always use pnpm\n for the frontend build.",
44
- "reason": "the rule was ignored", "session_id": "s1"},
45
- ])
46
- report = run_verify(findings_path, tmp / "out")
47
- self.assertTrue(report["findings"][0]["verified"])
48
- self.assertIsNone(report["findings"][0]["reason_code"])
49
-
50
- def test_paraphrase_does_not_verify(self):
51
- with tempfile.TemporaryDirectory() as tmp:
52
- tmp = Path(tmp)
53
- transcript = tmp / "t1.md"
54
- write_transcript(transcript, "[user] Reminder: always use pnpm for the frontend build.\n")
55
- findings_path = tmp / "findings.json"
56
- write_findings(findings_path, [
57
- {"rule_id": "r1", "transcript_path": str(transcript), "label": "violated",
58
- "class": "non-compliance",
59
- "quote": "the agent should always prefer pnpm instead of npm here",
60
- "reason": "paraphrased, not quoted", "session_id": "s1"},
61
- ])
62
- report = run_verify(findings_path, tmp / "out")
63
- self.assertFalse(report["findings"][0]["verified"])
64
- self.assertEqual(report["findings"][0]["reason_code"], "not_found")
65
-
66
- def test_case_difference_does_not_verify(self):
67
- # Explicit assertion that the check is case-sensitive by design.
68
- with tempfile.TemporaryDirectory() as tmp:
69
- tmp = Path(tmp)
70
- transcript = tmp / "t1.md"
71
- write_transcript(transcript, "[user] reminder: always use pnpm here for the build.\n")
72
- findings_path = tmp / "findings.json"
73
- write_findings(findings_path, [
74
- {"rule_id": "r1", "transcript_path": str(transcript), "label": "violated",
75
- "class": "non-compliance", "quote": "ALWAYS USE PNPM HERE",
76
- "reason": "case differs from transcript", "session_id": "s1"},
77
- ])
78
- report = run_verify(findings_path, tmp / "out")
79
- finding = report["findings"][0]
80
- self.assertFalse(finding["verified"])
81
- self.assertEqual(finding["reason_code"], "not_found")
82
- # Prove the ONLY thing wrong is case: the lowercase form does verify.
83
- self.assertIn(
84
- vc.fold_whitespace("always use pnpm here"),
85
- vc.fold_whitespace(transcript.read_text(encoding="utf-8")),
86
- )
87
-
88
-
89
- class ReasonCodeTests(unittest.TestCase):
90
- def test_too_short_quote_gets_its_own_code(self):
91
- with tempfile.TemporaryDirectory() as tmp:
92
- tmp = Path(tmp)
93
- transcript = tmp / "t1.md"
94
- write_transcript(transcript, "[user] Always use pnpm here.\n")
95
- findings_path = tmp / "findings.json"
96
- write_findings(findings_path, [
97
- {"rule_id": "r1", "transcript_path": str(transcript), "label": "followed",
98
- "class": "none", "quote": "pnpm", "reason": "too short to prove anything",
99
- "session_id": "s1"},
100
- ])
101
- report = run_verify(findings_path, tmp / "out")
102
- finding = report["findings"][0]
103
- self.assertFalse(finding["verified"])
104
- self.assertEqual(finding["reason_code"], "quote_too_short")
105
-
106
- def test_missing_quote_field_gets_quote_missing(self):
107
- with tempfile.TemporaryDirectory() as tmp:
108
- tmp = Path(tmp)
109
- transcript = tmp / "t1.md"
110
- write_transcript(transcript, "[user] Always use pnpm here for the frontend.\n")
111
- findings_path = tmp / "findings.json"
112
- write_findings(findings_path, [
113
- {"rule_id": "r1", "transcript_path": str(transcript), "label": "followed",
114
- "class": "none", "quote": "", "reason": "no citation given", "session_id": "s1"},
115
- ])
116
- report = run_verify(findings_path, tmp / "out")
117
- self.assertEqual(report["findings"][0]["reason_code"], "quote_missing")
118
-
119
- def test_missing_transcript_yields_transcript_unreadable_not_not_found(self):
120
- with tempfile.TemporaryDirectory() as tmp:
121
- tmp = Path(tmp)
122
- missing_transcript = tmp / "does-not-exist.md"
123
- findings_path = tmp / "findings.json"
124
- write_findings(findings_path, [
125
- {"rule_id": "r1", "transcript_path": str(missing_transcript), "label": "violated",
126
- "class": "non-compliance", "quote": "always use pnpm here for the build",
127
- "reason": "transcript file is gone", "session_id": "s1"},
128
- ])
129
- report = run_verify(findings_path, tmp / "out")
130
- finding = report["findings"][0]
131
- self.assertFalse(finding["verified"])
132
- self.assertEqual(finding["reason_code"], "transcript_unreadable")
133
- self.assertEqual(report["stats"]["by_reason_code"]["transcript_unreadable"], 1)
134
- self.assertEqual(report["stats"]["by_reason_code"]["not_found"], 0)
135
-
136
-
137
- class CachingTests(unittest.TestCase):
138
- def test_shared_transcript_is_read_from_disk_once(self):
139
- with tempfile.TemporaryDirectory() as tmp:
140
- tmp = Path(tmp)
141
- transcript = tmp / "shared.md"
142
- write_transcript(transcript, "[user] Always use pnpm for the frontend build, please.\n")
143
- findings_path = tmp / "findings.json"
144
- write_findings(findings_path, [
145
- {"rule_id": "r1", "transcript_path": str(transcript), "label": "followed",
146
- "class": "none", "quote": "Always use pnpm for the frontend build",
147
- "reason": "first citation", "session_id": "s1"},
148
- {"rule_id": "r2", "transcript_path": str(transcript), "label": "violated",
149
- "class": "non-compliance", "quote": "for the frontend build, please",
150
- "reason": "second citation, same transcript", "session_id": "s1"},
151
- ])
152
- with mock.patch.object(vc, "load_transcript_text", wraps=vc.load_transcript_text) as spy:
153
- report = run_verify(findings_path, tmp / "out")
154
- self.assertEqual(spy.call_count, 1)
155
- self.assertTrue(all(f["verified"] for f in report["findings"]))
156
- self.assertEqual(report["stats"]["by_transcript"][str(transcript)]["total"], 2)
157
-
158
-
159
- class OptionalTriggerFieldTests(unittest.TestCase):
160
- """scorers/adherence.md gains an optional `trigger` field per finding
161
- (what the session was doing when the rule applied -- structure.md's raw
162
- material for writing a condition). verify_citations.py does not verify
163
- it like `quote`; it must pass it through untouched."""
164
-
165
- def test_trigger_field_passes_through_unverified_and_unchanged(self):
166
- with tempfile.TemporaryDirectory() as tmp:
167
- tmp = Path(tmp)
168
- transcript = tmp / "t1.md"
169
- write_transcript(transcript, "[assistant] edited backend/analysis/parser_4g.py\n")
170
- findings_path = tmp / "findings.json"
171
- write_findings(findings_path, [
172
- {"rule_id": "r1", "transcript_path": str(transcript), "label": "followed",
173
- "class": "none", "quote": "edited backend/analysis/parser_4g.py",
174
- "reason": "followed the rule while touching that file",
175
- "trigger": "editing backend/analysis/parser_4g.py"},
176
- ])
177
- report = run_verify(findings_path, tmp / "out")
178
- self.assertEqual(len(report["findings"]), 1)
179
- finding = report["findings"][0]
180
- self.assertTrue(finding["verified"])
181
- self.assertEqual(finding["trigger"], "editing backend/analysis/parser_4g.py")
182
-
183
- def test_finding_without_trigger_field_still_verifies(self):
184
- # trigger is optional -- its absence must not change anything.
185
- with tempfile.TemporaryDirectory() as tmp:
186
- tmp = Path(tmp)
187
- transcript = tmp / "t1.md"
188
- write_transcript(transcript, "[assistant] ran pnpm install\n")
189
- findings_path = tmp / "findings.json"
190
- write_findings(findings_path, [
191
- {"rule_id": "r1", "transcript_path": str(transcript), "label": "followed",
192
- "class": "none", "quote": "ran pnpm install", "reason": "used pnpm"},
193
- ])
194
- report = run_verify(findings_path, tmp / "out")
195
- self.assertTrue(report["findings"][0]["verified"])
196
- self.assertNotIn("trigger", report["findings"][0])
197
-
198
-
199
- class MalformedInputTests(unittest.TestCase):
200
- def test_invalid_json_exits_non_zero(self):
201
- with tempfile.TemporaryDirectory() as tmp:
202
- tmp = Path(tmp)
203
- findings_path = tmp / "findings.json"
204
- findings_path.write_text("{not json", encoding="utf-8")
205
- with self.assertRaises(SystemExit) as ctx:
206
- vc.main([str(findings_path), "--out", str(tmp / "out")])
207
- self.assertNotEqual(ctx.exception.code, 0)
208
-
209
- def test_missing_findings_key_exits_non_zero(self):
210
- with tempfile.TemporaryDirectory() as tmp:
211
- tmp = Path(tmp)
212
- findings_path = tmp / "findings.json"
213
- findings_path.write_text(json.dumps({"nope": []}), encoding="utf-8")
214
- with self.assertRaises(SystemExit) as ctx:
215
- vc.main([str(findings_path), "--out", str(tmp / "out")])
216
- self.assertNotEqual(ctx.exception.code, 0)
217
-
218
- def test_verification_failures_still_exit_zero(self):
219
- # Failing verification is data, not a script error -- main() must
220
- # not raise SystemExit at all on a structurally valid input file.
221
- with tempfile.TemporaryDirectory() as tmp:
222
- tmp = Path(tmp)
223
- findings_path = tmp / "findings.json"
224
- write_findings(findings_path, [
225
- {"rule_id": "r1", "transcript_path": str(tmp / "missing.md"), "label": "violated",
226
- "class": "non-compliance", "quote": "this quote will not verify at all",
227
- "reason": "bad citation", "session_id": "s1"},
228
- ])
229
- try:
230
- vc.main([str(findings_path), "--out", str(tmp / "out")])
231
- except SystemExit as exc:
232
- self.fail(f"main() exited with {exc.code} on a merely-unverified finding")
233
-
234
-
235
- if __name__ == "__main__":
236
- unittest.main()