@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,530 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Tests for extract_rules.py's rule splitter."""
3
-
4
- import contextlib
5
- import io
6
- import json
7
- import os
8
- import tempfile
9
- import unittest
10
- from pathlib import Path
11
- from unittest import mock
12
-
13
- from extract_rules import (
14
- build_sections,
15
- extract_rules_from_text,
16
- find_fenced_line_numbers,
17
- main,
18
- parse_args,
19
- parse_headings,
20
- )
21
-
22
-
23
- class SplitterTests(unittest.TestCase):
24
- def test_three_or_more_list_items_split_per_item(self):
25
- text = (
26
- "# Title\n"
27
- "\n"
28
- "## Rules\n"
29
- "- one\n"
30
- "- two\n"
31
- "- three\n"
32
- )
33
- rules, _, meta = extract_rules_from_text(text, "f", "project")
34
- self.assertEqual(meta["rules"], 3)
35
- titles = [r["title"] for r in rules]
36
- self.assertEqual(titles, ["- one", "- two", "- three"])
37
-
38
- def test_two_list_items_split_per_item(self):
39
- # LIST_SPLIT_MIN_ITEMS = 2: two clearly labelled bullets are already
40
- # two distinct, independently gradable rules -- e.g. a project
41
- # file's "Otras reglas" section with one bullet about learning from
42
- # corrections and one unrelated bullet about the package manager
43
- # must not collapse into a single rule with one merged verdict.
44
- text = (
45
- "# Title\n"
46
- "\n"
47
- "## Rules\n"
48
- "- one\n"
49
- "- two\n"
50
- )
51
- rules, _, meta = extract_rules_from_text(text, "f", "project")
52
- self.assertEqual(meta["rules"], 2)
53
- titles = [r["title"] for r in rules]
54
- self.assertEqual(titles, ["- one", "- two"])
55
-
56
- def test_single_list_item_does_not_split(self):
57
- # Below LIST_SPLIT_MIN_ITEMS: one lone bullet under a heading is
58
- # just a heading with one paragraph, not a list a reader would scan
59
- # item by item, so the whole section body stays one rule.
60
- text = (
61
- "# Title\n"
62
- "\n"
63
- "## Rules\n"
64
- "- only one item here\n"
65
- )
66
- rules, _, meta = extract_rules_from_text(text, "f", "project")
67
- self.assertEqual(meta["rules"], 1)
68
- self.assertIn("- only one item here", rules[0]["text"])
69
-
70
- def test_fenced_code_block_does_not_split(self):
71
- text = (
72
- "# Title\n"
73
- "\n"
74
- "## Rules\n"
75
- "Some intro prose.\n"
76
- "```markdown\n"
77
- "# heading inside fence\n"
78
- "- item inside fence\n"
79
- "- another item\n"
80
- "- yet another\n"
81
- "```\n"
82
- "More prose after.\n"
83
- )
84
- rules, _, meta = extract_rules_from_text(text, "f", "project")
85
- # The fenced "# heading" and 3 "-" items must not create a section
86
- # split or a list split -- the whole section body is one rule.
87
- self.assertEqual(meta["rules"], 1)
88
- self.assertIn("item inside fence", rules[0]["text"])
89
- self.assertIn("More prose after.", rules[0]["text"])
90
-
91
- def test_nested_list_item_stays_attached_to_parent(self):
92
- text = (
93
- "# Title\n"
94
- "\n"
95
- "## Rules\n"
96
- "1. First item\n"
97
- " - nested sub point one\n"
98
- " - nested sub point two\n"
99
- "2. Second item\n"
100
- "3. Third item\n"
101
- )
102
- rules, _, meta = extract_rules_from_text(text, "f", "project")
103
- self.assertEqual(meta["rules"], 3)
104
- first = rules[0]
105
- self.assertIn("First item", first["text"])
106
- self.assertIn("nested sub point one", first["text"])
107
- self.assertIn("nested sub point two", first["text"])
108
- self.assertNotIn("Second item", first["text"])
109
-
110
- def test_heading_path_through_empty_parent_section(self):
111
- text = (
112
- "# Parent\n"
113
- "\n"
114
- "## Empty Child\n"
115
- "\n"
116
- "### Grandchild\n"
117
- "Some real content here.\n"
118
- )
119
- rules, _, _ = extract_rules_from_text(text, "f", "global")
120
- self.assertEqual(len(rules), 1)
121
- self.assertEqual(rules[0]["section"], "Parent › Empty Child › Grandchild")
122
-
123
- def test_line_numbers_point_at_real_source_lines(self):
124
- text = (
125
- "# Title\n" # line 1
126
- "\n" # line 2
127
- "## Rules\n" # line 3
128
- "- alpha\n" # line 4
129
- "- beta\n" # line 5
130
- "- gamma\n" # line 6
131
- )
132
- rules, _, _ = extract_rules_from_text(text, "f", "project")
133
- self.assertEqual([r["start_line"] for r in rules], [4, 5, 6])
134
- self.assertEqual([r["end_line"] for r in rules], [4, 5, 6])
135
- self.assertEqual(rules[0]["id"], "f#4")
136
-
137
-
138
- class HelperTests(unittest.TestCase):
139
- def test_find_fenced_line_numbers_handles_indented_fence(self):
140
- lines = [
141
- "1. Item",
142
- " ```bash",
143
- " echo hi",
144
- " ```",
145
- "2. Next",
146
- ]
147
- fenced = find_fenced_line_numbers(lines)
148
- self.assertEqual(fenced, {2, 3, 4})
149
-
150
- def test_parse_headings_skips_fenced_hashes(self):
151
- lines = ["# Real", "```", "# not a heading", "```", "## Also Real"]
152
- fenced = find_fenced_line_numbers(lines)
153
- headings = parse_headings(lines, fenced)
154
- self.assertEqual([h["title"] for h in headings], ["Real", "Also Real"])
155
-
156
- def test_build_sections_drops_whitespace_only_body_but_keeps_path(self):
157
- lines = ["# A", "", "## B", "", "### C", "content"]
158
- fenced = set()
159
- headings = parse_headings(lines, fenced)
160
- sections = build_sections(len(lines), headings)
161
- # section for "B" has an empty body (only whitespace up to "### C")
162
- b_section = next(s for s in sections if s["title"] == "B")
163
- b_body = lines[b_section["body_start"] - 1:b_section["body_end"]]
164
- self.assertTrue(all(line.strip() == "" for line in b_body))
165
- c_section = next(s for s in sections if s["title"] == "C")
166
- self.assertEqual(c_section["heading_path"], "A › B › C")
167
-
168
-
169
- class ConditionalBlockTests(unittest.TestCase):
170
- """<important if="..."> blocks (references/structure.md, rule-model.md):
171
- a container whose rules split the same way as any other section, each
172
- carrying `condition`. Bare rules keep `condition: None`."""
173
-
174
- def test_block_with_list_splits_per_item_and_carries_condition(self):
175
- text = (
176
- "# Title\n"
177
- "\n"
178
- "## Rules\n"
179
- '<important if="editing backend Python files">\n'
180
- "- one\n"
181
- "- two\n"
182
- "</important>\n"
183
- )
184
- rules, _, meta = extract_rules_from_text(text, "f", "project")
185
- self.assertEqual(meta["rules"], 2)
186
- self.assertEqual([r["title"] for r in rules], ["- one", "- two"])
187
- self.assertEqual(
188
- [r["condition"] for r in rules],
189
- ["editing backend Python files", "editing backend Python files"],
190
- )
191
-
192
- def test_block_with_single_paragraph_stays_one_rule_with_condition(self):
193
- text = (
194
- "# Title\n"
195
- "\n"
196
- "## Rules\n"
197
- '<important if="running celery beat">\n'
198
- "Rebuild the worker after any Python change.\n"
199
- "</important>\n"
200
- )
201
- rules, _, meta = extract_rules_from_text(text, "f", "project")
202
- self.assertEqual(meta["rules"], 1)
203
- self.assertEqual(rules[0]["condition"], "running celery beat")
204
- self.assertIn("Rebuild the worker", rules[0]["text"])
205
-
206
- def test_bare_rules_have_null_condition(self):
207
- text = "# Title\n\n## Rules\n- one\n- two\n"
208
- rules, _, _ = extract_rules_from_text(text, "f", "project")
209
- self.assertEqual([r["condition"] for r in rules], [None, None])
210
-
211
- def test_tags_excluded_from_rule_text_and_token_cost(self):
212
- text = (
213
- "# Title\n"
214
- "\n"
215
- "## Rules\n"
216
- '<important if="some condition">\n'
217
- "Body text only.\n"
218
- "</important>\n"
219
- )
220
- rules, _, _ = extract_rules_from_text(text, "f", "project")
221
- self.assertEqual(len(rules), 1)
222
- self.assertNotIn("<important", rules[0]["text"])
223
- self.assertNotIn("</important>", rules[0]["text"])
224
- self.assertEqual(rules[0]["chars"], len("Body text only."))
225
-
226
- def test_nested_block_rejected_with_warning_not_flattened(self):
227
- text = (
228
- "# Title\n"
229
- "\n"
230
- "## Rules\n"
231
- '<important if="outer condition">\n'
232
- "Outer prose.\n"
233
- '<important if="inner condition">\n'
234
- "Inner prose.\n"
235
- "</important>\n"
236
- "</important>\n"
237
- )
238
- rules, _, meta = extract_rules_from_text(text, "f", "project")
239
- # Nested block is rejected, not flattened: the whole body -- including
240
- # the literal inner tag lines -- survives as one rule under the outer
241
- # condition, and nothing is silently dropped.
242
- self.assertEqual(meta["rules"], 1)
243
- self.assertEqual(rules[0]["condition"], "outer condition")
244
- self.assertIn("Outer prose.", rules[0]["text"])
245
- self.assertIn('<important if="inner condition">', rules[0]["text"])
246
- self.assertIn("Inner prose.", rules[0]["text"])
247
- self.assertTrue(
248
- any("nested" in w for w in meta["warnings"]),
249
- f"expected a nested-block warning, got: {meta['warnings']}",
250
- )
251
-
252
- def test_stray_closing_tag_kept_as_text_with_warning(self):
253
- text = (
254
- "# Title\n"
255
- "\n"
256
- "## Rules\n"
257
- "Some prose.\n"
258
- "</important>\n"
259
- "More prose.\n"
260
- )
261
- rules, _, meta = extract_rules_from_text(text, "f", "project")
262
- self.assertEqual(meta["rules"], 1)
263
- self.assertIn("</important>", rules[0]["text"])
264
- self.assertTrue(any("stray" in w for w in meta["warnings"]))
265
-
266
- def test_unclosed_block_reverts_to_literal_text_and_does_not_wrap_rest_of_file(self):
267
- # A missing </important> must never silently wrap the rest of the
268
- # file -- including content in a later section, past a heading --
269
- # under a condition nobody closed. The whole thing reverts to plain
270
- # text instead.
271
- text = (
272
- "# Title\n"
273
- "\n"
274
- "## Section One\n"
275
- '<important if="never closed">\n'
276
- "Some prose.\n"
277
- "\n"
278
- "## Section Two\n"
279
- "This must stay bare.\n"
280
- )
281
- rules, _, meta = extract_rules_from_text(text, "f", "project")
282
- self.assertTrue(all(r["condition"] is None for r in rules))
283
- section_two_rule = next(r for r in rules if "must stay bare" in r["text"])
284
- self.assertEqual(section_two_rule["condition"], None)
285
- opening_tag_rule = next(r for r in rules if "Some prose." in r["text"])
286
- self.assertIn('<important if="never closed">', opening_tag_rule["text"])
287
- self.assertTrue(any("never closed" in w for w in meta["warnings"]))
288
-
289
- def test_inline_mention_of_the_tag_in_prose_is_not_recognized(self):
290
- # references/structure.md and rule-model.md mention the tag inline
291
- # in prose -- that must never be treated as a real block boundary.
292
- text = (
293
- "# Title\n"
294
- "\n"
295
- "## Rules\n"
296
- 'An `<important if="condition">` block puts a relevance signal '
297
- "next to the rule.\n"
298
- )
299
- rules, _, meta = extract_rules_from_text(text, "f", "project")
300
- self.assertEqual(len(rules), 1)
301
- self.assertEqual(rules[0]["condition"], None)
302
- self.assertIn('<important if="condition">', rules[0]["text"])
303
- self.assertEqual(meta["warnings"], [])
304
-
305
- def test_file_without_blocks_matches_pre_restructure_output(self):
306
- # Regression: a file with no <important if> blocks at all must emit
307
- # exactly the same rules/rollups/meta as before this feature existed
308
- # (T1), aside from the new `condition: None` key on every rule.
309
- text = (
310
- "# Doc\n"
311
- "\n"
312
- "Some preamble prose.\n"
313
- "\n"
314
- "## Section One\n"
315
- "Intro paragraph.\n"
316
- "```python\n"
317
- "# not a heading\n"
318
- "- not a list item\n"
319
- "```\n"
320
- "More prose after the fence.\n"
321
- "\n"
322
- "## Section Two\n"
323
- "- alpha\n"
324
- "- beta\n"
325
- "- gamma\n"
326
- "\n"
327
- "### Nested\n"
328
- "1. First\n"
329
- " - sub a\n"
330
- " - sub b\n"
331
- "2. Second\n"
332
- )
333
- rules, sections, meta = extract_rules_from_text(text, "f", "project")
334
-
335
- # Every rule carries condition: None, and everything else matches
336
- # the pre-restructure fields exactly.
337
- self.assertTrue(all(r["condition"] is None for r in rules))
338
- stripped = [{k: v for k, v in r.items() if k != "condition"} for r in rules]
339
-
340
- # The first rule is the "# Doc" section itself (heading level 1):
341
- # its body is everything up to "## Section One", and since it is a
342
- # whole, unwrapped section under a heading, its title is the
343
- # heading's own title ("Doc") -- exactly as split_section_into_rules
344
- # behaved before this feature existed.
345
- self.assertEqual(stripped[0]["title"], "Doc")
346
- # Doc(1) + Section One(1) + Section Two's 3 bullets + Nested's
347
- # "1. First" (with sub a/sub b attached) and "2. Second".
348
- self.assertEqual(len(rules), 7)
349
- self.assertEqual(meta["warnings"], [])
350
- # Full fence body text is preserved untouched inside its rule.
351
- fenced_rule = next(r for r in rules if "not a list item" in r["text"])
352
- self.assertIn("# not a heading", fenced_rule["text"])
353
- self.assertIn("More prose after the fence.", fenced_rule["text"])
354
-
355
-
356
- def _load_old_extract_rules_module():
357
- """Load scripts/testdata/old_extract_rules_pre_t1.py.txt -- a frozen
358
- copy of extract_rules.py exactly as it was before T1 (commit
359
- acae673^), captured with `git show acae673^:scripts/extract_rules.py`.
360
- Used as the ground truth for the golden test below: comparing against
361
- hand-written expected values (as the test above does) can only prove
362
- the test author's own assumptions, not real byte-for-byte compatibility
363
- with the pre-T1 splitter."""
364
- import importlib.util
365
- from importlib.machinery import SourceFileLoader
366
-
367
- old_path = Path(__file__).parent / "testdata" / "old_extract_rules_pre_t1.py.txt"
368
- loader = SourceFileLoader("old_extract_rules_pre_t1", str(old_path))
369
- spec = importlib.util.spec_from_file_location("old_extract_rules_pre_t1", old_path, loader=loader)
370
- module = importlib.util.module_from_spec(spec)
371
- loader.exec_module(module)
372
- return module
373
-
374
-
375
- class GoldenNoBlocksRegressionTests(unittest.TestCase):
376
- """T1's mandatory regression: a file with no <important if> blocks must
377
- extract identically to the pre-T1 splitter. Frozen-golden, not
378
- hand-computed -- see _load_old_extract_rules_module()."""
379
-
380
- @classmethod
381
- def setUpClass(cls):
382
- cls.old = _load_old_extract_rules_module()
383
-
384
- def _assert_matches_old(self, text, label="f"):
385
- old_rules, old_sections, old_meta = self.old.extract_rules_from_text(text, label, "project")
386
- new_rules, new_sections, new_meta = extract_rules_from_text(text, label, "project")
387
-
388
- self.assertEqual(len(old_rules), len(new_rules))
389
- for old_rule, new_rule in zip(old_rules, new_rules):
390
- new_stripped = {k: v for k, v in new_rule.items() if k != "condition"}
391
- self.assertEqual(old_rule, new_stripped)
392
- self.assertIsNone(new_rule.get("condition"))
393
-
394
- self.assertEqual(old_sections, new_sections)
395
-
396
- new_meta_stripped = {k: v for k, v in new_meta.items() if k != "warnings"}
397
- self.assertEqual(old_meta, new_meta_stripped)
398
- self.assertEqual(new_meta.get("warnings"), [])
399
-
400
- def test_enriched_fixture_with_every_edge_case(self):
401
- # Real preamble (content before the first heading), a single-bullet
402
- # section (must NOT split), prose before a list, a fenced block
403
- # with heading/list-marker look-alikes, nested list items, an empty
404
- # parent heading, and trailing blank lines.
405
- text = (
406
- "Preamble prose before any heading at all.\n"
407
- "\n"
408
- "# Doc\n"
409
- "\n"
410
- "## Single Bullet\n"
411
- "- only one item here, must stay one rule\n"
412
- "\n"
413
- "## Prose Then List\n"
414
- "Some intro prose before the list starts.\n"
415
- "- alpha\n"
416
- "- beta\n"
417
- "\n"
418
- "## Fenced Lookalikes\n"
419
- "```markdown\n"
420
- "# not a real heading\n"
421
- "- not a real list item\n"
422
- "1. not a real list item either\n"
423
- "```\n"
424
- "\n"
425
- "## Empty Parent\n"
426
- "\n"
427
- "### Real Child\n"
428
- "Content that belongs to the child, not the empty parent.\n"
429
- "\n"
430
- "## Deep Nesting\n"
431
- "1. First\n"
432
- " - sub a\n"
433
- " - sub b\n"
434
- "2. Second\n"
435
- "3. Third\n"
436
- "\n"
437
- "\n"
438
- )
439
- self._assert_matches_old(text)
440
-
441
- def test_real_repo_files_match_the_pre_t1_splitter(self):
442
- # The files this skill actually grades (or documents itself with),
443
- # none of which had -- or, for the two written in T2, still don't
444
- # use as real blocks -- an <important if> container of their own.
445
- repo_root = Path(__file__).parent.parent
446
- candidates = [
447
- repo_root / "SKILL.md",
448
- repo_root / "references" / "rule-model.md",
449
- repo_root / "references" / "structure.md",
450
- repo_root / "scorers" / "adherence.md",
451
- repo_root / "scorers" / "rule-value.md",
452
- ]
453
- checked = 0
454
- for path in candidates:
455
- if not path.is_file():
456
- continue
457
- text = path.read_text(encoding="utf-8", errors="replace")
458
- with self.subTest(file=str(path)):
459
- self._assert_matches_old(text, label=path.name)
460
- checked += 1
461
- self.assertGreater(checked, 0, "no candidate instruction file was found to compare")
462
-
463
-
464
- class OnlyFlagTests(unittest.TestCase):
465
- """`--only` (T6 fix 5): disables auto-discovery so only `--file` paths
466
- are extracted, and errors when given without `--file` -- auto-discovery
467
- is what would otherwise supply something to grade."""
468
-
469
- def _make_home_with_global_claude(self, tmp):
470
- home = Path(tmp) / "home"
471
- (home / ".claude").mkdir(parents=True)
472
- (home / ".claude" / "CLAUDE.md").write_text(
473
- "# Global\n\nSome bare rule.\n", encoding="utf-8"
474
- )
475
- return home
476
-
477
- def test_only_without_file_errors(self):
478
- stderr = io.StringIO()
479
- with self.assertRaises(SystemExit) as cm:
480
- with contextlib.redirect_stderr(stderr):
481
- parse_args(["--out", "/tmp/doctor-md-agents-test-only", "--only"])
482
- self.assertEqual(cm.exception.code, 2)
483
- err = stderr.getvalue()
484
- self.assertIn("--only requires", err)
485
- self.assertNotIn("unrecognized", err)
486
-
487
- def test_only_disables_auto_discovery(self):
488
- with tempfile.TemporaryDirectory() as tmp:
489
- home = self._make_home_with_global_claude(tmp)
490
- explicit = Path(tmp) / "PROJECT.md"
491
- explicit.write_text("# Project\n\nSome bare rule.\n", encoding="utf-8")
492
- out_dir = Path(tmp) / "out"
493
- with mock.patch.dict(os.environ, {"HOME": str(home)}):
494
- main(["--out", str(out_dir), "--only", "--file", str(explicit)])
495
- data = json.loads((out_dir / "rules.json").read_text())
496
- labels = [f["label"] for f in data["files"]]
497
- self.assertEqual(len(labels), 1)
498
- self.assertTrue(any("PROJECT.md" in l for l in labels))
499
-
500
- def test_without_only_default_behavior_unchanged(self):
501
- # Characterization test: without --only, auto-discovery still runs
502
- # alongside --file, exactly as before this flag existed.
503
- with tempfile.TemporaryDirectory() as tmp:
504
- home = self._make_home_with_global_claude(tmp)
505
- explicit = Path(tmp) / "PROJECT.md"
506
- explicit.write_text("# Project\n\nSome bare rule.\n", encoding="utf-8")
507
- out_dir = Path(tmp) / "out"
508
- with mock.patch.dict(os.environ, {"HOME": str(home)}):
509
- main(["--out", str(out_dir), "--file", str(explicit)])
510
- data = json.loads((out_dir / "rules.json").read_text())
511
- labels = [f["label"] for f in data["files"]]
512
- self.assertEqual(len(labels), 2)
513
-
514
-
515
- class FileIOTests(unittest.TestCase):
516
- def test_extract_rules_from_file_on_disk(self):
517
- with tempfile.TemporaryDirectory() as tmp:
518
- path = Path(tmp) / "CLAUDE.md"
519
- path.write_text(
520
- "# Doc\n\n## Section\n- one\n- two\n- three\n", encoding="utf-8"
521
- )
522
- text = path.read_text(encoding="utf-8")
523
- rules, sections, meta = extract_rules_from_text(text, "test:CLAUDE.md", "project")
524
- self.assertEqual(meta["rules"], 3)
525
- self.assertEqual(len(sections), 1)
526
- self.assertEqual(sections[0]["rule_ids"], ["test:CLAUDE.md#4", "test:CLAUDE.md#5", "test:CLAUDE.md#6"])
527
-
528
-
529
- if __name__ == "__main__":
530
- unittest.main()