@stylusnexus/work-plan 2026.6.9-1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,185 @@
1
+ """Tests for init command on shared (.work-plan/) paths — Phase C."""
2
+ import io
3
+ import sys
4
+ import unittest
5
+ from contextlib import redirect_stdout
6
+ from pathlib import Path
7
+ from unittest.mock import patch, MagicMock
8
+
9
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(SKILL_ROOT))
11
+
12
+ from commands import init
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def _make_cfg(*, notes_root="/tmp/fake-notes", repos=None):
20
+ if repos is None:
21
+ repos = {
22
+ "myrepo": {
23
+ "github": "org/myrepo",
24
+ "local": "/home/user/projects/myrepo",
25
+ }
26
+ }
27
+ return {"notes_root": notes_root, "repos": repos}
28
+
29
+
30
+ def _drive_shared(args, *, cfg=None, body="", vis="PRIVATE",
31
+ path_str=None, meta=None):
32
+ """Run init.run on a path inside a .work-plan/ directory.
33
+
34
+ Uses paths that are already absolute and canonical (no symlink resolution
35
+ needed) and patches expanduser/resolve so they return the path unchanged.
36
+ Config local paths match the fake clone root exactly.
37
+ """
38
+ if cfg is None:
39
+ cfg = _make_cfg()
40
+ if path_str is None:
41
+ path_str = "/home/user/projects/myrepo/.work-plan/my-track.md"
42
+ fake_path = Path(path_str)
43
+ existing_meta = meta if meta is not None else {}
44
+
45
+ # Patch expanduser to be a no-op and resolve to return self, so Path
46
+ # comparisons inside _find_repo_for_shared_path use the literal strings
47
+ # we put in cfg["repos"][...]["local"].
48
+ _orig_expanduser = Path.expanduser
49
+ _orig_resolve = Path.resolve
50
+
51
+ def _noop_expanduser(self):
52
+ return self
53
+
54
+ def _noop_resolve(self):
55
+ return self
56
+
57
+ with patch("commands.init.load_config", return_value=cfg), \
58
+ patch("commands.init.parse_file", return_value=(existing_meta, body)), \
59
+ patch("commands.init.write_file") as mw, \
60
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
61
+ patch("pathlib.Path.exists", return_value=True), \
62
+ patch("pathlib.Path.expanduser", _noop_expanduser), \
63
+ patch("pathlib.Path.resolve", _noop_resolve):
64
+ full_args = [str(fake_path)] + list(args)
65
+ buf = io.StringIO()
66
+ with redirect_stdout(buf):
67
+ rc = init.run(full_args)
68
+ return rc, mw, buf.getvalue()
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Tests
73
+ # ---------------------------------------------------------------------------
74
+
75
+ class InitSharedPathTest(unittest.TestCase):
76
+
77
+ def test_shared_path_resolves_repo_from_config(self):
78
+ """init on a .work-plan/ path resolves github repo from config entry."""
79
+ rc, mw, out = _drive_shared([], vis="PRIVATE")
80
+ self.assertEqual(rc, 0)
81
+ mw.assert_called_once()
82
+ written_meta = mw.call_args[0][1]
83
+ self.assertEqual(written_meta["github"]["repo"], "org/myrepo")
84
+
85
+ def test_shared_path_never_writes_tbd(self):
86
+ """init on a .work-plan/ path never writes github.repo == 'TBD'."""
87
+ rc, mw, out = _drive_shared([], vis="PRIVATE")
88
+ self.assertEqual(rc, 0)
89
+ written_meta = mw.call_args[0][1]
90
+ self.assertNotEqual(written_meta["github"]["repo"], "TBD")
91
+
92
+ def test_shared_path_prints_tier_shared(self):
93
+ """init on a .work-plan/ path prints 'tier: shared'."""
94
+ rc, mw, out = _drive_shared([], vis="PRIVATE")
95
+ self.assertEqual(rc, 0)
96
+ self.assertIn("tier: shared", out)
97
+
98
+ def test_shared_path_unregistered_repo_returns_rc1(self):
99
+ """init on a .work-plan/ dir not in config → error, rc 1."""
100
+ # Config has no matching local path
101
+ cfg = _make_cfg(repos={
102
+ "other": {
103
+ "github": "org/other",
104
+ "local": "/home/user/projects/other",
105
+ }
106
+ })
107
+ rc, mw, out = _drive_shared([], cfg=cfg, vis="PRIVATE")
108
+ self.assertEqual(rc, 1)
109
+ mw.assert_not_called()
110
+ self.assertIn("ERROR", out)
111
+ self.assertIn("init-repo", out)
112
+
113
+ def test_shared_path_already_has_frontmatter_no_write(self):
114
+ """init on a .work-plan/ path that already has frontmatter → no write, rc 0."""
115
+ existing = {"track": "my-track", "status": "active"}
116
+ rc, mw, out = _drive_shared([], meta=existing, vis="PRIVATE")
117
+ self.assertEqual(rc, 0)
118
+ mw.assert_not_called()
119
+ self.assertIn("already has frontmatter", out)
120
+
121
+ def test_shared_path_body_issue_refs_captured(self):
122
+ """init on a .work-plan/ path scans body for issue refs."""
123
+ body = "Implements #42 and #99.\n"
124
+ rc, mw, out = _drive_shared([], body=body, vis="PRIVATE")
125
+ self.assertEqual(rc, 0)
126
+ written_meta = mw.call_args[0][1]
127
+ self.assertEqual(written_meta["github"]["issues"], [42, 99])
128
+
129
+ def test_shared_path_public_repo_requires_confirm(self):
130
+ """init on a .work-plan/ path with PUBLIC repo → needs_confirm JSON, no write."""
131
+ rc, mw, out = _drive_shared([], vis="PUBLIC")
132
+ self.assertEqual(rc, 0)
133
+ mw.assert_not_called()
134
+ import json
135
+ data = json.loads(out.strip())
136
+ self.assertTrue(data["needs_confirm"])
137
+
138
+
139
+ def _noop_expanduser(self):
140
+ return self
141
+
142
+
143
+ def _noop_resolve(self):
144
+ return self
145
+
146
+
147
+ class InitFindRepoHelperTest(unittest.TestCase):
148
+ """Unit tests for _find_repo_for_shared_path.
149
+
150
+ Use expanduser/resolve no-op patches so that literal path strings in cfg
151
+ compare equal to the Path objects derived from the track path.
152
+ """
153
+
154
+ def test_finds_registered_repo(self):
155
+ """_find_repo_for_shared_path returns github slug for registered clone."""
156
+ cfg = _make_cfg()
157
+ path = Path("/home/user/projects/myrepo/.work-plan/some-track.md")
158
+ with patch("pathlib.Path.expanduser", _noop_expanduser), \
159
+ patch("pathlib.Path.resolve", _noop_resolve):
160
+ result = init._find_repo_for_shared_path(path, cfg)
161
+ self.assertEqual(result, "org/myrepo")
162
+
163
+ def test_returns_none_for_unregistered_clone(self):
164
+ """_find_repo_for_shared_path returns None when clone not in config."""
165
+ cfg = _make_cfg(repos={
166
+ "other": {"github": "org/other", "local": "/home/user/projects/other"},
167
+ })
168
+ path = Path("/home/user/projects/myrepo/.work-plan/some-track.md")
169
+ with patch("pathlib.Path.expanduser", _noop_expanduser), \
170
+ patch("pathlib.Path.resolve", _noop_resolve):
171
+ result = init._find_repo_for_shared_path(path, cfg)
172
+ self.assertIsNone(result)
173
+
174
+ def test_returns_none_for_non_shared_path(self):
175
+ """_find_repo_for_shared_path returns None for a path not in .work-plan/."""
176
+ cfg = _make_cfg()
177
+ path = Path("/tmp/fake-notes/myrepo/some-track.md")
178
+ # No patches needed — the path doesn't have .work-plan in parts,
179
+ # so the function returns None before calling expanduser/resolve.
180
+ result = init._find_repo_for_shared_path(path, cfg)
181
+ self.assertIsNone(result)
182
+
183
+
184
+ if __name__ == "__main__":
185
+ unittest.main()
@@ -0,0 +1,77 @@
1
+ """Tests for LLM candidate selection + evidence gathering."""
2
+ import unittest
3
+ import sys
4
+ import tempfile
5
+ from datetime import date, datetime
6
+ from pathlib import Path
7
+ from unittest import mock
8
+
9
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(SKILL_ROOT))
11
+
12
+ from lib.llm_evidence import select_candidates, gather_evidence, EXCERPT_CHARS
13
+
14
+
15
+ def _row(rel, verdict, present, declared):
16
+ return {"rel": rel, "verdict": verdict, "files_present": present,
17
+ "files_declared": declared, "glyph": "?", "rationale": ""}
18
+
19
+
20
+ class SelectCandidatesTest(unittest.TestCase):
21
+ def test_picks_manifest_less(self):
22
+ rows = [_row("a.md", "manifest-less", 0, 0)]
23
+ self.assertEqual([r["rel"] for r in select_candidates(rows)], ["a.md"])
24
+
25
+ def test_picks_ambiguous_low_completion(self):
26
+ rows = [_row("b.md", "partial", 0, 38), _row("c.md", "partial", 1, 11)]
27
+ picked = {r["rel"] for r in select_candidates(rows)}
28
+ self.assertEqual(picked, {"b.md", "c.md"})
29
+
30
+ def test_skips_confident_shipped_and_healthy_partial(self):
31
+ rows = [_row("d.md", "shipped", 9, 9), _row("e.md", "partial", 8, 12)]
32
+ self.assertEqual(select_candidates(rows), [])
33
+
34
+
35
+ class GatherEvidenceTest(unittest.TestCase):
36
+ def test_builds_evidence_dict(self):
37
+ with tempfile.TemporaryDirectory() as d:
38
+ root = Path(d)
39
+ (root / "docs").mkdir()
40
+ doc_path = root / "docs/x-design.md"
41
+ doc_path.write_text("# Design X\n\nLong prose. " + "z" * 5000)
42
+
43
+ class Doc:
44
+ path = doc_path
45
+ rel = "docs/x-design.md"
46
+ kind = "spec"
47
+
48
+ fake_dt = datetime(2026, 4, 2, 10, 0, 0)
49
+ with mock.patch("lib.llm_evidence.git_state.path_last_commit_date",
50
+ return_value=fake_dt):
51
+ ev = gather_evidence(Doc(), root)
52
+ self.assertEqual(ev["rel"], "docs/x-design.md")
53
+ self.assertEqual(ev["kind"], "spec")
54
+ self.assertEqual(ev["last_touched"], "2026-04-02")
55
+ self.assertEqual(ev["title"], "Design X")
56
+ self.assertLessEqual(len(ev["excerpt"]), EXCERPT_CHARS)
57
+
58
+ def test_none_last_touched(self):
59
+ with tempfile.TemporaryDirectory() as d:
60
+ root = Path(d)
61
+ doc_path = root / "y.md"
62
+ doc_path.write_text("no heading\n")
63
+
64
+ class Doc:
65
+ path = doc_path
66
+ rel = "y.md"
67
+ kind = "adhoc"
68
+
69
+ with mock.patch("lib.llm_evidence.git_state.path_last_commit_date",
70
+ return_value=None):
71
+ ev = gather_evidence(Doc(), root)
72
+ self.assertIsNone(ev["last_touched"])
73
+ self.assertEqual(ev["title"], "(no title)")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ unittest.main()
@@ -0,0 +1,162 @@
1
+ """Tests for manifest parsing + scoring."""
2
+ import unittest
3
+ import sys
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(SKILL_ROOT))
9
+
10
+ from lib.manifest import (
11
+ DeclaredPath, strip_range, parse_declared_paths,
12
+ count_checkboxes, plan_date_from_filename,
13
+ ManifestScore, score_manifest,
14
+ is_in_tree, out_of_tree_ratio,
15
+ )
16
+
17
+
18
+ class InTreeTest(unittest.TestCase):
19
+ ROOT = Path("/repo")
20
+
21
+ def test_relative_is_in_tree(self):
22
+ self.assertTrue(is_in_tree("src/foo.ts", self.ROOT))
23
+
24
+ def test_tilde_is_out_of_tree(self):
25
+ self.assertFalse(is_in_tree("~/.claude/skills/x.py", self.ROOT))
26
+
27
+ def test_absolute_elsewhere_is_out_of_tree(self):
28
+ self.assertFalse(is_in_tree("/Applications/other/x.ts", self.ROOT))
29
+
30
+ def test_absolute_under_root_is_in_tree(self):
31
+ self.assertTrue(is_in_tree("/repo/src/x.ts", self.ROOT))
32
+
33
+ def test_dotdot_escape_is_out_of_tree(self):
34
+ self.assertFalse(is_in_tree("../sibling/x.ts", self.ROOT))
35
+
36
+
37
+ class OutOfTreeRatioTest(unittest.TestCase):
38
+ def test_all_foreign(self):
39
+ decls = [DeclaredPath("create", "~/.claude/a.py"),
40
+ DeclaredPath("create", "/Applications/other/b.ts")]
41
+ self.assertEqual(out_of_tree_ratio(decls, Path("/repo")), 1.0)
42
+
43
+ def test_mixed(self):
44
+ decls = [DeclaredPath("create", "src/a.ts"),
45
+ DeclaredPath("create", "~/b.py")]
46
+ self.assertEqual(out_of_tree_ratio(decls, Path("/repo")), 0.5)
47
+
48
+ def test_empty_is_zero(self):
49
+ self.assertEqual(out_of_tree_ratio([], Path("/repo")), 0.0)
50
+
51
+
52
+ class StripRangeTest(unittest.TestCase):
53
+ def test_strips_line_range(self):
54
+ self.assertEqual(strip_range("src/foo.ts:120-145"), "src/foo.ts")
55
+
56
+ def test_strips_single_line(self):
57
+ self.assertEqual(strip_range("src/foo.ts:12"), "src/foo.ts")
58
+
59
+ def test_strips_multi_range(self):
60
+ self.assertEqual(strip_range("src/foo.tsx:104-115,217-247"), "src/foo.tsx")
61
+
62
+ def test_leaves_bare_path(self):
63
+ self.assertEqual(strip_range("src/foo.ts"), "src/foo.ts")
64
+
65
+
66
+ class ParseDeclaredPathsTest(unittest.TestCase):
67
+ SAMPLE = (
68
+ "**Files:**\n"
69
+ "- Create: `src/lib/idea.ts`\n"
70
+ "- Modify: `src/app/route.ts:10-22`\n"
71
+ "- Test: `tests/idea.test.ts`\n"
72
+ "Run: `npm test`\n" # not a declared path (no Create/Modify/Test)
73
+ "See `SomeType` for details\n" # not a path
74
+ )
75
+
76
+ def test_extracts_three_kinds(self):
77
+ decls = parse_declared_paths(self.SAMPLE)
78
+ kinds = {d.kind for d in decls}
79
+ self.assertEqual(kinds, {"create", "modify", "test"})
80
+
81
+ def test_strips_range_on_modify(self):
82
+ decls = parse_declared_paths(self.SAMPLE)
83
+ modify = [d for d in decls if d.kind == "modify"][0]
84
+ self.assertEqual(modify.path, "src/app/route.ts")
85
+
86
+ def test_ignores_non_declaration_backticks(self):
87
+ decls = parse_declared_paths(self.SAMPLE)
88
+ paths = {d.path for d in decls}
89
+ self.assertNotIn("npm test", paths)
90
+ self.assertNotIn("SomeType", paths)
91
+
92
+ def test_dedupes_first_kind_wins(self):
93
+ text = "- Create: `a/b.ts`\n- Modify: `a/b.ts`\n"
94
+ decls = parse_declared_paths(text)
95
+ self.assertEqual(len(decls), 1)
96
+ self.assertEqual(decls[0].kind, "create")
97
+
98
+
99
+ class CountCheckboxesTest(unittest.TestCase):
100
+ def test_counts_done_and_total_multiline(self):
101
+ text = "- [x] one\n- [ ] two\n - [X] three\n- [ ] four\n"
102
+ done, total = count_checkboxes(text)
103
+ self.assertEqual((done, total), (2, 4))
104
+
105
+ def test_no_checkboxes(self):
106
+ self.assertEqual(count_checkboxes("plain prose"), (0, 0))
107
+
108
+
109
+ class PlanDateTest(unittest.TestCase):
110
+ def test_extracts_iso_prefix(self):
111
+ self.assertEqual(plan_date_from_filename("2026-03-16-idea-mode-ui.md"),
112
+ date(2026, 3, 16))
113
+
114
+ def test_returns_none_without_date(self):
115
+ self.assertIsNone(plan_date_from_filename("idea-mode-ui.md"))
116
+
117
+
118
+ class ScoreManifestTest(unittest.TestCase):
119
+ def _decls(self):
120
+ return [
121
+ DeclaredPath("create", "src/new.ts"),
122
+ DeclaredPath("create", "src/missing.ts"),
123
+ DeclaredPath("modify", "src/existing.ts"),
124
+ DeclaredPath("test", "tests/new.test.ts"),
125
+ ]
126
+
127
+ def test_scores_with_injected_predicates(self):
128
+ present = {"src/new.ts", "tests/new.test.ts", "src/existing.ts"}
129
+ committed = {"src/existing.ts"}
130
+ score = score_manifest(
131
+ self._decls(), Path("/repo"), date(2026, 3, 1),
132
+ exists=lambda rel: rel in present,
133
+ committed_since=lambda rel: rel in committed,
134
+ )
135
+ # create: new.ts present(yes), missing.ts(no) -> 1/2
136
+ # modify: existing.ts committed-since(yes) -> 1/1
137
+ # test: new.test.ts present(yes) -> 1/1
138
+ self.assertEqual(score.total, 4)
139
+ self.assertEqual(score.satisfied, 3)
140
+ self.assertEqual(score.by_kind["create"], (1, 2))
141
+ self.assertEqual(score.by_kind["modify"], (1, 1))
142
+ self.assertEqual(score.by_kind["test"], (1, 1))
143
+ self.assertAlmostEqual(score.pct, 75.0)
144
+
145
+ def test_modify_existing_but_not_committed_is_unsatisfied(self):
146
+ score = score_manifest(
147
+ [DeclaredPath("modify", "src/old.ts")], Path("/repo"), date(2026, 3, 1),
148
+ exists=lambda rel: True, # file exists...
149
+ committed_since=lambda rel: False, # ...but untouched since plan date
150
+ )
151
+ self.assertEqual(score.satisfied, 0)
152
+
153
+ def test_empty_manifest_pct_none(self):
154
+ score = score_manifest([], Path("/repo"), None,
155
+ exists=lambda rel: False,
156
+ committed_since=lambda rel: False)
157
+ self.assertEqual(score.total, 0)
158
+ self.assertIsNone(score.pct)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ unittest.main()
@@ -0,0 +1,130 @@
1
+ """Tests for new-issue matching."""
2
+ import unittest
3
+ import sys
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(SKILL_ROOT))
9
+
10
+ from lib.new_issues import build_slug_labels, match_issue_to_tracks
11
+
12
+
13
+ class MatchIssueTest(unittest.TestCase):
14
+ def test_label_match_wins(self):
15
+ issue = {"number": 9, "title": "unrelated", "labels": [{"name": "track/tabletop"}]}
16
+ matches = match_issue_to_tracks(issue, ["tabletop", "ux-redesign"])
17
+ self.assertEqual(matches, ["tabletop"])
18
+
19
+ def test_keyword_in_title(self):
20
+ issue = {"number": 10, "title": "fix tabletop initiative tracker", "labels": []}
21
+ matches = match_issue_to_tracks(issue, ["tabletop", "ux-redesign"])
22
+ self.assertEqual(matches, ["tabletop"])
23
+
24
+ def test_no_match_returns_empty(self):
25
+ issue = {"number": 11, "title": "boring thing", "labels": []}
26
+ self.assertEqual(match_issue_to_tracks(issue, ["tabletop", "ux-redesign"]), [])
27
+
28
+ def test_multiple_matches(self):
29
+ issue = {"number": 12, "title": "tabletop ux redesign for combat", "labels": []}
30
+ matches = match_issue_to_tracks(issue, ["tabletop", "ux-redesign"])
31
+ self.assertEqual(set(matches), {"tabletop", "ux-redesign"})
32
+
33
+ def test_slug_labels_override_single(self):
34
+ # Repo uses flat label `storytelling` instead of `track/storytelling-enhancements`.
35
+ issue = {"number": 100, "title": "unrelated", "labels": [{"name": "storytelling"}]}
36
+ slug_labels = {"storytelling-enhancements": ["storytelling"]}
37
+ matches = match_issue_to_tracks(issue, ["storytelling-enhancements"],
38
+ slug_labels=slug_labels)
39
+ self.assertEqual(matches, ["storytelling-enhancements"])
40
+
41
+ def test_slug_labels_override_multiple_or_semantics(self):
42
+ # Track configured to match if EITHER label is present (OR semantics).
43
+ slug_labels = {"ai-generators": ["ai", "generators"]}
44
+
45
+ issue_a = {"number": 200, "title": "x", "labels": [{"name": "ai"}]}
46
+ self.assertEqual(
47
+ match_issue_to_tracks(issue_a, ["ai-generators"], slug_labels=slug_labels),
48
+ ["ai-generators"],
49
+ )
50
+
51
+ issue_b = {"number": 201, "title": "x", "labels": [{"name": "generators"}]}
52
+ self.assertEqual(
53
+ match_issue_to_tracks(issue_b, ["ai-generators"], slug_labels=slug_labels),
54
+ ["ai-generators"],
55
+ )
56
+
57
+ issue_c = {"number": 202, "title": "x", "labels": [{"name": "unrelated"}]}
58
+ self.assertEqual(
59
+ match_issue_to_tracks(issue_c, ["ai-generators"], slug_labels=slug_labels),
60
+ [],
61
+ )
62
+
63
+ def test_default_track_slug_label_still_works_when_other_track_overrides(self):
64
+ # Two tracks: one overridden, one using default `track/<slug>`.
65
+ slug_labels = {"storytelling-enhancements": ["storytelling"]}
66
+ issue = {"number": 300, "title": "unrelated", "labels": [{"name": "track/tabletop"}]}
67
+ matches = match_issue_to_tracks(
68
+ issue, ["storytelling-enhancements", "tabletop"], slug_labels=slug_labels
69
+ )
70
+ self.assertEqual(matches, ["tabletop"])
71
+
72
+ def test_type_label_does_not_leak_into_track_match(self):
73
+ # A `type:feature` label on its own should NOT auto-match a track
74
+ # unless a track explicitly opts into it via slug_labels.
75
+ issue = {
76
+ "number": 400,
77
+ "title": "boring thing",
78
+ "labels": [{"name": "type:feature"}, {"name": "priority:P3"}],
79
+ }
80
+ # No slug_labels override → default behaviour, no match.
81
+ self.assertEqual(match_issue_to_tracks(issue, ["tabletop"]), [])
82
+ # Explicit opt-in for one track → that track matches.
83
+ slug_labels = {"feature-work": ["type:feature"]}
84
+ matches = match_issue_to_tracks(
85
+ issue, ["feature-work", "tabletop"], slug_labels=slug_labels
86
+ )
87
+ self.assertEqual(matches, ["feature-work"])
88
+
89
+
90
+ class BuildSlugLabelsTest(unittest.TestCase):
91
+ def _track(self, slug, labels=None, has_fm=True):
92
+ meta = {"track": slug}
93
+ if labels is not None:
94
+ meta["github"] = {"labels": labels}
95
+ return SimpleNamespace(name=slug, has_frontmatter=has_fm, meta=meta)
96
+
97
+ def test_extracts_labels_from_frontmatter(self):
98
+ tracks = [
99
+ self._track("storytelling-enhancements", ["storytelling"]),
100
+ self._track("ai-generators", ["ai", "generators"]),
101
+ ]
102
+ result = build_slug_labels(tracks)
103
+ self.assertEqual(result, {
104
+ "storytelling-enhancements": ["storytelling"],
105
+ "ai-generators": ["ai", "generators"],
106
+ })
107
+
108
+ def test_omits_tracks_without_labels(self):
109
+ # Tracks without `github.labels` are absent from the map; callers fall
110
+ # back to the default `track/<slug>` pattern for those.
111
+ tracks = [
112
+ self._track("tabletop"), # no labels
113
+ self._track("storytelling-enhancements", ["storytelling"]),
114
+ ]
115
+ result = build_slug_labels(tracks)
116
+ self.assertEqual(result, {"storytelling-enhancements": ["storytelling"]})
117
+
118
+ def test_skips_tracks_without_frontmatter(self):
119
+ tracks = [
120
+ self._track("ghost", ["foo"], has_fm=False),
121
+ ]
122
+ self.assertEqual(build_slug_labels(tracks), {})
123
+
124
+ def test_strips_blank_label_entries(self):
125
+ tracks = [self._track("foo", ["a", " ", ""])]
126
+ self.assertEqual(build_slug_labels(tracks), {"foo": ["a"]})
127
+
128
+
129
+ if __name__ == "__main__":
130
+ unittest.main()