@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.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/VERSION +1 -0
- package/bin/work-plan +59 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +152 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/auto_triage.py +230 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +139 -0
- package/skills/work-plan/commands/close.py +98 -0
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +124 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +272 -0
- package/skills/work-plan/commands/handoff.py +867 -0
- package/skills/work-plan/commands/hygiene.py +128 -0
- package/skills/work-plan/commands/init.py +128 -0
- package/skills/work-plan/commands/init_repo.py +132 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +225 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +225 -0
- package/skills/work-plan/commands/refresh_md.py +145 -0
- package/skills/work-plan/commands/set_field.py +61 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +154 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +88 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +42 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +248 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_auto_triage.py +324 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +169 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_group_apply.py +348 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +379 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +610 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +385 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +220 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Tests for track discovery."""
|
|
2
|
+
import io
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
10
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
11
|
+
|
|
12
|
+
from lib.tracks import discover_tracks, discover_archived_tracks
|
|
13
|
+
|
|
14
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Helpers
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def _make_git_repo(base: Path) -> Path:
|
|
21
|
+
"""Create a minimal fake git repo at base (has a .git dir)."""
|
|
22
|
+
(base / ".git").mkdir(parents=True, exist_ok=True)
|
|
23
|
+
return base
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_track_md(path: Path, track_name: str, repo: str,
|
|
27
|
+
status: str = "active") -> None:
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path.write_text(
|
|
30
|
+
f"---\ntrack: {track_name}\nstatus: {status}\n"
|
|
31
|
+
f"github:\n repo: {repo}\n issues: []\n---\n\n# {track_name}\n",
|
|
32
|
+
encoding="utf-8",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Existing private-notes tests (unchanged behaviour)
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
class DiscoverTracksTest(unittest.TestCase):
|
|
41
|
+
def setUp(self):
|
|
42
|
+
self.cfg = {
|
|
43
|
+
"notes_root": str(FIXTURES),
|
|
44
|
+
"repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def test_active_track_discovered(self):
|
|
48
|
+
names = [t.name for t in discover_tracks(self.cfg) if t.has_frontmatter]
|
|
49
|
+
self.assertIn("example", names)
|
|
50
|
+
|
|
51
|
+
def test_repo_inferred_from_folder(self):
|
|
52
|
+
ex = next(t for t in discover_tracks(self.cfg) if t.name == "example")
|
|
53
|
+
self.assertEqual(ex.repo, "stylusnexus/CritForge")
|
|
54
|
+
|
|
55
|
+
def test_no_frontmatter_flagged_needs_init(self):
|
|
56
|
+
nf = next(t for t in discover_tracks(self.cfg) if t.path.name == "no_frontmatter.md")
|
|
57
|
+
self.assertTrue(nf.needs_init)
|
|
58
|
+
|
|
59
|
+
def test_loose_file_flagged_needs_filing(self):
|
|
60
|
+
loose = next(t for t in discover_tracks(self.cfg) if t.path.name == "loose_at_root.md")
|
|
61
|
+
self.assertTrue(loose.needs_filing)
|
|
62
|
+
|
|
63
|
+
def test_archived_excluded_from_discover_tracks(self):
|
|
64
|
+
names = [t.name for t in discover_tracks(self.cfg)]
|
|
65
|
+
self.assertNotIn("old", names)
|
|
66
|
+
|
|
67
|
+
def test_private_tracks_tagged_private(self):
|
|
68
|
+
"""All tracks from notes_root carry tier='private'."""
|
|
69
|
+
tracks = discover_tracks(self.cfg)
|
|
70
|
+
for t in tracks:
|
|
71
|
+
self.assertEqual(t.tier, "private",
|
|
72
|
+
f"Expected tier='private' for {t.path}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DiscoverArchivedTracksTest(unittest.TestCase):
|
|
76
|
+
def setUp(self):
|
|
77
|
+
self.cfg = {
|
|
78
|
+
"notes_root": str(FIXTURES),
|
|
79
|
+
"repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def test_finds_shipped_track_in_archive(self):
|
|
83
|
+
archived = discover_archived_tracks(self.cfg)
|
|
84
|
+
slugs = [a.meta.get("track") for a in archived]
|
|
85
|
+
self.assertIn("old", slugs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Shared-notes tier tests
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
class SharedTrackDiscoveryTest(unittest.TestCase):
|
|
93
|
+
"""Shared tracks live in <local>/.work-plan/ and are tagged tier='shared'."""
|
|
94
|
+
|
|
95
|
+
def _make_cfg(self, local_clone: Path, notes_root: Path) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"notes_root": str(notes_root),
|
|
98
|
+
"repos": {
|
|
99
|
+
"myrepo": {
|
|
100
|
+
"github": "org/myrepo",
|
|
101
|
+
"local": str(local_clone),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def test_shared_track_discovered_and_tagged(self):
|
|
107
|
+
"""A track in <local>/.work-plan/ is discovered with tier='shared'."""
|
|
108
|
+
with tempfile.TemporaryDirectory() as d:
|
|
109
|
+
base = Path(d)
|
|
110
|
+
clone = _make_git_repo(base / "clone")
|
|
111
|
+
wp = clone / ".work-plan" / "myrepo"
|
|
112
|
+
_write_track_md(wp / "feat-x.md", "feat-x", "org/myrepo")
|
|
113
|
+
notes = base / "notes"
|
|
114
|
+
notes.mkdir()
|
|
115
|
+
cfg = self._make_cfg(clone, notes)
|
|
116
|
+
|
|
117
|
+
tracks = discover_tracks(cfg)
|
|
118
|
+
names = [t.name for t in tracks]
|
|
119
|
+
self.assertIn("feat-x", names)
|
|
120
|
+
shared = next(t for t in tracks if t.name == "feat-x")
|
|
121
|
+
self.assertEqual(shared.tier, "shared")
|
|
122
|
+
|
|
123
|
+
def test_shared_track_repo_from_folder_config(self):
|
|
124
|
+
"""repo and local_path on a shared track come from folder config, not frontmatter."""
|
|
125
|
+
with tempfile.TemporaryDirectory() as d:
|
|
126
|
+
base = Path(d)
|
|
127
|
+
clone = _make_git_repo(base / "clone")
|
|
128
|
+
wp = clone / ".work-plan"
|
|
129
|
+
_write_track_md(wp / "feat-y.md", "feat-y", "org/myrepo")
|
|
130
|
+
notes = base / "notes"
|
|
131
|
+
notes.mkdir()
|
|
132
|
+
cfg = self._make_cfg(clone, notes)
|
|
133
|
+
|
|
134
|
+
tracks = discover_tracks(cfg)
|
|
135
|
+
t = next(t for t in tracks if t.name == "feat-y")
|
|
136
|
+
self.assertEqual(t.repo, "org/myrepo")
|
|
137
|
+
self.assertEqual(
|
|
138
|
+
str(Path(t.local_path).resolve()),
|
|
139
|
+
str(clone.resolve()),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def test_archive_dir_skipped_by_discover_tracks(self):
|
|
143
|
+
"""Files inside .work-plan/archive/ are excluded from active discovery."""
|
|
144
|
+
with tempfile.TemporaryDirectory() as d:
|
|
145
|
+
base = Path(d)
|
|
146
|
+
clone = _make_git_repo(base / "clone")
|
|
147
|
+
_write_track_md(
|
|
148
|
+
clone / ".work-plan" / "archive" / "old.md", "old", "org/myrepo"
|
|
149
|
+
)
|
|
150
|
+
notes = base / "notes"
|
|
151
|
+
notes.mkdir()
|
|
152
|
+
cfg = self._make_cfg(clone, notes)
|
|
153
|
+
|
|
154
|
+
tracks = discover_tracks(cfg)
|
|
155
|
+
names = [t.name for t in tracks]
|
|
156
|
+
self.assertNotIn("old", names)
|
|
157
|
+
|
|
158
|
+
def test_dotfiles_skipped_in_work_plan(self):
|
|
159
|
+
"""Dotfiles inside .work-plan/ are not discovered."""
|
|
160
|
+
with tempfile.TemporaryDirectory() as d:
|
|
161
|
+
base = Path(d)
|
|
162
|
+
clone = _make_git_repo(base / "clone")
|
|
163
|
+
wp = clone / ".work-plan"
|
|
164
|
+
wp.mkdir(parents=True)
|
|
165
|
+
(wp / ".hidden.md").write_text("---\ntrack: hidden\n---\n", encoding="utf-8")
|
|
166
|
+
notes = base / "notes"
|
|
167
|
+
notes.mkdir()
|
|
168
|
+
cfg = self._make_cfg(clone, notes)
|
|
169
|
+
|
|
170
|
+
tracks = discover_tracks(cfg)
|
|
171
|
+
names = [t.name for t in tracks]
|
|
172
|
+
self.assertNotIn(".hidden", names)
|
|
173
|
+
|
|
174
|
+
def test_readme_skipped_in_work_plan(self):
|
|
175
|
+
"""README.md inside .work-plan/ is not discovered as a track."""
|
|
176
|
+
with tempfile.TemporaryDirectory() as d:
|
|
177
|
+
base = Path(d)
|
|
178
|
+
clone = _make_git_repo(base / "clone")
|
|
179
|
+
wp = clone / ".work-plan"
|
|
180
|
+
wp.mkdir(parents=True)
|
|
181
|
+
(wp / "README.md").write_text("# Notes\n", encoding="utf-8")
|
|
182
|
+
notes = base / "notes"
|
|
183
|
+
notes.mkdir()
|
|
184
|
+
cfg = self._make_cfg(clone, notes)
|
|
185
|
+
|
|
186
|
+
tracks = discover_tracks(cfg)
|
|
187
|
+
names = [t.name for t in tracks]
|
|
188
|
+
self.assertNotIn("README", names)
|
|
189
|
+
|
|
190
|
+
def test_invalid_local_path_contributes_no_tracks(self):
|
|
191
|
+
"""Repos with no/invalid local path produce zero shared tracks."""
|
|
192
|
+
with tempfile.TemporaryDirectory() as d:
|
|
193
|
+
base = Path(d)
|
|
194
|
+
notes = base / "notes"
|
|
195
|
+
notes.mkdir()
|
|
196
|
+
cfg = {
|
|
197
|
+
"notes_root": str(notes),
|
|
198
|
+
"repos": {
|
|
199
|
+
"noclone": {
|
|
200
|
+
"github": "org/noclone",
|
|
201
|
+
"local": str(base / "nonexistent"),
|
|
202
|
+
},
|
|
203
|
+
"nolocal": {
|
|
204
|
+
"github": "org/nolocal",
|
|
205
|
+
"local": None,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
# Should return empty list without raising
|
|
210
|
+
tracks = discover_tracks(cfg)
|
|
211
|
+
self.assertEqual(tracks, [])
|
|
212
|
+
|
|
213
|
+
def test_non_git_repo_local_contributes_no_tracks(self):
|
|
214
|
+
"""A local path that exists but has no .git/ is skipped silently."""
|
|
215
|
+
with tempfile.TemporaryDirectory() as d:
|
|
216
|
+
base = Path(d)
|
|
217
|
+
not_a_repo = base / "notarepo"
|
|
218
|
+
not_a_repo.mkdir()
|
|
219
|
+
# Create .work-plan with a track but NO .git dir
|
|
220
|
+
_write_track_md(
|
|
221
|
+
not_a_repo / ".work-plan" / "feat.md", "feat", "org/repo"
|
|
222
|
+
)
|
|
223
|
+
notes = base / "notes"
|
|
224
|
+
notes.mkdir()
|
|
225
|
+
cfg = {
|
|
226
|
+
"notes_root": str(notes),
|
|
227
|
+
"repos": {
|
|
228
|
+
"repo": {
|
|
229
|
+
"github": "org/repo",
|
|
230
|
+
"local": str(not_a_repo),
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
tracks = discover_tracks(cfg)
|
|
235
|
+
self.assertEqual(tracks, [])
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class SharedTrackCollisionTest(unittest.TestCase):
|
|
239
|
+
"""When (repo, name) appears in both shared and private, shared wins + warns."""
|
|
240
|
+
|
|
241
|
+
def test_shared_wins_on_collision(self):
|
|
242
|
+
with tempfile.TemporaryDirectory() as d:
|
|
243
|
+
base = Path(d)
|
|
244
|
+
clone = _make_git_repo(base / "clone")
|
|
245
|
+
# Shared track
|
|
246
|
+
_write_track_md(
|
|
247
|
+
clone / ".work-plan" / "feat-x.md", "feat-x", "org/repo"
|
|
248
|
+
)
|
|
249
|
+
# Private track with SAME name in notes_root
|
|
250
|
+
notes = base / "notes"
|
|
251
|
+
_write_track_md(
|
|
252
|
+
notes / "repo" / "feat-x.md", "feat-x", "org/repo"
|
|
253
|
+
)
|
|
254
|
+
cfg = {
|
|
255
|
+
"notes_root": str(notes),
|
|
256
|
+
"repos": {
|
|
257
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
tracks = discover_tracks(cfg)
|
|
262
|
+
feat_x_tracks = [t for t in tracks if t.name == "feat-x"]
|
|
263
|
+
# Exactly one track survives the collision
|
|
264
|
+
self.assertEqual(len(feat_x_tracks), 1)
|
|
265
|
+
self.assertEqual(feat_x_tracks[0].tier, "shared")
|
|
266
|
+
|
|
267
|
+
def test_collision_emits_one_warning(self):
|
|
268
|
+
with tempfile.TemporaryDirectory() as d:
|
|
269
|
+
base = Path(d)
|
|
270
|
+
clone = _make_git_repo(base / "clone")
|
|
271
|
+
_write_track_md(
|
|
272
|
+
clone / ".work-plan" / "feat-x.md", "feat-x", "org/repo"
|
|
273
|
+
)
|
|
274
|
+
notes = base / "notes"
|
|
275
|
+
_write_track_md(
|
|
276
|
+
notes / "repo" / "feat-x.md", "feat-x", "org/repo"
|
|
277
|
+
)
|
|
278
|
+
cfg = {
|
|
279
|
+
"notes_root": str(notes),
|
|
280
|
+
"repos": {
|
|
281
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
with patch("sys.stderr", new_callable=io.StringIO) as mock_err:
|
|
286
|
+
discover_tracks(cfg)
|
|
287
|
+
output = mock_err.getvalue()
|
|
288
|
+
# One warning about the collision
|
|
289
|
+
self.assertEqual(output.count("WARN:"), 1)
|
|
290
|
+
self.assertIn("feat-x", output)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class SharedTrackSingleOwnerTest(unittest.TestCase):
|
|
294
|
+
"""Frontmatter github.repo that disagrees with folder config → warn, use folder."""
|
|
295
|
+
|
|
296
|
+
def test_frontmatter_repo_disagreement_warns_and_uses_folder(self):
|
|
297
|
+
with tempfile.TemporaryDirectory() as d:
|
|
298
|
+
base = Path(d)
|
|
299
|
+
clone = _make_git_repo(base / "clone")
|
|
300
|
+
# Frontmatter says a DIFFERENT repo than the folder config
|
|
301
|
+
_write_track_md(
|
|
302
|
+
clone / ".work-plan" / "feat.md", "feat", "wrong/repo"
|
|
303
|
+
)
|
|
304
|
+
notes = base / "notes"
|
|
305
|
+
notes.mkdir()
|
|
306
|
+
cfg = {
|
|
307
|
+
"notes_root": str(notes),
|
|
308
|
+
"repos": {
|
|
309
|
+
"myrepo": {"github": "correct/repo", "local": str(clone)},
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
stderr_buf = io.StringIO()
|
|
314
|
+
with patch("sys.stderr", stderr_buf):
|
|
315
|
+
tracks = discover_tracks(cfg)
|
|
316
|
+
|
|
317
|
+
t = next(t for t in tracks if t.name == "feat")
|
|
318
|
+
# Folder config wins
|
|
319
|
+
self.assertEqual(t.repo, "correct/repo")
|
|
320
|
+
# Warning was emitted
|
|
321
|
+
self.assertIn("WARN:", stderr_buf.getvalue())
|
|
322
|
+
self.assertIn("correct/repo", stderr_buf.getvalue())
|
|
323
|
+
|
|
324
|
+
def test_frontmatter_repo_match_no_warning(self):
|
|
325
|
+
with tempfile.TemporaryDirectory() as d:
|
|
326
|
+
base = Path(d)
|
|
327
|
+
clone = _make_git_repo(base / "clone")
|
|
328
|
+
_write_track_md(
|
|
329
|
+
clone / ".work-plan" / "feat.md", "feat", "correct/repo"
|
|
330
|
+
)
|
|
331
|
+
notes = base / "notes"
|
|
332
|
+
notes.mkdir()
|
|
333
|
+
cfg = {
|
|
334
|
+
"notes_root": str(notes),
|
|
335
|
+
"repos": {
|
|
336
|
+
"myrepo": {"github": "correct/repo", "local": str(clone)},
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
stderr_buf = io.StringIO()
|
|
341
|
+
with patch("sys.stderr", stderr_buf):
|
|
342
|
+
discover_tracks(cfg)
|
|
343
|
+
|
|
344
|
+
self.assertNotIn("WARN:", stderr_buf.getvalue())
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class DiscoverArchivedSharedTest(unittest.TestCase):
|
|
348
|
+
"""discover_archived_tracks also scans shared repos' .work-plan/archive/."""
|
|
349
|
+
|
|
350
|
+
def test_shared_archived_track_found(self):
|
|
351
|
+
with tempfile.TemporaryDirectory() as d:
|
|
352
|
+
base = Path(d)
|
|
353
|
+
clone = _make_git_repo(base / "clone")
|
|
354
|
+
_write_track_md(
|
|
355
|
+
clone / ".work-plan" / "archive" / "shipped.md",
|
|
356
|
+
"shipped", "org/repo", status="shipped",
|
|
357
|
+
)
|
|
358
|
+
notes = base / "notes"
|
|
359
|
+
notes.mkdir()
|
|
360
|
+
cfg = {
|
|
361
|
+
"notes_root": str(notes),
|
|
362
|
+
"repos": {
|
|
363
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
archived = discover_archived_tracks(cfg)
|
|
368
|
+
names = [t.name for t in archived]
|
|
369
|
+
self.assertIn("shipped", names)
|
|
370
|
+
t = next(t for t in archived if t.name == "shipped")
|
|
371
|
+
self.assertEqual(t.tier, "shared")
|
|
372
|
+
|
|
373
|
+
def test_private_archived_track_still_found(self):
|
|
374
|
+
"""Existing notes_root archives continue to be discovered."""
|
|
375
|
+
cfg = {
|
|
376
|
+
"notes_root": str(FIXTURES),
|
|
377
|
+
"repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
|
|
378
|
+
}
|
|
379
|
+
archived = discover_archived_tracks(cfg)
|
|
380
|
+
slugs = [a.meta.get("track") for a in archived]
|
|
381
|
+
self.assertIn("old", slugs)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
if __name__ == "__main__":
|
|
385
|
+
unittest.main()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tests for pure verdict classification."""
|
|
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 ManifestScore
|
|
11
|
+
from lib.verdict import classify, Verdict
|
|
12
|
+
|
|
13
|
+
TODAY = date(2026, 5, 30)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _score(sat, tot):
|
|
17
|
+
return ManifestScore(total=tot, satisfied=sat,
|
|
18
|
+
by_kind={"create": (sat, tot), "modify": (0, 0), "test": (0, 0)})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClassifyTest(unittest.TestCase):
|
|
22
|
+
def test_shipped_when_all_files_present(self):
|
|
23
|
+
v = classify(_score(9, 9), checkbox_done=0, checkbox_total=24,
|
|
24
|
+
last_touched=date(2026, 4, 1), today=TODAY)
|
|
25
|
+
self.assertEqual(v.label, "shipped")
|
|
26
|
+
self.assertEqual(v.glyph, "✅")
|
|
27
|
+
self.assertIn("boxes stale", v.rationale) # 0/24 boxes -> stale note
|
|
28
|
+
|
|
29
|
+
def test_shipped_without_stale_note_when_boxes_checked(self):
|
|
30
|
+
v = classify(_score(9, 9), checkbox_done=20, checkbox_total=24,
|
|
31
|
+
last_touched=date(2026, 4, 1), today=TODAY)
|
|
32
|
+
self.assertEqual(v.label, "shipped")
|
|
33
|
+
self.assertNotIn("boxes stale", v.rationale)
|
|
34
|
+
|
|
35
|
+
def test_partial_when_some_files(self):
|
|
36
|
+
v = classify(_score(3, 9), checkbox_done=0, checkbox_total=9,
|
|
37
|
+
last_touched=date(2026, 5, 1), today=TODAY)
|
|
38
|
+
self.assertEqual(v.label, "partial")
|
|
39
|
+
self.assertEqual(v.glyph, "\U0001f7e1")
|
|
40
|
+
|
|
41
|
+
def test_dead_when_no_files_and_stale(self):
|
|
42
|
+
v = classify(_score(0, 9), checkbox_done=0, checkbox_total=9,
|
|
43
|
+
last_touched=date(2026, 1, 1), today=TODAY, dead_days=60)
|
|
44
|
+
self.assertEqual(v.label, "dead")
|
|
45
|
+
self.assertEqual(v.glyph, "\U0001f480")
|
|
46
|
+
|
|
47
|
+
def test_early_not_dead_when_recent(self):
|
|
48
|
+
v = classify(_score(0, 9), checkbox_done=0, checkbox_total=9,
|
|
49
|
+
last_touched=date(2026, 5, 20), today=TODAY, dead_days=60)
|
|
50
|
+
self.assertEqual(v.label, "partial")
|
|
51
|
+
|
|
52
|
+
def test_manifest_less_routes_to_llm(self):
|
|
53
|
+
v = classify(_score(0, 0), checkbox_done=0, checkbox_total=0,
|
|
54
|
+
last_touched=None, today=TODAY)
|
|
55
|
+
self.assertEqual(v.label, "manifest-less")
|
|
56
|
+
self.assertEqual(v.glyph, "\U0001f47b")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
unittest.main()
|