mindsystem-cc 3.19.0 → 3.21.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 (83) hide show
  1. package/README.md +5 -6
  2. package/agents/ms-designer.md +5 -2
  3. package/agents/ms-mockup-designer.md +1 -1
  4. package/agents/ms-plan-writer.md +8 -1
  5. package/agents/ms-product-researcher.md +69 -0
  6. package/agents/ms-research-synthesizer.md +1 -1
  7. package/agents/ms-researcher.md +8 -8
  8. package/agents/ms-roadmapper.md +9 -13
  9. package/bin/install.js +68 -5
  10. package/commands/ms/add-phase.md +30 -18
  11. package/commands/ms/adhoc.md +1 -1
  12. package/commands/ms/audit-milestone.md +12 -12
  13. package/commands/ms/complete-milestone.md +25 -22
  14. package/commands/ms/config.md +202 -0
  15. package/commands/ms/design-phase.md +34 -29
  16. package/commands/ms/discuss-phase.md +26 -22
  17. package/commands/ms/doctor.md +22 -202
  18. package/commands/ms/execute-phase.md +18 -7
  19. package/commands/ms/help.md +46 -39
  20. package/commands/ms/insert-phase.md +29 -17
  21. package/commands/ms/new-milestone.md +42 -19
  22. package/commands/ms/new-project.md +88 -103
  23. package/commands/ms/plan-milestone-gaps.md +4 -5
  24. package/commands/ms/plan-phase.md +5 -3
  25. package/commands/ms/progress.md +2 -4
  26. package/commands/ms/research-phase.md +7 -12
  27. package/commands/ms/research-project.md +12 -12
  28. package/mindsystem/references/continuation-format.md +3 -3
  29. package/mindsystem/references/plan-format.md +11 -1
  30. package/mindsystem/references/principles.md +1 -1
  31. package/mindsystem/references/questioning.md +50 -8
  32. package/mindsystem/references/routing/audit-result-routing.md +12 -11
  33. package/mindsystem/references/routing/between-milestones-routing.md +2 -2
  34. package/mindsystem/references/routing/milestone-complete-routing.md +1 -1
  35. package/mindsystem/references/routing/next-phase-routing.md +4 -2
  36. package/mindsystem/templates/context.md +7 -6
  37. package/mindsystem/templates/milestone-archive.md +5 -5
  38. package/mindsystem/templates/milestone-context.md +1 -1
  39. package/mindsystem/templates/milestone.md +9 -9
  40. package/mindsystem/templates/project.md +70 -64
  41. package/mindsystem/templates/research-subagent-prompt.md +3 -3
  42. package/mindsystem/templates/roadmap-milestone.md +14 -14
  43. package/mindsystem/templates/roadmap.md +9 -7
  44. package/mindsystem/workflows/adhoc.md +1 -1
  45. package/mindsystem/workflows/complete-milestone.md +66 -107
  46. package/mindsystem/workflows/discuss-phase.md +137 -65
  47. package/mindsystem/workflows/doctor-fixes.md +273 -0
  48. package/mindsystem/workflows/execute-phase.md +7 -3
  49. package/mindsystem/workflows/execute-plan.md +6 -5
  50. package/mindsystem/workflows/map-codebase.md +2 -2
  51. package/mindsystem/workflows/mockup-generation.md +1 -1
  52. package/mindsystem/workflows/plan-phase.md +28 -3
  53. package/mindsystem/workflows/transition.md +20 -25
  54. package/mindsystem/workflows/verify-work.md +1 -1
  55. package/package.json +1 -1
  56. package/scripts/__pycache__/ms-tools.cpython-314.pyc +0 -0
  57. package/scripts/__pycache__/test_ms_tools.cpython-314-pytest-9.0.2.pyc +0 -0
  58. package/scripts/fixtures/scan-context/.planning/ROADMAP.md +16 -0
  59. package/scripts/fixtures/scan-context/.planning/adhoc/20260220-fix-token-SUMMARY.md +12 -0
  60. package/scripts/fixtures/scan-context/.planning/config.json +3 -0
  61. package/scripts/fixtures/scan-context/.planning/debug/resolved/token-bug.md +11 -0
  62. package/scripts/fixtures/scan-context/.planning/knowledge/auth.md +11 -0
  63. package/scripts/fixtures/scan-context/.planning/phases/02-infra/02-1-SUMMARY.md +20 -0
  64. package/scripts/fixtures/scan-context/.planning/phases/04-setup/04-1-SUMMARY.md +21 -0
  65. package/scripts/fixtures/scan-context/.planning/phases/05-auth/05-1-SUMMARY.md +28 -0
  66. package/scripts/fixtures/scan-context/.planning/todos/done/setup-db.md +10 -0
  67. package/scripts/fixtures/scan-context/.planning/todos/pending/add-logout.md +10 -0
  68. package/scripts/fixtures/scan-context/expected-output.json +257 -0
  69. package/scripts/ms-tools.py +2139 -0
  70. package/scripts/test_ms_tools.py +836 -0
  71. package/commands/ms/list-phase-assumptions.md +0 -56
  72. package/mindsystem/workflows/list-phase-assumptions.md +0 -178
  73. package/scripts/__pycache__/compare_mockups.cpython-314.pyc +0 -0
  74. package/scripts/archive-milestone-files.sh +0 -68
  75. package/scripts/archive-milestone-phases.sh +0 -138
  76. package/scripts/doctor-scan.sh +0 -379
  77. package/scripts/gather-milestone-stats.sh +0 -179
  78. package/scripts/generate-adhoc-patch.sh +0 -79
  79. package/scripts/generate-phase-patch.sh +0 -169
  80. package/scripts/scan-artifact-subsystems.sh +0 -55
  81. package/scripts/scan-planning-context.py +0 -839
  82. package/scripts/update-state.sh +0 -59
  83. package/scripts/validate-execution-order.sh +0 -104
@@ -0,0 +1,836 @@
1
+ """Tests for ms-tools.py pure logic layer and scan-planning-context integration."""
2
+
3
+ import importlib.util
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Import ms-tools.py (hyphenated filename requires importlib)
11
+ # ---------------------------------------------------------------------------
12
+
13
+ _spec = importlib.util.spec_from_file_location(
14
+ "ms_tools", Path(__file__).parent / "ms-tools.py"
15
+ )
16
+ _mod = importlib.util.module_from_spec(_spec)
17
+ _spec.loader.exec_module(_mod)
18
+
19
+ slugify = _mod.slugify
20
+ normalize_phase = _mod.normalize_phase
21
+ in_range = _mod.in_range
22
+ parse_frontmatter = _mod.parse_frontmatter
23
+ build_exclude_pathspecs = _mod.build_exclude_pathspecs
24
+ PATCH_EXCLUSIONS = _mod.PATCH_EXCLUSIONS
25
+ _extract_phase_number = _mod._extract_phase_number
26
+ _is_adjacent_phase = _mod._is_adjacent_phase
27
+ _score_summary = _mod._score_summary
28
+ _resolve_transitive_requires = _mod._resolve_transitive_requires
29
+ _aggregate_from_summaries = _mod._aggregate_from_summaries
30
+ _has_readiness_section = _mod._has_readiness_section
31
+ _scan_summaries = _mod._scan_summaries
32
+ _scan_debug_docs = _mod._scan_debug_docs
33
+ _scan_adhoc_summaries = _mod._scan_adhoc_summaries
34
+ _scan_todos = _mod._scan_todos
35
+ _scan_knowledge_files = _mod._scan_knowledge_files
36
+ _detect_versioned_milestone_dirs = _mod._detect_versioned_milestone_dirs
37
+ _parse_milestone_name_mapping = _mod._parse_milestone_name_mapping
38
+ _SafeEncoder = _mod._SafeEncoder
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Fixtures
42
+ # ---------------------------------------------------------------------------
43
+
44
+ FIXTURE_PLANNING = Path(__file__).parent / "fixtures" / "scan-context" / ".planning"
45
+
46
+ # Set to True, run once, review expected-output.json, then set back to False.
47
+ REGENERATE_GOLDEN = False
48
+
49
+
50
+ # ===================================================================
51
+ # Part 1: Pure Function Unit Tests
52
+ # ===================================================================
53
+
54
+
55
+ class TestSlugify:
56
+ def test_basic_name(self):
57
+ assert slugify("Push Notifications") == "push-notifications"
58
+
59
+ def test_ampersand_stripped(self):
60
+ assert slugify("Auth & Payments") == "auth-payments"
61
+
62
+ def test_uppercase(self):
63
+ assert slugify("MVP") == "mvp"
64
+
65
+ def test_underscores(self):
66
+ assert slugify("push_notifications") == "push-notifications"
67
+
68
+ def test_consecutive_hyphens(self):
69
+ assert slugify("auth -- payments") == "auth-payments"
70
+
71
+ def test_leading_trailing_hyphens(self):
72
+ assert slugify("-hello-world-") == "hello-world"
73
+
74
+ def test_special_characters(self):
75
+ assert slugify("v2.0 New Features!") == "v20-new-features"
76
+
77
+ def test_empty_string(self):
78
+ assert slugify("") == ""
79
+
80
+ def test_already_slug(self):
81
+ assert slugify("push-notifications") == "push-notifications"
82
+
83
+
84
+ class TestNormalizePhase:
85
+ def test_single_digit(self):
86
+ assert normalize_phase("5") == "05"
87
+
88
+ def test_decimal(self):
89
+ assert normalize_phase("2.1") == "02.1"
90
+
91
+ def test_already_padded(self):
92
+ assert normalize_phase("02.1") == "02.1"
93
+
94
+ def test_empty_string(self):
95
+ assert normalize_phase("") == ""
96
+
97
+ def test_non_numeric(self):
98
+ assert normalize_phase("abc") == "abc"
99
+
100
+ def test_leading_zero_large(self):
101
+ assert normalize_phase("007") == "07"
102
+
103
+ def test_two_digit(self):
104
+ assert normalize_phase("12") == "12"
105
+
106
+ def test_zero(self):
107
+ assert normalize_phase("0") == "00"
108
+
109
+ def test_already_padded_integer(self):
110
+ assert normalize_phase("05") == "05"
111
+
112
+
113
+ class TestInRange:
114
+ def test_inside(self):
115
+ assert in_range("03", 1, 5) is True
116
+
117
+ def test_at_start_boundary(self):
118
+ assert in_range("01", 1, 5) is True
119
+
120
+ def test_at_end_boundary(self):
121
+ assert in_range("05", 1, 5) is True
122
+
123
+ def test_decimal_inside(self):
124
+ assert in_range("05.1", 3, 5) is True
125
+
126
+ def test_decimal_outside(self):
127
+ assert in_range("06.0", 3, 5) is False
128
+
129
+ def test_below_range(self):
130
+ assert in_range("00", 1, 5) is False
131
+
132
+ def test_above_range(self):
133
+ assert in_range("10", 1, 5) is False
134
+
135
+ def test_non_numeric(self):
136
+ assert in_range("abc", 1, 5) is False
137
+
138
+ def test_decimal_at_upper_edge(self):
139
+ # 5.999 is within range end+0.999
140
+ assert in_range("05.9", 3, 5) is True
141
+
142
+
143
+ class TestExtractPhaseNumber:
144
+ def test_phase_with_name(self):
145
+ assert _extract_phase_number("05-auth") == 5
146
+
147
+ def test_decimal_phase(self):
148
+ assert _extract_phase_number("02.1-setup") == 2
149
+
150
+ def test_empty_string(self):
151
+ assert _extract_phase_number("") is None
152
+
153
+ def test_non_numeric(self):
154
+ assert _extract_phase_number("auth") is None
155
+
156
+ def test_bare_number(self):
157
+ assert _extract_phase_number("07") == 7
158
+
159
+
160
+ class TestIsAdjacentPhase:
161
+ def test_n_minus_1(self):
162
+ assert _is_adjacent_phase(6, 5) is True
163
+
164
+ def test_n_minus_2(self):
165
+ assert _is_adjacent_phase(6, 4) is True
166
+
167
+ def test_equal(self):
168
+ assert _is_adjacent_phase(6, 6) is False
169
+
170
+ def test_n_minus_3(self):
171
+ assert _is_adjacent_phase(6, 3) is False
172
+
173
+ def test_n_plus_1(self):
174
+ assert _is_adjacent_phase(6, 7) is False
175
+
176
+
177
+ class TestScoreSummary:
178
+ """Test _score_summary relevance scoring."""
179
+
180
+ def test_high_via_affects(self):
181
+ fm = {"affects": ["06-ui"], "subsystem": "", "requires": [], "tags": [], "phase": "05-auth"}
182
+ score, reasons = _score_summary(fm, "06", 6, [], [])
183
+ assert score == "HIGH"
184
+ assert any("affects" in r for r in reasons)
185
+
186
+ def test_high_via_subsystem(self):
187
+ fm = {"affects": [], "subsystem": "auth", "requires": [], "tags": [], "phase": "03"}
188
+ score, reasons = _score_summary(fm, "06", 6, ["auth"], [])
189
+ assert score == "HIGH"
190
+ assert any("subsystem" in r for r in reasons)
191
+
192
+ def test_high_via_requires(self):
193
+ fm = {"affects": [], "subsystem": "", "requires": [{"phase": "06-ui"}], "tags": [], "phase": "03"}
194
+ score, reasons = _score_summary(fm, "06", 6, [], [])
195
+ assert score == "HIGH"
196
+ assert any("requires" in r for r in reasons)
197
+
198
+ def test_medium_via_tags(self):
199
+ fm = {"affects": [], "subsystem": "", "requires": [], "tags": ["jwt", "config"], "phase": "01"}
200
+ score, reasons = _score_summary(fm, "06", 6, [], ["jwt"])
201
+ assert score == "MEDIUM"
202
+ assert any("tags" in r for r in reasons)
203
+
204
+ def test_medium_via_adjacent(self):
205
+ fm = {"affects": [], "subsystem": "", "requires": [], "tags": [], "phase": "05-auth"}
206
+ score, reasons = _score_summary(fm, "06", 6, [], [])
207
+ assert score == "MEDIUM"
208
+ assert any("adjacent" in r for r in reasons)
209
+
210
+ def test_low_default(self):
211
+ fm = {"affects": [], "subsystem": "database", "requires": [], "tags": ["postgres"], "phase": "02-infra"}
212
+ score, reasons = _score_summary(fm, "06", 6, [], [])
213
+ assert score == "LOW"
214
+
215
+ def test_string_affects_coercion(self):
216
+ """affects as string instead of list."""
217
+ fm = {"affects": "06-ui", "subsystem": "", "requires": [], "tags": [], "phase": "05"}
218
+ score, _ = _score_summary(fm, "06", 6, [], [])
219
+ assert score == "HIGH"
220
+
221
+ def test_none_affects(self):
222
+ fm = {"affects": None, "subsystem": "", "requires": None, "tags": None, "phase": "01"}
223
+ score, _ = _score_summary(fm, "06", 6, [], [])
224
+ assert score == "LOW"
225
+
226
+ def test_high_trumps_medium(self):
227
+ """When both HIGH and MEDIUM signals present, result is HIGH."""
228
+ fm = {"affects": ["06-ui"], "subsystem": "", "requires": [], "tags": ["jwt"], "phase": "05-auth"}
229
+ score, reasons = _score_summary(fm, "06", 6, [], ["jwt"])
230
+ assert score == "HIGH"
231
+ # Both reasons should be present
232
+ assert any("affects" in r for r in reasons)
233
+ assert any("tags" in r for r in reasons)
234
+
235
+ def test_requires_string_in_list(self):
236
+ """requires as list of strings instead of list of dicts."""
237
+ fm = {"affects": [], "subsystem": "", "requires": ["06-ui"], "tags": [], "phase": "03"}
238
+ score, reasons = _score_summary(fm, "06", 6, [], [])
239
+ assert score == "HIGH"
240
+
241
+
242
+ class TestResolveTransitiveRequires:
243
+ def test_direct_affects(self):
244
+ summaries = [
245
+ {"frontmatter": {"phase": "05-auth", "affects": ["06-ui"], "requires": []}},
246
+ ]
247
+ result = _resolve_transitive_requires(summaries, "06")
248
+ assert "05-auth" in result
249
+
250
+ def test_one_hop_chain(self):
251
+ """05-auth affects 06, and requires 04-setup -> 04-setup should be in chain."""
252
+ summaries = [
253
+ {"frontmatter": {"phase": "05-auth", "affects": ["06-ui"], "requires": ["04-setup"]}},
254
+ {"frontmatter": {"phase": "04-setup", "affects": [], "requires": []}},
255
+ ]
256
+ result = _resolve_transitive_requires(summaries, "06")
257
+ assert "05-auth" in result
258
+ assert "04-setup" in result
259
+
260
+ def test_no_matches(self):
261
+ summaries = [
262
+ {"frontmatter": {"phase": "02-infra", "affects": [], "requires": []}},
263
+ ]
264
+ result = _resolve_transitive_requires(summaries, "06")
265
+ assert len(result) == 0
266
+
267
+ def test_string_affects_coercion(self):
268
+ summaries = [
269
+ {"frontmatter": {"phase": "05-auth", "affects": "06-ui", "requires": []}},
270
+ ]
271
+ result = _resolve_transitive_requires(summaries, "06")
272
+ assert "05-auth" in result
273
+
274
+ def test_dict_requires(self):
275
+ summaries = [
276
+ {"frontmatter": {"phase": "05-auth", "affects": ["06-ui"], "requires": [{"phase": "04-setup"}]}},
277
+ ]
278
+ result = _resolve_transitive_requires(summaries, "06")
279
+ assert "04-setup" in result
280
+
281
+
282
+ class TestAggregateFromSummaries:
283
+ def test_skips_low(self):
284
+ summaries = [
285
+ {"relevance": "LOW", "frontmatter": {
286
+ "tech-stack": {"added": ["redis"], "patterns": []},
287
+ "patterns-established": [], "key-files": {}, "key-decisions": [],
288
+ }},
289
+ ]
290
+ result = _aggregate_from_summaries(summaries)
291
+ assert result["tech_stack_added"] == []
292
+
293
+ def test_collects_high_and_medium(self):
294
+ summaries = [
295
+ {"relevance": "HIGH", "frontmatter": {
296
+ "tech-stack": {"added": ["jose"], "patterns": ["jwt-auth"]},
297
+ "patterns-established": ["Token rotation"],
298
+ "key-files": {"created": ["src/auth.ts"], "modified": ["src/config.ts"]},
299
+ "key-decisions": ["Use JWT"],
300
+ }},
301
+ {"relevance": "MEDIUM", "frontmatter": {
302
+ "tech-stack": {"added": ["dotenv"], "patterns": []},
303
+ "patterns-established": [],
304
+ "key-files": {"created": ["src/config.ts"], "modified": []},
305
+ "key-decisions": ["Use dotenv"],
306
+ }},
307
+ ]
308
+ result = _aggregate_from_summaries(summaries)
309
+ assert "jose" in result["tech_stack_added"]
310
+ assert "dotenv" in result["tech_stack_added"]
311
+ assert "Token rotation" in result["patterns_established"]
312
+ assert "src/auth.ts" in result["key_files_created"]
313
+ assert "Use JWT" in result["key_decisions"]
314
+ assert "Use dotenv" in result["key_decisions"]
315
+
316
+ def test_deduplication(self):
317
+ summaries = [
318
+ {"relevance": "HIGH", "frontmatter": {
319
+ "tech-stack": {"added": ["jose"], "patterns": []},
320
+ "patterns-established": ["P1"],
321
+ "key-files": {"created": ["f.ts"], "modified": []},
322
+ "key-decisions": ["D1"],
323
+ }},
324
+ {"relevance": "HIGH", "frontmatter": {
325
+ "tech-stack": {"added": ["jose"], "patterns": []},
326
+ "patterns-established": ["P1"],
327
+ "key-files": {"created": ["f.ts"], "modified": []},
328
+ "key-decisions": ["D1"],
329
+ }},
330
+ ]
331
+ result = _aggregate_from_summaries(summaries)
332
+ assert result["tech_stack_added"] == ["jose"]
333
+ assert result["patterns_established"] == ["P1"]
334
+ assert result["key_files_created"] == ["f.ts"]
335
+ assert result["key_decisions"] == ["D1"]
336
+
337
+ def test_string_coercion(self):
338
+ """String values instead of lists should be coerced."""
339
+ summaries = [
340
+ {"relevance": "HIGH", "frontmatter": {
341
+ "tech-stack": {"added": "single-lib", "patterns": "single-pattern"},
342
+ "patterns-established": "single-established",
343
+ "key-files": {"created": "single-file.ts", "modified": "mod-file.ts"},
344
+ "key-decisions": "single-decision",
345
+ }},
346
+ ]
347
+ result = _aggregate_from_summaries(summaries)
348
+ assert "single-lib" in result["tech_stack_added"]
349
+ assert "single-pattern" in result["patterns_established"]
350
+ assert "single-established" in result["patterns_established"]
351
+ assert "single-file.ts" in result["key_files_created"]
352
+ assert "mod-file.ts" in result["key_files_modified"]
353
+ assert "single-decision" in result["key_decisions"]
354
+
355
+ def test_none_fields(self):
356
+ """None values for optional fields should not crash."""
357
+ summaries = [
358
+ {"relevance": "HIGH", "frontmatter": {
359
+ "tech-stack": None,
360
+ "patterns-established": None,
361
+ "key-files": None,
362
+ "key-decisions": None,
363
+ }},
364
+ ]
365
+ result = _aggregate_from_summaries(summaries)
366
+ assert result["tech_stack_added"] == []
367
+ assert result["patterns_established"] == []
368
+ assert result["key_files_created"] == []
369
+ assert result["key_decisions"] == []
370
+
371
+
372
+ class TestBuildExcludePathspecs:
373
+ def test_all_start_with_colon_bang(self):
374
+ specs = build_exclude_pathspecs()
375
+ for spec in specs:
376
+ assert spec.startswith(":!"), f"Expected ':!' prefix, got: {spec}"
377
+
378
+ def test_count_matches_exclusions(self):
379
+ specs = build_exclude_pathspecs()
380
+ assert len(specs) == len(PATCH_EXCLUSIONS)
381
+
382
+
383
+ class TestParseFrontmatter:
384
+ def test_valid_yaml(self, tmp_path):
385
+ f = tmp_path / "test.md"
386
+ f.write_text("---\ntitle: Hello\ntags: [a, b]\n---\n\n# Content\n")
387
+ result = parse_frontmatter(f)
388
+ assert result == {"title": "Hello", "tags": ["a", "b"]}
389
+
390
+ def test_no_frontmatter(self, tmp_path):
391
+ f = tmp_path / "test.md"
392
+ f.write_text("# Just a heading\n\nSome content.\n")
393
+ assert parse_frontmatter(f) is None
394
+
395
+ def test_malformed_yaml(self, tmp_path):
396
+ f = tmp_path / "test.md"
397
+ f.write_text("---\n: invalid: yaml: [[\n---\n\nContent\n")
398
+ assert parse_frontmatter(f) is None
399
+
400
+ def test_empty_file(self, tmp_path):
401
+ f = tmp_path / "test.md"
402
+ f.write_text("")
403
+ assert parse_frontmatter(f) is None
404
+
405
+ def test_date_value(self, tmp_path):
406
+ f = tmp_path / "test.md"
407
+ f.write_text("---\ndate: 2026-01-15\n---\n\nContent\n")
408
+ result = parse_frontmatter(f)
409
+ assert result is not None
410
+ # YAML parses bare dates as datetime.date objects
411
+ import datetime
412
+ assert result["date"] == datetime.date(2026, 1, 15)
413
+
414
+ def test_list_value(self, tmp_path):
415
+ f = tmp_path / "test.md"
416
+ f.write_text("---\nitems:\n - one\n - two\n - three\n---\n\nContent\n")
417
+ result = parse_frontmatter(f)
418
+ assert result == {"items": ["one", "two", "three"]}
419
+
420
+ def test_empty_frontmatter(self, tmp_path):
421
+ """Empty frontmatter (no content between ---) doesn't match the regex."""
422
+ f = tmp_path / "test.md"
423
+ f.write_text("---\n---\n\nContent\n")
424
+ assert parse_frontmatter(f) is None
425
+
426
+ def test_empty_frontmatter_with_newline(self, tmp_path):
427
+ """Frontmatter with only a newline between --- returns empty dict."""
428
+ f = tmp_path / "test.md"
429
+ f.write_text("---\n\n---\n\nContent\n")
430
+ result = parse_frontmatter(f)
431
+ assert result == {}
432
+
433
+ def test_nonexistent_file(self, tmp_path):
434
+ f = tmp_path / "nonexistent.md"
435
+ assert parse_frontmatter(f) is None
436
+
437
+
438
+ class TestHasReadinessSection:
439
+ def test_present_with_content(self, tmp_path):
440
+ f = tmp_path / "test.md"
441
+ f.write_text("---\nphase: '05'\n---\n\n## Next Phase Readiness\n\n- Need auth provider\n- Token refresh missing\n")
442
+ assert _has_readiness_section(f) is True
443
+
444
+ def test_empty_section(self, tmp_path):
445
+ f = tmp_path / "test.md"
446
+ f.write_text("---\nphase: '05'\n---\n\n## Next Phase Readiness\n\n## Another Section\n")
447
+ assert _has_readiness_section(f) is False
448
+
449
+ def test_absent(self, tmp_path):
450
+ f = tmp_path / "test.md"
451
+ f.write_text("---\nphase: '05'\n---\n\n## Summary\n\nSome content.\n")
452
+ assert _has_readiness_section(f) is False
453
+
454
+ def test_nonexistent_file(self, tmp_path):
455
+ f = tmp_path / "nonexistent.md"
456
+ assert _has_readiness_section(f) is False
457
+
458
+
459
+ # ===================================================================
460
+ # Part 2: Golden-File Integration Test
461
+ # ===================================================================
462
+
463
+
464
+ def _build_scan_output(planning: Path) -> dict:
465
+ """Build the same output dict that cmd_scan_planning_context produces."""
466
+ target_phase = "06"
467
+ target_num = 6
468
+ subsystems = ["auth"]
469
+ keywords = ["jwt", "ui"]
470
+ parse_errors: list[dict] = []
471
+
472
+ summaries, summaries_src = _scan_summaries(
473
+ planning, target_phase, target_num, subsystems, keywords, parse_errors
474
+ )
475
+ debug_learnings, debug_src = _scan_debug_docs(planning, parse_errors)
476
+ adhoc_learnings, adhoc_src = _scan_adhoc_summaries(planning, parse_errors)
477
+ completed_todos, completed_src = _scan_todos(planning, "done", parse_errors)
478
+ pending_todos, pending_src = _scan_todos(planning, "pending", parse_errors)
479
+ knowledge_files, knowledge_src = _scan_knowledge_files(planning, subsystems)
480
+
481
+ aggregated = _aggregate_from_summaries(summaries)
482
+
483
+ return {
484
+ "success": True,
485
+ "target": {
486
+ "phase": target_phase,
487
+ "phase_name": "",
488
+ "subsystems": subsystems,
489
+ "keywords": keywords,
490
+ },
491
+ "sources": {
492
+ "summaries": summaries_src,
493
+ "debug_docs": debug_src,
494
+ "adhoc_summaries": adhoc_src,
495
+ "completed_todos": completed_src,
496
+ "pending_todos": pending_src,
497
+ "knowledge_files": knowledge_src,
498
+ "parse_errors": parse_errors,
499
+ },
500
+ "summaries": summaries,
501
+ "debug_learnings": debug_learnings,
502
+ "adhoc_learnings": adhoc_learnings,
503
+ "completed_todos": completed_todos,
504
+ "pending_todos": pending_todos,
505
+ "knowledge_files": knowledge_files,
506
+ "aggregated": aggregated,
507
+ }
508
+
509
+
510
+ def _normalize_paths(obj, base: Path):
511
+ """Recursively replace absolute paths with relative paths for stable comparison."""
512
+ base_str = str(base)
513
+ if isinstance(obj, str):
514
+ return obj.replace(base_str, "<FIXTURE>")
515
+ if isinstance(obj, list):
516
+ return [_normalize_paths(item, base) for item in obj]
517
+ if isinstance(obj, dict):
518
+ return {k: _normalize_paths(v, base) for k, v in obj.items()}
519
+ return obj
520
+
521
+
522
+ GOLDEN_FILE = Path(__file__).parent / "fixtures" / "scan-context" / "expected-output.json"
523
+
524
+
525
+ class TestGoldenFile:
526
+ """Full JSON comparison of scan-planning-context output against golden file."""
527
+
528
+ def test_golden_file(self):
529
+ output = _build_scan_output(FIXTURE_PLANNING)
530
+ normalized = _normalize_paths(output, FIXTURE_PLANNING.parent)
531
+
532
+ if REGENERATE_GOLDEN:
533
+ GOLDEN_FILE.write_text(
534
+ json.dumps(normalized, indent=2, cls=_SafeEncoder) + "\n",
535
+ encoding="utf-8",
536
+ )
537
+ pytest.skip("Golden file regenerated — review and set REGENERATE_GOLDEN = False")
538
+
539
+ expected = json.loads(GOLDEN_FILE.read_text(encoding="utf-8"))
540
+ assert normalized == expected, (
541
+ "Output differs from golden file. "
542
+ "Set REGENERATE_GOLDEN = True and re-run to update."
543
+ )
544
+
545
+
546
+ class TestScanIntegrationTargeted:
547
+ """Targeted assertions against fixture data."""
548
+
549
+ def test_summary_relevance_scores(self):
550
+ parse_errors: list[dict] = []
551
+ summaries, _ = _scan_summaries(
552
+ FIXTURE_PLANNING, "06", 6, ["auth"], ["jwt", "ui"], parse_errors
553
+ )
554
+ by_phase = {s["frontmatter"]["phase"]: s for s in summaries}
555
+
556
+ assert by_phase["02-infra"]["relevance"] == "LOW"
557
+ assert by_phase["05-auth"]["relevance"] == "HIGH"
558
+ # 04-setup: upgraded from MEDIUM to HIGH via transitive requires
559
+ assert by_phase["04-setup"]["relevance"] == "HIGH"
560
+
561
+ def test_transitive_requires_upgrade(self):
562
+ """04-setup gets upgraded to HIGH because 05-auth affects 06 and requires 04-setup."""
563
+ parse_errors: list[dict] = []
564
+ summaries, _ = _scan_summaries(
565
+ FIXTURE_PLANNING, "06", 6, ["auth"], ["jwt", "ui"], parse_errors
566
+ )
567
+ setup = next(s for s in summaries if s["frontmatter"]["phase"] == "04-setup")
568
+ assert setup["relevance"] == "HIGH"
569
+ assert any("transitive" in r for r in setup["match_reasons"])
570
+
571
+ def test_aggregated_includes_high_and_medium(self):
572
+ output = _build_scan_output(FIXTURE_PLANNING)
573
+ agg = output["aggregated"]
574
+
575
+ # From 05-auth (HIGH) and 04-setup (upgraded to HIGH)
576
+ assert "jose" in agg["tech_stack_added"]
577
+ assert "bcrypt" in agg["tech_stack_added"]
578
+ assert "dotenv" in agg["tech_stack_added"]
579
+
580
+ # 02-infra is LOW, so postgres should NOT be in aggregated
581
+ assert "postgres" not in agg["tech_stack_added"]
582
+
583
+ def test_debug_learnings_collected(self):
584
+ output = _build_scan_output(FIXTURE_PLANNING)
585
+ assert len(output["debug_learnings"]) == 1
586
+ debug = output["debug_learnings"][0]
587
+ assert debug["subsystem"] == "auth"
588
+ assert "clock skew" in debug["root_cause"].lower()
589
+
590
+ def test_adhoc_learnings_collected(self):
591
+ output = _build_scan_output(FIXTURE_PLANNING)
592
+ assert len(output["adhoc_learnings"]) == 1
593
+ adhoc = output["adhoc_learnings"][0]
594
+ assert adhoc["subsystem"] == "auth"
595
+ assert len(adhoc["learnings"]) == 2
596
+
597
+ def test_pending_todos_collected(self):
598
+ output = _build_scan_output(FIXTURE_PLANNING)
599
+ assert len(output["pending_todos"]) == 1
600
+ assert output["pending_todos"][0]["title"] == "Add logout endpoint"
601
+
602
+ def test_completed_todos_collected(self):
603
+ output = _build_scan_output(FIXTURE_PLANNING)
604
+ assert len(output["completed_todos"]) == 1
605
+ assert output["completed_todos"][0]["title"] == "Set up database migrations"
606
+
607
+ def test_knowledge_files_matched(self):
608
+ output = _build_scan_output(FIXTURE_PLANNING)
609
+ knowledge = output["knowledge_files"]
610
+ assert len(knowledge) == 1
611
+ assert knowledge[0]["subsystem"] == "auth"
612
+ assert knowledge[0]["matched"] is True
613
+
614
+ def test_readiness_warnings_on_05_auth(self):
615
+ parse_errors: list[dict] = []
616
+ summaries, _ = _scan_summaries(
617
+ FIXTURE_PLANNING, "06", 6, ["auth"], ["jwt", "ui"], parse_errors
618
+ )
619
+ auth_summary = next(s for s in summaries if s["frontmatter"]["phase"] == "05-auth")
620
+ assert auth_summary["has_readiness_warnings"] is True
621
+
622
+ def test_no_parse_errors(self):
623
+ output = _build_scan_output(FIXTURE_PLANNING)
624
+ assert output["sources"]["parse_errors"] == []
625
+
626
+
627
+ # ===================================================================
628
+ # Part 3: Milestone Naming Detection Tests
629
+ # ===================================================================
630
+
631
+
632
+ class TestDetectVersionedMilestoneDirs:
633
+ """Test _detect_versioned_milestone_dirs detection logic."""
634
+
635
+ def test_standard_dirs(self, tmp_path):
636
+ """v0.1/, v0.2/ with .md files detected as standard."""
637
+ planning = tmp_path / ".planning"
638
+ ms = planning / "milestones"
639
+ (ms / "v0.1").mkdir(parents=True)
640
+ (ms / "v0.1" / "ROADMAP.md").write_text("# Roadmap")
641
+ (ms / "v0.2").mkdir(parents=True)
642
+ (ms / "v0.2" / "ROADMAP.md").write_text("# Roadmap")
643
+
644
+ result = _detect_versioned_milestone_dirs(planning)
645
+ assert len(result) == 2
646
+ assert result[0]["version"] == "v0.1"
647
+ assert result[0]["type"] == "standard"
648
+ assert result[0]["sub"] is None
649
+ assert result[1]["version"] == "v0.2"
650
+ assert result[1]["type"] == "standard"
651
+
652
+ def test_nested_dirs(self, tmp_path):
653
+ """v2.0.0/ with sub-dirs and no .md files detected as nested."""
654
+ planning = tmp_path / ".planning"
655
+ ms = planning / "milestones"
656
+ v200 = ms / "v2.0.0"
657
+ (v200 / "quests").mkdir(parents=True)
658
+ (v200 / "quests" / "ROADMAP.md").write_text("# Roadmap")
659
+ (v200 / "sanctuary").mkdir(parents=True)
660
+ (v200 / "sanctuary" / "ROADMAP.md").write_text("# Roadmap")
661
+
662
+ result = _detect_versioned_milestone_dirs(planning)
663
+ assert len(result) == 2
664
+ assert result[0]["version"] == "v2.0.0"
665
+ assert result[0]["sub"] == "quests"
666
+ assert result[0]["type"] == "nested"
667
+ assert result[1]["sub"] == "sanctuary"
668
+ assert result[1]["type"] == "nested"
669
+
670
+ def test_mixed_standard_and_nested(self, tmp_path):
671
+ """v2.0.0/quests/ (nested) + v2.2.0/ (standard) both detected."""
672
+ planning = tmp_path / ".planning"
673
+ ms = planning / "milestones"
674
+ # Nested
675
+ v200 = ms / "v2.0.0"
676
+ (v200 / "quests").mkdir(parents=True)
677
+ (v200 / "quests" / "ROADMAP.md").write_text("# Roadmap")
678
+ # Standard
679
+ (ms / "v2.2.0").mkdir(parents=True)
680
+ (ms / "v2.2.0" / "ROADMAP.md").write_text("# Roadmap")
681
+
682
+ result = _detect_versioned_milestone_dirs(planning)
683
+ assert len(result) == 2
684
+ nested = [r for r in result if r["type"] == "nested"]
685
+ standard = [r for r in result if r["type"] == "standard"]
686
+ assert len(nested) == 1
687
+ assert nested[0]["sub"] == "quests"
688
+ assert len(standard) == 1
689
+ assert standard[0]["version"] == "v2.2.0"
690
+
691
+ def test_slug_dirs_ignored(self, tmp_path):
692
+ """mvp/, blast-pass/ are not flagged."""
693
+ planning = tmp_path / ".planning"
694
+ ms = planning / "milestones"
695
+ (ms / "mvp").mkdir(parents=True)
696
+ (ms / "mvp" / "ROADMAP.md").write_text("# Roadmap")
697
+ (ms / "blast-pass").mkdir(parents=True)
698
+ (ms / "blast-pass" / "ROADMAP.md").write_text("# Roadmap")
699
+
700
+ result = _detect_versioned_milestone_dirs(planning)
701
+ assert result == []
702
+
703
+ def test_no_milestones_dir(self, tmp_path):
704
+ """No milestones/ directory returns empty."""
705
+ planning = tmp_path / ".planning"
706
+ planning.mkdir(parents=True)
707
+
708
+ result = _detect_versioned_milestone_dirs(planning)
709
+ assert result == []
710
+
711
+ def test_empty_milestones_dir(self, tmp_path):
712
+ """Empty milestones/ directory returns empty."""
713
+ planning = tmp_path / ".planning"
714
+ (planning / "milestones").mkdir(parents=True)
715
+
716
+ result = _detect_versioned_milestone_dirs(planning)
717
+ assert result == []
718
+
719
+ def test_phases_subdir_excluded_from_nested(self, tmp_path):
720
+ """phases/ sub-directory inside v-dir is excluded from nested detection."""
721
+ planning = tmp_path / ".planning"
722
+ ms = planning / "milestones"
723
+ v01 = ms / "v0.1"
724
+ (v01 / "phases" / "01-setup").mkdir(parents=True)
725
+ # Has .md files, so it's standard despite having phases/ sub-dir
726
+ (v01 / "ROADMAP.md").write_text("# Roadmap")
727
+
728
+ result = _detect_versioned_milestone_dirs(planning)
729
+ assert len(result) == 1
730
+ assert result[0]["type"] == "standard"
731
+
732
+
733
+ class TestParseMilestoneNameMapping:
734
+ """Test _parse_milestone_name_mapping parsing logic."""
735
+
736
+ def test_standard_headers(self, tmp_path):
737
+ """Parses ## v0.1 MVP (Shipped: ...) correctly."""
738
+ planning = tmp_path / ".planning"
739
+ planning.mkdir(parents=True)
740
+ (planning / "MILESTONES.md").write_text(
741
+ "# Milestones\n\n"
742
+ "## v0.1 MVP (Shipped: 2026-01-15)\n\n"
743
+ "Some content.\n"
744
+ )
745
+
746
+ result = _parse_milestone_name_mapping(planning)
747
+ assert len(result) == 1
748
+ assert result[0]["version"] == "v0.1"
749
+ assert result[0]["name"] == "MVP"
750
+ assert result[0]["slug"] == "mvp"
751
+
752
+ def test_multi_word_names(self, tmp_path):
753
+ """Parses multi-word names with special chars."""
754
+ planning = tmp_path / ".planning"
755
+ planning.mkdir(parents=True)
756
+ (planning / "MILESTONES.md").write_text(
757
+ "# Milestones\n\n"
758
+ "## v0.1 MVP - POSitive Plus SDK Integration (Shipped: 2026-01-15)\n\n"
759
+ )
760
+
761
+ result = _parse_milestone_name_mapping(planning)
762
+ assert len(result) == 1
763
+ assert result[0]["name"] == "MVP - POSitive Plus SDK Integration"
764
+ assert result[0]["slug"] == "mvp-positive-plus-sdk-integration"
765
+
766
+ def test_duplicate_version(self, tmp_path):
767
+ """Two v2.0.0 entries (like ForgeBlast) both extracted."""
768
+ planning = tmp_path / ".planning"
769
+ planning.mkdir(parents=True)
770
+ (planning / "MILESTONES.md").write_text(
771
+ "# Milestones\n\n"
772
+ "## v2.0.0 Quests Feature (Shipped: 2026-01-01)\n\n"
773
+ "Content.\n\n"
774
+ "## v2.0.0 Sanctuary (Shipped: 2026-02-01)\n\n"
775
+ "Content.\n"
776
+ )
777
+
778
+ result = _parse_milestone_name_mapping(planning)
779
+ assert len(result) == 2
780
+ versions = [r["version"] for r in result]
781
+ assert versions == ["v2.0.0", "v2.0.0"]
782
+ names = [r["name"] for r in result]
783
+ assert "Quests Feature" in names
784
+ assert "Sanctuary" in names
785
+
786
+ def test_no_versioned_headers(self, tmp_path):
787
+ """New-format headers without version prefix are not matched."""
788
+ planning = tmp_path / ".planning"
789
+ planning.mkdir(parents=True)
790
+ (planning / "MILESTONES.md").write_text(
791
+ "# Milestones\n\n"
792
+ "## MVP (Shipped: 2026-01-15)\n\n"
793
+ "Content.\n"
794
+ )
795
+
796
+ result = _parse_milestone_name_mapping(planning)
797
+ assert result == []
798
+
799
+ def test_current_milestone_from_project(self, tmp_path):
800
+ """Parses ## Current Milestone: v0.3 Demo Release from PROJECT.md."""
801
+ planning = tmp_path / ".planning"
802
+ planning.mkdir(parents=True)
803
+ (planning / "PROJECT.md").write_text(
804
+ "# Project\n\n"
805
+ "## Current Milestone: v0.3 Demo Release\n\n"
806
+ "Content.\n"
807
+ )
808
+
809
+ result = _parse_milestone_name_mapping(planning)
810
+ assert len(result) == 1
811
+ assert result[0]["version"] == "v0.3"
812
+ assert result[0]["name"] == "Demo Release"
813
+ assert result[0]["slug"] == "demo-release"
814
+ assert result[0].get("current") is True
815
+
816
+ def test_no_files(self, tmp_path):
817
+ """No MILESTONES.md or PROJECT.md returns empty."""
818
+ planning = tmp_path / ".planning"
819
+ planning.mkdir(parents=True)
820
+
821
+ result = _parse_milestone_name_mapping(planning)
822
+ assert result == []
823
+
824
+ def test_started_status(self, tmp_path):
825
+ """Parses Started: status headers too."""
826
+ planning = tmp_path / ".planning"
827
+ planning.mkdir(parents=True)
828
+ (planning / "MILESTONES.md").write_text(
829
+ "# Milestones\n\n"
830
+ "## v0.2 Infrastructure (Started: 2026-02-01)\n\n"
831
+ )
832
+
833
+ result = _parse_milestone_name_mapping(planning)
834
+ assert len(result) == 1
835
+ assert result[0]["version"] == "v0.2"
836
+ assert result[0]["name"] == "Infrastructure"