11"""Tests for plugin-source/build_plugins.py — the plugin source compilation pipeline.
22
3- These tests exercise the build pipeline end-to-end: render plugin-source/ into a
4- temp tree, verify each manifested file lands at its declared per-platform path,
5- and confirm the drift detector fires when the committed output is perturbed.
3+ The build pipeline has three observable contracts that these tests pin down:
4+
5+ 1. Render is the inverse of check: rendering plugin-source/ into a tree, then
6+ running check_drift against that tree, returns 0.
7+ 2. Render is hermetic: each platform's plugin_root is wiped before write, so
8+ a stale orphan in platform-integrations/<platform>/ is gone after render.
9+ 3. Render is deterministic: rendering twice into the same tree produces
10+ byte-identical output.
11+
12+ Plus targeted tests for the routing conventions (`_<platform>/` prefix, bob's
13+ 1:1 commands generation, the per-platform Jinja context). Where order-sensitive
14+ behavior used to slip in (e.g. "pick the alphabetically-first verbatim entry"),
15+ these tests now address files by name so they don't break when the file tree
16+ shifts.
617
718Refs #219.
819"""
@@ -35,9 +46,35 @@ def build_module():
3546 return _import_build_module ()
3647
3748
49+ @pytest .fixture
50+ def isolated_repo (tmp_path , build_module , monkeypatch ):
51+ """Copy plugin-source/ into tmp_path and monkeypatch REPO_ROOT / PLUGIN_SOURCE_DIR
52+ so render_to and check_drift operate against an isolated tree. Returns tmp_path."""
53+ shutil .copytree (REPO_ROOT / "plugin-source" , tmp_path / "plugin-source" )
54+ monkeypatch .setattr (build_module , "REPO_ROOT" , tmp_path )
55+ monkeypatch .setattr (build_module , "PLUGIN_SOURCE_DIR" , tmp_path / "plugin-source" )
56+ return tmp_path
57+
58+
59+ @pytest .fixture
60+ def rendered_repo (isolated_repo , build_module ):
61+ """isolated_repo + a fresh render — for tests that inspect rendered output."""
62+ build_module .render_to (isolated_repo )
63+ return isolated_repo
64+
65+
66+ def _plugin_root (manifest , platform : str ) -> Path :
67+ """Absolute path of the platform's plugin_root from the (possibly
68+ monkeypatched) manifest. After `isolated_repo` patches `build_module.REPO_ROOT`,
69+ this already points into the test's tmp_path."""
70+ return Path (manifest .platforms [platform ].plugin_root )
71+
72+
3873@pytest .mark .platform_integrations
3974@pytest .mark .unit
4075class TestManifest :
76+ """Manifest sanity — purely structural, no I/O on platform-integrations/."""
77+
4178 def test_manifest_loads_without_error (self , build_module ):
4279 manifest = build_module .load_manifest ()
4380 assert manifest .platforms , "manifest declares no platforms"
@@ -48,7 +85,7 @@ def test_every_manifest_source_exists(self, build_module):
4885 for entry in manifest .files :
4986 assert entry .source .is_file (), f"manifest references missing source: { entry .source } "
5087
51- def test_every_manifest_target_platform_is_declared (self , build_module ):
88+ def test_every_target_platform_is_declared (self , build_module ):
5289 manifest = build_module .load_manifest ()
5390 declared = set (manifest .platforms )
5491 for entry in manifest .files :
@@ -58,95 +95,222 @@ def test_every_manifest_target_platform_is_declared(self, build_module):
5895
5996@pytest .mark .platform_integrations
6097@pytest .mark .unit
61- class TestRender :
62- def test_render_into_temp_dir_produces_every_manifest_target (self , tmp_path , build_module ):
63- """Rendering into a fresh dir should write every declared (file × platform) target."""
64- written = build_module .render_to (tmp_path )
65- assert written , "render produced no output"
98+ class TestRenderInverseOfCheck :
99+ """The headline invariant: render then check is silent and returns 0."""
100+
101+ def test_render_then_check_is_clean (self , isolated_repo , build_module , capsys ):
102+ build_module .render_to (isolated_repo )
103+ rc = build_module .check_drift ()
104+ captured = capsys .readouterr ()
105+ assert rc == 0 , f"check_drift returned { rc } on a fresh render. stderr:\n { captured .err } "
106+ assert captured .err == "" , f"check_drift emitted output on a fresh render:\n { captured .err } "
107+
108+
109+ @pytest .mark .platform_integrations
110+ @pytest .mark .unit
111+ class TestRenderProperties :
112+ def test_render_is_idempotent (self , isolated_repo , build_module ):
113+ """Rendering twice into the same tree must produce byte-identical output."""
114+ build_module .render_to (isolated_repo )
115+ first = {p .relative_to (isolated_repo ): p .read_bytes () for p in (isolated_repo / "platform-integrations" ).rglob ("*" ) if p .is_file ()}
116+ build_module .render_to (isolated_repo )
117+ second = {p .relative_to (isolated_repo ): p .read_bytes () for p in (isolated_repo / "platform-integrations" ).rglob ("*" ) if p .is_file ()}
118+ assert first .keys () == second .keys (), "second render produced a different file set"
119+ for path , body in first .items ():
120+ assert body == second [path ], f"non-deterministic output: { path } "
121+
122+ def test_render_wipes_orphans_under_each_plugin_root (self , isolated_repo , build_module ):
123+ """Stale files under any platform's plugin_root must be removed before write."""
124+ manifest = build_module .load_manifest ()
125+ orphans = []
126+ for platform in manifest .platforms :
127+ root = _plugin_root (manifest , platform )
128+ root .mkdir (parents = True , exist_ok = True )
129+ orphan = root / "leftover-orphan.txt"
130+ orphan .write_text ("stale content" )
131+ orphans .append (orphan )
132+
133+ build_module .render_to (isolated_repo )
134+
135+ for orphan in orphans :
136+ assert not orphan .exists (), f"render did not wipe orphan { orphan } "
137+
138+ def test_every_non_excluded_target_is_rendered (self , rendered_repo , build_module ):
139+ """For every (file, platform) declared by the manifest that the platform
140+ doesn't exclude, the rendered output exists at the expected path."""
66141 manifest = build_module .load_manifest ()
67142 for entry in manifest .files :
68143 for platform in entry .platforms :
69144 cfg = manifest .platforms [platform ]
70- plugin_root_rel = cfg .plugin_root .relative_to (REPO_ROOT )
71- rendered = tmp_path / plugin_root_rel / cfg .rewrite_target (entry .target_rel )
145+ if cfg .excludes (entry .target_rel ):
146+ continue
147+ rendered = _plugin_root (manifest , platform ) / cfg .rewrite_target (entry .target_rel )
72148 assert rendered .is_file (), f"render did not emit { rendered } "
73149
74- def test_verbatim_files_match_source_byte_for_byte (self , tmp_path , build_module ):
75- """Non-template (.py, .md, etc) files should be copied byte-for-byte."""
76- build_module .render_to (tmp_path )
150+ def test_verbatim_files_match_source_byte_for_byte (self , rendered_repo , build_module ):
151+ """Non-template files are copied byte-for-byte (excludes-aware)."""
77152 manifest = build_module .load_manifest ()
78153 for entry in manifest .files :
79154 if build_module ._is_template (entry .source ):
80155 continue
81156 for platform in entry .platforms :
82157 cfg = manifest .platforms [platform ]
83- plugin_root_rel = cfg .plugin_root .relative_to (REPO_ROOT )
84- rendered = tmp_path / plugin_root_rel / cfg .rewrite_target (entry .target_rel )
85- assert filecmp .cmp (entry .source , rendered , shallow = False ), f"verbatim file { rendered } differs from source { entry .source } "
158+ if cfg .excludes (entry .target_rel ):
159+ continue
160+ rendered = _plugin_root (manifest , platform ) / cfg .rewrite_target (entry .target_rel )
161+ assert filecmp .cmp (entry .source , rendered , shallow = False ), f"verbatim mismatch at { rendered } "
162+
163+
164+ @pytest .mark .platform_integrations
165+ @pytest .mark .unit
166+ class TestPerPlatformRouting :
167+ """Files under plugin-source/_<platform>/ ship only to that platform, and the
168+ `_<platform>/` prefix is stripped from the output target."""
169+
170+ def test_underscore_platform_files_route_to_only_that_platform (self , build_module ):
171+ manifest = build_module .load_manifest ()
172+ for src , platforms in build_module ._walk_sources ():
173+ rel = src .relative_to (build_module .PLUGIN_SOURCE_DIR )
174+ head = rel .parts [0 ]
175+ if head .startswith ("_" ) and head [1 :] in manifest .platforms :
176+ expected = (head [1 :],)
177+ assert platforms == expected , f"{ rel } routes to { platforms } , expected { expected } "
178+
179+ def test_underscore_platform_prefix_stripped_from_output (self , rendered_repo , build_module ):
180+ """A file at _<platform>/<rest> renders to <plugin_root>/<rest>, not <plugin_root>/_<platform>/<rest>."""
181+ manifest = build_module .load_manifest ()
182+ for src , platforms in build_module ._walk_sources ():
183+ rel = src .relative_to (build_module .PLUGIN_SOURCE_DIR )
184+ head = rel .parts [0 ]
185+ if not (head .startswith ("_" ) and head [1 :] in manifest .platforms ):
186+ continue
187+ (platform ,) = platforms
188+ target_rel = build_module ._target_for (src )
189+ rendered = _plugin_root (manifest , platform ) / target_rel
190+ assert rendered .is_file (), f"per-platform source { src } did not render to { rendered } "
191+ # And nothing under a `_<platform>/` subpath should appear in the output.
192+ stray = _plugin_root (manifest , platform ) / head
193+ assert not stray .exists (), f"render leaked the `_<platform>/` prefix into { stray } "
194+
195+
196+ @pytest .mark .platform_integrations
197+ @pytest .mark .unit
198+ class TestBobCommandGeneration :
199+ """Bob commands are auto-generated 1:1 from the skills walk; description is
200+ pulled from each skill's SKILL.md.j2 frontmatter and the body uses the
201+ dash-form folder reference (since bob resolves skills by folder name)."""
202+
203+ def _bob_commands_dir (self , rendered_repo , build_module ) -> Path :
204+ manifest = build_module .load_manifest ()
205+ return _plugin_root (manifest , "bob" ) / "commands"
206+
207+ def test_one_command_per_skill (self , rendered_repo , build_module ):
208+ skill_names = sorted (d .name for d in build_module ._discover_skills ())
209+ commands = sorted (p .stem .removeprefix ("evolve-lite-" ) for p in self ._bob_commands_dir (rendered_repo , build_module ).glob ("*.md" ))
210+ assert commands == skill_names , "bob commands are not 1:1 with skills"
211+
212+ def test_command_body_references_dash_form (self , rendered_repo , build_module ):
213+ for cmd_file in self ._bob_commands_dir (rendered_repo , build_module ).glob ("*.md" ):
214+ skill = cmd_file .stem .removeprefix ("evolve-lite-" )
215+ body = cmd_file .read_text ()
216+ assert f"`evolve-lite-{ skill } `" in body , f"{ cmd_file .name } body should reference the dash-form folder"
217+ assert f"evolve-lite:{ skill } " not in body , f"{ cmd_file .name } body should not use the colon form (bob resolves by folder)"
218+
219+ def test_command_description_comes_from_skill_frontmatter (self , rendered_repo , build_module ):
220+ for skill_dir in build_module ._discover_skills ():
221+ description = build_module ._read_skill_description (skill_dir )
222+ cmd_file = self ._bob_commands_dir (rendered_repo , build_module ) / f"evolve-lite-{ skill_dir .name } .md"
223+ assert f"description: { description } \n " in cmd_file .read_text ()
224+
225+ def test_command_frontmatter_has_no_name_field (self , rendered_repo , build_module ):
226+ """Bob's command schema only honors `description` / `argument-hints`;
227+ an explicit `name:` would be silently ignored or rejected."""
228+ for cmd_file in self ._bob_commands_dir (rendered_repo , build_module ).glob ("*.md" ):
229+ text = cmd_file .read_text ()
230+ # Frontmatter is the block between the first two `---` lines.
231+ _ , frontmatter , _ = text .split ("---" , 2 )
232+ assert "\n name:" not in frontmatter , f"{ cmd_file .name } has a `name:` field bob doesn't support"
86233
87234
88235@pytest .mark .platform_integrations
89236@pytest .mark .unit
90237class TestCheckDrift :
91- def test_check_passes_on_clean_committed_tree (self , build_module , capsys ):
92- """The committed platform-integrations/ should match plugin-source/ at HEAD."""
238+ """Drift detection — pin specific failure modes by file name, not by index."""
239+
240+ def test_committed_tree_is_clean (self , build_module , capsys ):
241+ """The real committed platform-integrations/ matches a fresh render of plugin-source/."""
93242 rc = build_module .check_drift ()
94243 captured = capsys .readouterr ()
95- assert rc == 0 , (
96- f"check_drift returned { rc } on a clean tree. stderr:\n { captured .err } \n "
97- f"This means committed platform-integrations/ has drifted from plugin-source/. "
98- f"Run `just compile-plugins` and commit the result."
99- )
244+ assert rc == 0 , f"check_drift returned { rc } . stderr:\n { captured .err } \n Run `just compile-plugins` and commit the result."
100245
101- def test_check_fails_when_committed_file_is_perturbed (self , tmp_path , build_module , monkeypatch , capsys ):
102- """When a committed managed file has been edited, drift detection must fire.
246+ def test_perturbed_template_is_detected_as_drift (self , rendered_repo , build_module , capsys ):
247+ target = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md"
248+ assert target .is_file (), "test prerequisite missing — claude learn/SKILL.md not rendered"
249+ target .write_bytes (target .read_bytes () + b"\n # perturbation\n " )
103250
104- Points the build script at a temp REPO_ROOT whose plugin-source/ matches
105- the real one but whose platform-integrations/ has a perturbed copy of one
106- managed file. Picks a verbatim (non-template) file so we can compare bytes
107- directly without re-rendering.
108- """
109- manifest = build_module .load_manifest ()
110- verbatim_entry = next (e for e in manifest .files if not build_module ._is_template (e .source ))
111- first_platform = verbatim_entry .platforms [0 ]
112- plugin_root_rel = manifest .platforms [first_platform ].plugin_root .relative_to (REPO_ROOT )
251+ rc = build_module .check_drift ()
252+ captured = capsys .readouterr ()
253+ assert rc == 1
254+ assert "drift:" in captured .err
113255
114- fake_root = tmp_path / "fake_repo"
115- fake_plugin_source = fake_root / "plugin-source"
116- shutil .copytree (REPO_ROOT / "plugin-source" , fake_plugin_source )
256+ def test_perturbed_verbatim_file_is_detected_as_drift (self , rendered_repo , build_module , capsys ):
257+ target = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py"
258+ assert target .is_file (), "test prerequisite missing — claude learn/scripts/on_stop.py not rendered"
259+ target .write_bytes (target .read_bytes () + b"\n # perturbation\n " )
117260
118- committed = fake_root / plugin_root_rel / manifest .platforms [first_platform ].rewrite_target (verbatim_entry .target_rel )
119- committed .parent .mkdir (parents = True , exist_ok = True )
120- committed .write_bytes (verbatim_entry .source .read_bytes () + b"\n # perturbation\n " )
261+ rc = build_module .check_drift ()
262+ captured = capsys .readouterr ()
263+ assert rc == 1
264+ assert "drift:" in captured .err
121265
122- monkeypatch .setattr (build_module , "REPO_ROOT" , fake_root )
123- monkeypatch .setattr (build_module , "PLUGIN_SOURCE_DIR" , fake_plugin_source )
266+ def test_perturbed_bob_command_is_detected_as_drift (self , rendered_repo , build_module , capsys ):
267+ """Bob commands are generated, not source-tracked — their drift is also caught."""
268+ target = rendered_repo / "platform-integrations/bob/evolve-lite/commands/evolve-lite-learn.md"
269+ assert target .is_file (), "test prerequisite missing — bob's evolve-lite-learn command not rendered"
270+ target .write_bytes (target .read_bytes () + b"\n # perturbation\n " )
124271
125272 rc = build_module .check_drift ()
126273 captured = capsys .readouterr ()
127- assert rc == 1 , "check_drift should return 1 when a managed file is perturbed"
128- assert "drift:" in captured .err , "drift message should be printed to stderr"
274+ assert rc == 1
275+ assert "drift:" in captured .err
276+
277+ def test_missing_rendered_file_is_detected (self , rendered_repo , build_module , capsys ):
278+ target = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md"
279+ assert target .is_file ()
280+ target .unlink ()
281+
282+ rc = build_module .check_drift ()
283+ captured = capsys .readouterr ()
284+ assert rc == 1
285+ assert "missing managed file:" in captured .err
129286
130287
131288@pytest .mark .platform_integrations
132289@pytest .mark .unit
133290class TestJinjaTemplating :
134- def test_template_renders_with_per_platform_context (self , tmp_path , build_module ):
135- """A .j2 source rendered for two platforms should produce platform-specific output."""
291+ def test_template_renders_with_per_platform_context (self , rendered_repo , build_module ):
292+ """A .j2 source rendered for two non-excluded platforms produces platform-specific output."""
136293 manifest = build_module .load_manifest ()
137- template_entry = next ((e for e in manifest .files if build_module ._is_template (e .source )), None )
138- if template_entry is None or len (template_entry .platforms ) < 2 :
139- pytest .skip ("manifest has no .j2 file shared between two platforms yet" )
294+ candidate = next (
295+ (
296+ e
297+ for e in manifest .files
298+ if build_module ._is_template (e .source )
299+ and sum (1 for p in e .platforms if not manifest .platforms [p ].excludes (e .target_rel )) >= 2
300+ ),
301+ None ,
302+ )
303+ if candidate is None :
304+ pytest .skip ("manifest has no templated source shipped to two non-excluded platforms" )
140305
141- build_module .render_to (tmp_path )
142306 outputs = []
143- for platform in template_entry .platforms :
307+ for platform in candidate .platforms :
144308 cfg = manifest .platforms [platform ]
145- plugin_root_rel = cfg .plugin_root .relative_to (REPO_ROOT )
146- rendered = tmp_path / plugin_root_rel / cfg .rewrite_target (template_entry .target_rel )
309+ if cfg .excludes (candidate .target_rel ):
310+ continue
311+ rendered = _plugin_root (manifest , platform ) / cfg .rewrite_target (candidate .target_rel )
147312 outputs .append (rendered .read_bytes ())
148313
149314 assert any (a != b for a , b in zip (outputs , outputs [1 :])), (
150- "expected at least one pair of platform renderings to differ for a templated source; "
151- "if every platform produces the same bytes, the .j2 file does not actually use its context"
315+ "every platform produced the same bytes for a templated source — the .j2 file is not actually using its per-platform context"
152316 )
0 commit comments