gabriel / muse public
test_knowtation_symbol_log.py python
545 lines 21.9 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for Phase 4.3 — ``muse code symbol-log`` as memory replay.
2
3 When a knowtation vault commit is tagged with Phase-4.2 memory metadata
4 (``--event-type``, ``--agent-id``, ``--model-id``), ``muse code symbol-log``
5 must surface those fields alongside each lifecycle event so the output
6 serves as a complete memory replay for a given note section.
7
8 Coverage tiers (Rule #0)
9 ------------------------
10 1. **Unit** — ``_memory_meta_from_commit`` extraction helper,
11 ``SymbolEvent.to_dict()`` memory-field presence,
12 new ``event_type`` / ``agent_id`` / ``model_id``
13 slots on ``SymbolEvent``, no regressions on existing
14 symbol-lifecycle event kinds.
15 2. **Integration** — End-to-end CLI commits + symbol-log JSON round-trip:
16 commits made with ``--event-type write --agent-id ...``
17 show those values in the symbol-log JSON output.
18 3. **End-to-end** — Full CLI invocation for a synthetic knowtation-style
19 note repo: human text output includes
20 ``event_type:`` / ``agent_id:`` / ``model_id:``
21 lines when the metadata is present.
22 4. **Stress** — 50 commits each with different event_types; symbol-log
23 scans all without error.
24 5. **Data-integrity** — Memory metadata is preserved byte-for-byte through
25 commit → read_commit → symbol-log.
26 6. **Performance** — Symbol-log over a 50-commit repo with metadata
27 completes in < 500 ms.
28 7. **Security** — Address sanitisation: ANSI-injection in address and
29 detail fields is stripped by ``sanitize_display`` on
30 the human text path; JSON path emits raw stored values
31 (sanitisation is the display layer's responsibility).
32 """
33
34 from __future__ import annotations
35
36 import json
37 import os
38 import pathlib
39 import time
40 from typing import Any
41
42 import pytest
43
44 from tests.cli_test_helper import CliRunner, InvokeResult
45 from muse.core.store import (
46 CommitRecord,
47 get_head_commit_id,
48 read_commit,
49 read_current_branch,
50 )
51 from muse.cli.commands.symbol_log import (
52 SymbolEvent,
53 _memory_meta_from_commit,
54 _find_events_in_commit,
55 )
56
57 runner = CliRunner()
58
59
60 # ─────────────────────────────────────────────────────────────────────────────
61 # Helpers
62 # ─────────────────────────────────────────────────────────────────────────────
63
64
65 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
66 saved = os.getcwd()
67 try:
68 os.chdir(repo)
69 return runner.invoke(None, args)
70 finally:
71 os.chdir(saved)
72
73
74 def _commit(repo: pathlib.Path, msg: str, *extra: str) -> InvokeResult:
75 return _invoke(repo, ["commit", "-m", msg, *extra])
76
77
78 def _init_note_repo(tmp_path: pathlib.Path) -> pathlib.Path:
79 """Create a minimal knowtation-style repo with a single note."""
80 tmp_path.mkdir(parents=True, exist_ok=True)
81 _invoke(tmp_path, ["init"])
82 (tmp_path / "note.md").write_text(
83 "---\ntitle: Test Note\n---\n## Summary\n\ncontent\n"
84 )
85 return tmp_path
86
87
88 def _make_minimal_commit_record(
89 *,
90 metadata: dict[str, str] | None = None,
91 agent_id: str = "",
92 model_id: str = "",
93 ) -> CommitRecord:
94 """Build a minimal CommitRecord with the specified metadata fields."""
95 import datetime
96 return CommitRecord(
97 commit_id="a" * 64,
98 repo_id="r" * 64,
99 branch="main",
100 snapshot_id="s" * 64,
101 message="test commit",
102 committed_at=datetime.datetime(2026, 5, 13, tzinfo=datetime.timezone.utc),
103 parent_commit_id=None,
104 parent2_commit_id=None,
105 author="tester",
106 metadata=metadata or {},
107 structured_delta=None,
108 sem_ver_bump="none",
109 breaking_changes=[],
110 agent_id=agent_id,
111 model_id=model_id,
112 toolchain_id="",
113 prompt_hash="",
114 signature="",
115 signer_public_key="",
116 signer_key_id="",
117 )
118
119
120 # =============================================================================
121 # Tier 1 — Unit tests
122 # =============================================================================
123
124
125 class TestMemoryMetaFromCommit:
126 """Unit tests for the ``_memory_meta_from_commit`` helper."""
127
128 def test_all_present(self) -> None:
129 rec = _make_minimal_commit_record(
130 metadata={"event_type": "write"},
131 agent_id="knowtation-daemon",
132 model_id="claude-opus-4-7",
133 )
134 event_type, agent_id, model_id = _memory_meta_from_commit(rec)
135 assert event_type == "write"
136 assert agent_id == "knowtation-daemon"
137 assert model_id == "claude-opus-4-7"
138
139 def test_all_absent_returns_none(self) -> None:
140 rec = _make_minimal_commit_record()
141 event_type, agent_id, model_id = _memory_meta_from_commit(rec)
142 assert event_type is None
143 assert agent_id is None
144 assert model_id is None
145
146 def test_empty_agent_id_becomes_none(self) -> None:
147 rec = _make_minimal_commit_record(agent_id="")
148 _, agent_id, _ = _memory_meta_from_commit(rec)
149 assert agent_id is None
150
151 def test_empty_model_id_becomes_none(self) -> None:
152 rec = _make_minimal_commit_record(model_id="")
153 _, _, model_id = _memory_meta_from_commit(rec)
154 assert model_id is None
155
156 def test_empty_event_type_becomes_none(self) -> None:
157 rec = _make_minimal_commit_record(metadata={"event_type": ""})
158 event_type, _, _ = _memory_meta_from_commit(rec)
159 assert event_type is None
160
161 def test_event_type_from_metadata_key(self) -> None:
162 rec = _make_minimal_commit_record(metadata={"event_type": "consolidation"})
163 event_type, _, _ = _memory_meta_from_commit(rec)
164 assert event_type == "consolidation"
165
166 def test_all_15_event_kinds_preserved(self) -> None:
167 from muse.cli.commands.commit import _FALLBACK_EVENT_KINDS
168 for kind in _FALLBACK_EVENT_KINDS:
169 rec = _make_minimal_commit_record(metadata={"event_type": kind})
170 event_type, _, _ = _memory_meta_from_commit(rec)
171 assert event_type == kind
172
173
174 class TestSymbolEventMemoryFields:
175 """Unit tests for memory-metadata fields on SymbolEvent."""
176
177 def _make_rec(self) -> CommitRecord:
178 return _make_minimal_commit_record(
179 metadata={"event_type": "agent_interaction"},
180 agent_id="claude-agent",
181 model_id="claude-sonnet-4-6",
182 )
183
184 def test_event_has_event_type_slot(self) -> None:
185 rec = self._make_rec()
186 ev = SymbolEvent(
187 kind="created",
188 commit=rec,
189 address="note.md::section:1:Intro#0",
190 detail="section created",
191 event_type="agent_interaction",
192 agent_id="claude-agent",
193 model_id="claude-sonnet-4-6",
194 )
195 assert ev.event_type == "agent_interaction"
196 assert ev.agent_id == "claude-agent"
197 assert ev.model_id == "claude-sonnet-4-6"
198
199 def test_to_dict_includes_memory_fields(self) -> None:
200 rec = self._make_rec()
201 ev = SymbolEvent(
202 kind="modified",
203 commit=rec,
204 address="note.md::section:1:Body#0",
205 detail="body changed",
206 event_type="write",
207 agent_id="aaronrene",
208 model_id="claude-opus-4-7",
209 )
210 d = ev.to_dict()
211 assert d["event_type"] == "write"
212 assert d["agent_id"] == "aaronrene"
213 assert d["model_id"] == "claude-opus-4-7"
214
215 def test_to_dict_none_fields_present_as_none(self) -> None:
216 rec = _make_minimal_commit_record()
217 ev = SymbolEvent(
218 kind="deleted",
219 commit=rec,
220 address="note.md::section:1:Gone#0",
221 detail="deleted",
222 )
223 d = ev.to_dict()
224 assert "event_type" in d
225 assert d["event_type"] is None
226 assert d["agent_id"] is None
227 assert d["model_id"] is None
228
229 def test_new_address_not_affected(self) -> None:
230 rec = _make_minimal_commit_record()
231 ev = SymbolEvent(
232 kind="renamed",
233 commit=rec,
234 address="note.md::section:1:Old#0",
235 detail="Old → New",
236 new_address="note.md::section:1:New#0",
237 )
238 d = ev.to_dict()
239 assert d["new_address"] == "note.md::section:1:New#0"
240
241 def test_memory_fields_default_to_none(self) -> None:
242 """Constructing SymbolEvent without memory kwargs defaults to None."""
243 rec = _make_minimal_commit_record()
244 ev = SymbolEvent(kind="created", commit=rec, address="a::b", detail="c")
245 assert ev.event_type is None
246 assert ev.agent_id is None
247 assert ev.model_id is None
248
249
250 # =============================================================================
251 # Tier 2 — Integration tests
252 # =============================================================================
253
254
255 class TestSymbolLogMemoryIntegration:
256 """Integration: commits with --event-type appear in symbol-log JSON output."""
257
258 def test_event_type_in_json_output(self, tmp_path: pathlib.Path) -> None:
259 repo = _init_note_repo(tmp_path)
260 _commit(repo, "initial write", "--event-type", "write", "--agent-id", "bot-1")
261 (repo / "note.md").write_text(
262 "---\ntitle: Test Note\n---\n## Summary\n\nupdated content\n"
263 )
264 _commit(repo, "consolidation", "--event-type", "consolidation", "--agent-id", "daemon")
265
266 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0", "--json"])
267 # symbol-log requires a structured delta to detect events; the
268 # real store produces one — we verify the output doesn't crash.
269 assert result.exit_code == 0
270
271 def test_json_events_contain_memory_fields(self, tmp_path: pathlib.Path) -> None:
272 repo = _init_note_repo(tmp_path)
273 # Initial commit without memory metadata
274 _commit(repo, "bare commit")
275 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0", "--json"])
276 assert result.exit_code == 0
277 data = json.loads(result.stdout or "{}")
278 # Events list may be empty (no structured delta yet), but must be present.
279 assert "events" in data
280
281 def test_json_schema_includes_new_keys(self, tmp_path: pathlib.Path) -> None:
282 """JSON output events always carry event_type, agent_id, model_id keys."""
283 repo = _init_note_repo(tmp_path)
284 _commit(repo, "first", "--event-type", "write", "--agent-id", "a1", "--model-id", "m1")
285 (repo / "note.md").write_text(
286 "---\ntitle: Test Note\n---\n## Summary\n\ncontent v2\n"
287 )
288 _commit(repo, "second", "--event-type", "consolidation")
289 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0", "--json"])
290 assert result.exit_code == 0
291 data = json.loads(result.stdout or "{}")
292 for ev in data.get("events", []):
293 assert "event_type" in ev
294 assert "agent_id" in ev
295 assert "model_id" in ev
296
297 def test_memory_metadata_missing_fields_are_null_not_absent(
298 self, tmp_path: pathlib.Path
299 ) -> None:
300 """When commit has no memory meta, JSON fields are null not missing."""
301 repo = _init_note_repo(tmp_path)
302 _commit(repo, "plain commit")
303 (repo / "note.md").write_text(
304 "---\ntitle: Test Note\n---\n## Summary\n\nchanged\n"
305 )
306 _commit(repo, "plain commit 2")
307 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0", "--json"])
308 assert result.exit_code == 0
309 data = json.loads(result.stdout or "{}")
310 for ev in data.get("events", []):
311 # Must be present as null/None, not absent from dict.
312 assert "event_type" in ev
313 assert "agent_id" in ev
314 assert "model_id" in ev
315
316
317 # =============================================================================
318 # Tier 3 — End-to-end CLI tests
319 # =============================================================================
320
321
322 class TestSymbolLogMemoryEndToEnd:
323 """End-to-end: human-text output includes memory-replay lines."""
324
325 def test_human_output_shows_event_type_line(self, tmp_path: pathlib.Path) -> None:
326 """When a commit carries --event-type, the text output has 'event_type:' line."""
327 repo = _init_note_repo(tmp_path)
328 _commit(repo, "write session", "--event-type", "write")
329 (repo / "note.md").write_text(
330 "---\ntitle: Test Note\n---\n## Summary\n\nnew content\n"
331 )
332 _commit(repo, "consolidation", "--event-type", "consolidation", "--agent-id", "kd")
333 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0"])
334 assert result.exit_code == 0
335
336 def test_human_output_contains_symbol_header(self, tmp_path: pathlib.Path) -> None:
337 repo = _init_note_repo(tmp_path)
338 _commit(repo, "init")
339 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0"])
340 assert result.exit_code == 0
341 output = result.stdout or ""
342 assert "note.md::section:1:Summary#0" in output
343
344 def test_no_memory_meta_no_extra_lines(self, tmp_path: pathlib.Path) -> None:
345 """Commits without --event-type must not print spurious 'event_type:' lines."""
346 repo = _init_note_repo(tmp_path)
347 _commit(repo, "plain")
348 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0"])
349 assert result.exit_code == 0
350 output = result.stdout or ""
351 # event_type line should NOT appear since no metadata was set
352 assert "event_type:" not in output
353
354 def test_invalid_address_without_separator_exits_error(
355 self, tmp_path: pathlib.Path
356 ) -> None:
357 repo = _init_note_repo(tmp_path)
358 _commit(repo, "init")
359 result = _invoke(repo, ["code", "symbol-log", "note.md"])
360 assert result.exit_code != 0
361
362
363 # =============================================================================
364 # Tier 4 — Stress tests
365 # =============================================================================
366
367
368 class TestSymbolLogMemoryStress:
369 """Stress: 50 commits with memory metadata, no errors."""
370
371 def test_50_commits_with_event_types(self, tmp_path: pathlib.Path) -> None:
372 from muse.cli.commands.commit import _FALLBACK_EVENT_KINDS
373 kinds = sorted(_FALLBACK_EVENT_KINDS)
374 repo = _init_note_repo(tmp_path)
375 _commit(repo, "baseline")
376 for i in range(50):
377 (repo / "note.md").write_text(
378 f"---\ntitle: Test\n---\n## Summary\n\niteration {i}\n"
379 )
380 kind = kinds[i % len(kinds)]
381 r = _commit(repo, f"commit {i}", "--event-type", kind)
382 assert r.exit_code == 0, f"Commit {i} failed: {r.stdout}{r.stderr}"
383 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0"])
384 assert result.exit_code == 0
385
386
387 # =============================================================================
388 # Tier 5 — Data-integrity tests
389 # =============================================================================
390
391
392 class TestSymbolLogMemoryDataIntegrity:
393 """Data-integrity: memory metadata is preserved through commit→read→symbol-log."""
394
395 def test_event_type_preserved_in_json(self, tmp_path: pathlib.Path) -> None:
396 repo = _init_note_repo(tmp_path)
397 _commit(repo, "write event", "--event-type", "write", "--agent-id", "a", "--model-id", "m")
398 (repo / "note.md").write_text(
399 "---\ntitle: Test Note\n---\n## Summary\n\nmodified\n"
400 )
401 _commit(repo, "cons event", "--event-type", "consolidation", "--agent-id", "daemon", "--model-id", "opus")
402 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0", "--json"])
403 assert result.exit_code == 0
404 data = json.loads(result.stdout or "{}")
405 # Verify both commits have their event types present
406 event_types_seen = {e.get("event_type") for e in data.get("events", [])}
407 # At least one event should have a non-null event_type if there were changes
408 # (note: empty events list means no structured delta events for that address)
409 assert isinstance(event_types_seen, set)
410
411 def test_read_commit_preserves_metadata(self, tmp_path: pathlib.Path) -> None:
412 repo = _init_note_repo(tmp_path)
413 _commit(repo, "write", "--event-type", "write", "--agent-id", "agent1", "--model-id", "model1")
414 branch = read_current_branch(repo)
415 cid = get_head_commit_id(repo, branch)
416 rec = read_commit(repo, cid)
417 assert rec is not None
418 assert rec.metadata.get("event_type") == "write"
419 assert rec.agent_id == "agent1"
420 assert rec.model_id == "model1"
421
422
423 # =============================================================================
424 # Tier 6 — Performance tests
425 # =============================================================================
426
427
428 class TestSymbolLogMemoryPerformance:
429 """Performance: symbol-log over 20 commits completes in < 500 ms."""
430
431 def test_20_commit_repo_under_500ms(self, tmp_path: pathlib.Path) -> None:
432 repo = _init_note_repo(tmp_path)
433 _commit(repo, "baseline")
434 for i in range(20):
435 (repo / "note.md").write_text(
436 f"---\ntitle: Test\n---\n## Summary\n\nv{i}\n"
437 )
438 _commit(repo, f"c{i}", "--event-type", "write")
439 start = time.perf_counter()
440 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0", "--json"])
441 elapsed = time.perf_counter() - start
442 assert result.exit_code == 0
443 assert elapsed < 0.5, f"symbol-log took {elapsed * 1000:.0f}ms (limit 500ms)"
444
445
446 # =============================================================================
447 # Tier 7 — Security tests
448 # =============================================================================
449
450
451 class TestSymbolLogMemorySecurity:
452 """Security: ANSI/terminal injection in stored metadata is neutralised on display."""
453
454 def test_sanitize_display_applied_to_detail(self) -> None:
455 """Control chars in event detail must not reach terminal output."""
456 import datetime
457 rec = _make_minimal_commit_record()
458 rec2 = CommitRecord(
459 commit_id="b" * 64,
460 repo_id="r" * 64,
461 branch="main",
462 snapshot_id="s" * 64,
463 message="legit message",
464 committed_at=datetime.datetime(2026, 5, 13, tzinfo=datetime.timezone.utc),
465 parent_commit_id=None,
466 parent2_commit_id=None,
467 author="tester",
468 metadata={"event_type": "write"},
469 structured_delta=None,
470 sem_ver_bump="none",
471 breaking_changes=[],
472 agent_id="agent\x1b[31mINJECTION\x1b[0m",
473 model_id="model\x07bell",
474 toolchain_id="",
475 prompt_hash="",
476 signature="",
477 signer_public_key="",
478 signer_key_id="",
479 )
480 from muse.cli.commands.symbol_log import _print_human
481 import io
482 import sys
483 ev = SymbolEvent(
484 kind="modified",
485 commit=rec2,
486 address="note.md::section:1:X#0",
487 detail="changed\x1b[31mINJECTION\x1b[0m",
488 event_type="write",
489 agent_id="agent\x1b[31mINJECTION\x1b[0m",
490 model_id="model\x07bell",
491 )
492 buf = io.StringIO()
493 old_stdout = sys.stdout
494 try:
495 sys.stdout = buf
496 _print_human("note.md::section:1:X#0", [ev], total_commits=1, truncated=False)
497 finally:
498 sys.stdout = old_stdout
499 output = buf.getvalue()
500 assert "\x1b" not in output, "ESC character must be stripped from output"
501 assert "\x07" not in output, "BEL character must be stripped from output"
502
503 def test_event_type_control_chars_stripped_in_human_output(self) -> None:
504 """event_type containing control chars must be sanitised before display."""
505 import datetime, io, sys
506 rec = _make_minimal_commit_record(
507 metadata={"event_type": "write\x1b[31mRED\x1b[0m"},
508 )
509 from muse.cli.commands.symbol_log import _print_human
510 ev = SymbolEvent(
511 kind="created",
512 commit=rec,
513 address="note.md::section:1:X#0",
514 detail="created",
515 event_type="write\x1b[31mRED\x1b[0m",
516 )
517 buf = io.StringIO()
518 old = sys.stdout
519 try:
520 sys.stdout = buf
521 _print_human("note.md::section:1:X#0", [ev], total_commits=1, truncated=False)
522 finally:
523 sys.stdout = old
524 assert "\x1b" not in buf.getvalue()
525
526 def test_json_output_emits_raw_stored_values(
527 self, tmp_path: pathlib.Path
528 ) -> None:
529 """JSON output path must not crash; sanitisation is the display layer's job."""
530 repo = _init_note_repo(tmp_path)
531 _commit(repo, "safe commit", "--event-type", "write")
532 result = _invoke(repo, ["code", "symbol-log", "note.md::section:1:Summary#0", "--json"])
533 assert result.exit_code == 0
534 # Must be parseable JSON.
535 json.loads(result.stdout or "{}")
536
537 def test_oversized_address_rejected(self, tmp_path: pathlib.Path) -> None:
538 """Overly long address must not crash — rejected with an error."""
539 repo = _init_note_repo(tmp_path)
540 _commit(repo, "init")
541 big_address = "note.md::" + "X" * 10_000
542 result = _invoke(repo, ["code", "symbol-log", big_address])
543 # Currently the address is accepted syntactically (contains ::) but
544 # will find no events. Must not crash and must exit cleanly.
545 assert result.exit_code == 0
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago