feat(memory): FPLAN-0270 Phase 2 — check_entry validator + drone @memory lint (read-only audit)

Pure check_entry(type,text,limits) validator (chars not bytes, boundary at cap,
unknown-type safe) reusable by both gates. New 'drone @memory lint run' scans all
branches' .trinity via registry, handles dict+list containers and both
key_learning value shapes, sorts worst-first — strictly READ-ONLY (never writes/
trims, honors never_trim_s153). Phase-1 unused_function bypass removed (reader now
called). +12 tests (902 total), seedgo 100%. Verified by artifact incl. live lint:
513 over-limit entries across 17 branches (devpulse worst at 71, top offender
5724/600). enforce still false. Part of DPLAN-0205.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-06-13 01:35:58 -07:00
co-authored by Claude Opus 4.8
parent e47d4f0463
commit 828cc1c8d8
7 changed files with 952 additions and 14 deletions
+13 -3
View File
@@ -632,9 +632,19 @@
"reason": "Test file — test functions don't require docstrings."
},
{
"file": "apps/handlers/json/entry_limits.py",
"standard": "unused_function",
"reason": "Phase 1 of FPLAN-0270: load_entry_limits() is deliberately uncalled — Phase 3 wires it into write_memory_file(). Tests cover it."
"file": "tests/test_lint.py",
"standard": "architecture",
"reason": "Test file — lives in tests/ by design, not in 3-layer apps/ structure."
},
{
"file": "tests/test_lint.py",
"standard": "documentation",
"reason": "Test file — test functions don't require docstrings."
},
{
"file": "tests/test_lint.py",
"standard": "meta",
"reason": "Test file — META block present at lines 1-7; hook false-positive on test file format."
},
{
"file": "tests/test_entry_limits.py",
+6 -2
View File
@@ -30,6 +30,9 @@ drone @memory templates push-templates # Push template updates to all branch
drone @memory templates diff-templates # Show template differences per branch
drone @memory templates template-status # Show template version and push status
drone @memory lint # Audit .trinity entries for over-limit violations (read-only)
drone @memory lint @devpulse # Lint a specific branch
drone @memory verify FPLAN-XXXX # Check if plan is vectorized in ChromaDB
drone @memory watch # Auto-rollover watcher daemon (Ctrl+C to stop)
```
@@ -42,7 +45,8 @@ drone @memory watch # Auto-rollover watcher daemon (Ctrl+
memory/
├── apps/
│ ├── memory.py # Entry point — auto-discovers modules
│ ├── modules/ # 5 modules
│ ├── modules/ # 6 modules
│ │ ├── lint.py # Entry limit violation scanner (read-only)
│ │ ├── rollover.py # Rollover orchestration, status, sync-lines
│ │ ├── search.py # Semantic query routing
│ │ ├── symbolic.py # Fragmented memory extraction and search
@@ -51,7 +55,7 @@ memory/
│ └── handlers/ # 14 handler groups
│ ├── archive/ # indexer.py
│ ├── intake/ # plans_processor.py, pool_processor.py
│ ├── json/ # json_handler.py, memory_files.py
│ ├── json/ # json_handler.py, memory_files.py, entry_limits.py, lint_handler.py
│ ├── learnings/ # manager.py
│ ├── monitor/ # detector.py, memory_watcher.py
│ ├── rollover/ # extractor.py, orchestrator.py
@@ -1,26 +1,27 @@
# =================== AIPass ====================
# Name: entry_limits.py
# Description: Entry limits config reader for memory files
# Version: 1.0.0
# Description: Entry limits config reader and validator for memory files
# Version: 1.1.0
# Created: 2026-06-13
# Modified: 2026-06-13
# =============================================
"""
Entry Limits Config Reader
Entry Limits Config Reader & Validator
Reads the entry_limits section from memory.config.json and returns
the effective limits for a given branch, with per_branch overrides
deep-merged over the default entry_types.
Phase 1 only: reader + safe defaults. No enforcement, no validation,
no write-path integration.
Provides ``check_entry()`` — a pure validator that checks whether a
single entry text exceeds its character cap.
Usage:
from aipass.memory.apps.handlers.json.entry_limits import load_entry_limits
from aipass.memory.apps.handlers.json.entry_limits import load_entry_limits, check_entry
limits = load_entry_limits("devpulse")
# => {"enabled": True, "enforce": False, "entry_types": {...}}
verdict = check_entry("key_learnings", some_text, limits)
# => {"ok": True/False, "length": int, "cap": int, "over_by": int, "entry_type": str}
"""
import copy
@@ -188,3 +189,57 @@ def load_entry_limits(branch: str) -> dict[str, Any]:
)
return result
# ---------------------------------------------------------------------------
# Phase 2: pure entry validator
# ---------------------------------------------------------------------------
def check_entry(entry_type: str, text: str, limits: dict[str, Any]) -> dict[str, Any]:
"""Check whether *text* exceeds the character cap for *entry_type*.
This is a **pure function** — no I/O, no file reads, no side effects
(except a debug log when *entry_type* is unknown).
Args:
entry_type: Name of the entry type (e.g. ``"key_learnings"``).
text: The entry text to measure.
limits: The dict returned by :func:`load_entry_limits`.
Returns:
Verdict dict::
{
"ok": bool, # True when within cap (length <= cap)
"length": int, # len(text) — characters, not bytes
"cap": int, # max_chars for this type (0 if unknown)
"over_by": int, # max(0, length - cap)
"entry_type": str, # echo back the entry_type
}
"""
entry_types = limits.get("entry_types", {})
type_def = entry_types.get(entry_type)
length = len(text)
if type_def is None:
logger.info(f"[entry_limits] Unknown entry_type '{entry_type}' — no cap applied")
return {
"ok": True,
"length": length,
"cap": 0,
"over_by": 0,
"entry_type": entry_type,
}
cap = type_def.get("max_chars", 0)
over_by = max(0, length - cap)
return {
"ok": length <= cap,
"length": length,
"cap": cap,
"over_by": over_by,
"entry_type": entry_type,
}
@@ -0,0 +1,220 @@
# =================== AIPass ====================
# Name: lint_handler.py
# Description: Read-only lint handler for .trinity entry limit violations
# Version: 1.0.0
# Created: 2026-06-13
# Modified: 2026-06-13
# =============================================
"""
Lint Handler — Entry Limit Violation Scanner
Scans .trinity memory files across branches and reports entries that
exceed their configured character caps. Strictly **read-only** — never
writes, modifies, truncates, or deletes any file.
Called by the ``lint`` module (thin CLI layer).
"""
import json
from pathlib import Path
from typing import Any
from aipass.prax import logger
from aipass.memory.apps.handlers.json import json_handler
from aipass.memory.apps.handlers.json.entry_limits import (
check_entry,
load_entry_limits,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _measure_dict_container(
data: dict[str, Any],
field: str,
) -> list[tuple[str, str]]:
"""Extract (key, text) pairs from a dict-style container.
Each value may be:
- a plain string (the entry itself), or
- a dict containing *field* (the entry is ``value[field]``).
Returns a list of ``(key, text)`` tuples for measurable entries.
"""
pairs: list[tuple[str, str]] = []
for key, value in data.items():
if isinstance(value, str):
pairs.append((key, value))
elif isinstance(value, dict):
if field in value:
pairs.append((key, value[field]))
return pairs
def _measure_list_container(
data: list[Any],
field: str,
) -> list[tuple[str, str]]:
"""Extract (index-label, text) pairs from a list-style container.
Each item is expected to be a dict containing *field*. Items that
are not dicts or lack the field are silently skipped.
Returns a list of ``("[idx]", text)`` tuples.
"""
pairs: list[tuple[str, str]] = []
for idx, item in enumerate(data):
if isinstance(item, dict) and field in item:
pairs.append((f"[{idx}]", item[field]))
return pairs
# ---------------------------------------------------------------------------
# Core lint logic
# ---------------------------------------------------------------------------
def _lint_branch(
branch_name: str,
branch_path: str,
limits: dict[str, Any],
) -> list[dict[str, Any]]:
"""Lint a single branch and return a list of violation dicts.
Each violation dict has keys:
branch, file, container, key, length, cap, over_by, entry_type
"""
violations: list[dict[str, Any]] = []
trinity_dir = Path(branch_path) / ".trinity"
if not trinity_dir.is_dir():
logger.info(f"[lint] Branch '{branch_name}' has no .trinity directory, skipping")
return violations
entry_types = limits.get("entry_types", {})
for type_name, type_def in entry_types.items():
file_name = type_def.get("file", "")
container = type_def.get("container", "")
kind = type_def.get("kind", "")
field = type_def.get("field", "")
file_path = trinity_dir / file_name
if not file_path.is_file():
logger.info(f"[lint] {branch_name}: missing {file_name}, skipping {type_name}")
continue
try:
raw = file_path.read_text(encoding="utf-8")
data = json.loads(raw)
except (json.JSONDecodeError, OSError) as exc:
logger.warning(f"[lint] {branch_name}: failed to read {file_name}: {exc}")
continue
container_data = data.get(container)
if container_data is None:
continue
# Build (key, text) pairs depending on kind
if kind == "dict" and isinstance(container_data, dict):
pairs = _measure_dict_container(container_data, field)
elif kind == "list" and isinstance(container_data, list):
pairs = _measure_list_container(container_data, field)
else:
continue
for key, text in pairs:
verdict = check_entry(type_name, text, limits)
if not verdict["ok"]:
violations.append(
{
"branch": branch_name,
"file": file_name,
"container": container,
"key": key,
"length": verdict["length"],
"cap": verdict["cap"],
"over_by": verdict["over_by"],
"entry_type": type_name,
}
)
return violations
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def run_lint(
branches: list[dict[str, Any]],
branch_filter: str | None = None,
) -> dict[str, Any]:
"""Scan branches for entry-limit violations.
This function is **read-only** — it never writes, modifies, truncates,
or deletes any file.
Args:
branches: List of branch dicts (``{"name": ..., "path": ...}``),
typically from ``_read_registry()`` in the module layer.
branch_filter: If provided, only lint this branch (case-insensitive).
Returns:
Result dict::
{
"success": True,
"violations": [...], # sorted worst-first (highest over_by)
"total_violations": int,
"branches_scanned": int,
"branches_skipped": int,
}
"""
all_violations: list[dict[str, Any]] = []
branches_scanned = 0
branches_skipped = 0
for branch in branches:
name = branch.get("name", "unknown")
path = branch.get("path", "")
# Apply branch filter (case-insensitive)
if branch_filter and name.lower() != branch_filter.lower():
continue
limits = load_entry_limits(name)
if not limits.get("enabled", True):
branches_skipped += 1
continue
branch_violations = _lint_branch(name, path, limits)
all_violations.extend(branch_violations)
branches_scanned += 1
# Sort worst-first (highest over_by)
all_violations.sort(key=lambda v: v["over_by"], reverse=True)
json_handler.log_operation(
"lint",
{
"total_violations": len(all_violations),
"branches_scanned": branches_scanned,
"branch_filter": branch_filter,
},
module_name="lint",
)
return {
"success": True,
"violations": all_violations,
"total_violations": len(all_violations),
"branches_scanned": branches_scanned,
"branches_skipped": branches_skipped,
}
+5 -2
View File
@@ -82,7 +82,8 @@ def print_help():
console.print()
console.print(
Panel.fit(
"[bold cyan]Memory - Central Memory Archive System[/bold cyan]\n[dim]Vector search, memory rollover, and fragmented memory for AIPass[/dim]",
"[bold cyan]Memory - Central Memory Archive System[/bold cyan]\n"
"[dim]Vector search, memory rollover, and fragmented memory for AIPass[/dim]",
border_style="cyan",
box=box.ROUNDED,
)
@@ -120,6 +121,7 @@ def print_help():
table.add_row("pool process", "Process pool files + check/run rollover")
table.add_row("pool status", "Show pool file count, config, vector stats")
table.add_row("verify <plan_label>", "Check if a plan is vectorized in ChromaDB")
table.add_row("lint [@branch]", "Audit .trinity entries for over-limit violations (read-only)")
table.add_row("watch", "Start memory watcher (auto-rollover on changes)")
console.print(table)
@@ -166,7 +168,8 @@ def print_help():
console.print()
console.print(
"Commands: search, rollover [run|status|check|sync-lines], pool [process|status], symbolic, templates, verify, watch"
"Commands: search, rollover [run|status|check|sync-lines], lint,"
" pool [process|status], symbolic, templates, verify, watch"
)
console.print()
+237
View File
@@ -0,0 +1,237 @@
# =================== AIPass ====================
# Name: lint.py
# Description: Lint module — CLI routing for entry limit auditing
# Version: 1.0.0
# Created: 2026-06-13
# Modified: 2026-06-13
# =============================================
"""
Lint Module — Entry Limit Violation Scanner
Thin CLI routing layer that discovers branches via the registry,
delegates scanning to the lint handler, and formats results for
the console.
Strictly **read-only** — never writes, modifies, truncates, or
deletes any file.
Usage:
drone @memory lint # Scan all branches
drone @memory lint @devpulse # Scan one branch
"""
from typing import Any
from aipass.prax import logger
from aipass.cli.apps.modules import console, error, warning
from aipass.memory.apps.handlers.json import json_handler
# Handler import (same package family — json handlers)
from aipass.memory.apps.handlers.json.lint_handler import run_lint
# Cross-handler access for branch discovery (module layer bridges handlers)
from aipass.memory.apps.handlers.monitor.detector import _read_registry
# =============================================================================
# COMMAND HANDLER
# =============================================================================
def handle_command(command: str, args: list[str]) -> bool:
"""Handle lint commands with seedgo-compliant introspection.
Routing:
lint (no args) -> print_introspection()
lint --help / -h / help -> print_help()
lint @branch -> scan one branch
lint run -> scan all branches
lint run @branch -> scan one branch
Args:
command: Command name.
args: Additional arguments.
Returns:
True if command handled, False otherwise.
"""
if command != "lint":
return False
# No args -> introspection (seedgo standard)
if not args:
print_introspection()
return True
# Help
if args[0] in ("--help", "-h", "help"):
print_help()
return True
# Parse optional @branch filter
branch_filter = _extract_branch(args)
# "run" subcommand is accepted but optional — lint always runs
filtered_args = [a for a in args if a != "run" and not a.startswith("@")]
if filtered_args:
error(
f"Unknown lint argument: {filtered_args[0]}",
suggestion="Run 'drone @memory lint help' for usage",
)
return True
_execute_lint(branch_filter)
return True
# =============================================================================
# ARGUMENT HELPERS
# =============================================================================
def _extract_branch(args: list[str]) -> str | None:
"""Extract @branch from args, return branch name or None."""
for arg in args:
if arg.startswith("@"):
return arg[1:]
return None
# =============================================================================
# LINT EXECUTION
# =============================================================================
def _execute_lint(branch_filter: str | None = None) -> None:
"""Run the lint scan and display results.
Args:
branch_filter: If provided, only lint this branch.
"""
# Branch discovery happens in the module layer (bridges handlers)
try:
branches = _read_registry()
except Exception as exc:
logger.warning(f"[lint] Failed to read registry: {exc}")
error(f"Failed to read registry: {exc}")
return
if not branches:
warning("No branches found in registry")
return
result = run_lint(branches, branch_filter=branch_filter)
if not result.get("success"):
error(result.get("error", "Unknown lint error"))
return
_display_results(result, branch_filter)
# =============================================================================
# DISPLAY
# =============================================================================
def _display_results(result: dict[str, Any], branch_filter: str | None) -> None:
"""Format and display lint results via Rich console.
Args:
result: Result dict from ``run_lint``.
branch_filter: The branch filter used (for display context).
"""
violations = result.get("violations", [])
scanned = result.get("branches_scanned", 0)
skipped = result.get("branches_skipped", 0)
total = result.get("total_violations", 0)
console.print()
if not violations:
scope = f"@{branch_filter}" if branch_filter else "all branches"
console.print(f"[green]No violations found[/green] across {scope} ({scanned} scanned)")
console.print()
return
# Per-violation detail (sorted worst-first by handler)
console.print(f"[bold red]{total} violation(s) found[/bold red]")
console.print()
current_branch: str | None = None
branch_count = 0
for v in violations:
branch = v["branch"]
if branch != current_branch:
if current_branch is not None:
console.print()
console.print(f" [bold cyan]{branch}[/bold cyan]")
current_branch = branch
branch_count = 0
branch_count += 1
console.print(
f" [red]![/red] {v['file']}:{v['container']}/{v['key']} "
f"[dim]({v['entry_type']})[/dim] "
f"{v['length']}/{v['cap']} chars "
f"[red]+{v['over_by']} over[/red]"
)
console.print()
console.print(f"[dim]Scanned {scanned} branch(es), skipped {skipped}[/dim]")
console.print()
json_handler.log_operation(
"lint_display",
{"total_violations": total, "branches_scanned": scanned},
module_name="lint",
)
# =============================================================================
# INTROSPECTION
# =============================================================================
def print_introspection() -> None:
"""Display module introspection (seedgo standard).
Called when ``lint`` is invoked with no arguments.
"""
console.print()
console.print("[bold cyan]lint Module[/bold cyan]")
console.print("Audits .trinity entries for over-limit character violations (read-only)")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/json/[/cyan] [dim]lint_handler.py, entry_limits.py[/dim]")
console.print()
console.print("[yellow]Next:[/yellow]")
console.print(" [green]drone @memory lint run[/green] [dim]# Scan all branches[/dim]")
console.print(" [green]drone @memory lint @devpulse[/green] [dim]# Scan one branch[/dim]")
console.print(" [green]drone @memory lint help[/green] [dim]# Full usage guide[/dim]")
console.print()
def print_help() -> None:
"""Display lint module help."""
console.print()
console.print("[bold cyan]Lint Module - Entry Limit Violation Scanner[/bold cyan]")
console.print()
console.print("[bold]USAGE:[/bold]")
console.print(" drone @memory lint Scan all branches")
console.print(" drone @memory lint @<branch> Scan a specific branch")
console.print(" drone @memory lint run Scan all branches (explicit)")
console.print()
console.print("[bold]WHAT IT DOES:[/bold]")
console.print(" Reads .trinity/local.json and .trinity/observations.json for every")
console.print(" registered branch. Checks each entry against configured character")
console.print(" caps from memory.config.json. Reports violations sorted worst-first.")
console.print()
console.print("[bold]NOTE:[/bold]")
console.print(" This command is strictly [green]read-only[/green]. It never modifies any file.")
console.print()
+409
View File
@@ -0,0 +1,409 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: tests/test_lint.py
# Date: 2026-06-13
# Version: 1.0.0
# Category: memory/tests
# =============================================
"""
Tests for Phase 2 of FPLAN-0270: check_entry validator + lint handler.
Covers:
- check_entry boundary checks (at-cap, cap+1, larger over)
- Character-not-byte counting (em-dash, tree glyphs)
- Unknown entry_type handling
- Dict container measurement (plain string + dict-with-field)
- List container measurement + missing field skip
- Lint handler finds violations with correct counts
- Lint handler is read-only (files unchanged after scan)
"""
import importlib
import json
import sys
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Helpers: fresh-import modules under test with mocks already in place
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _fresh_lint_modules():
"""Drop cached modules so each test gets a fresh import."""
sys.modules.pop("aipass.memory.apps.handlers.json", None)
sys.modules.pop("aipass.memory.apps.handlers.json.json_handler", None)
sys.modules.pop("aipass.memory.apps.handlers.json.entry_limits", None)
sys.modules.pop("aipass.memory.apps.handlers.json.lint_handler", None)
sys.modules.pop("aipass.memory.apps.modules.lint", None)
yield
def _get_entry_limits():
"""Import and return the entry_limits module."""
return importlib.import_module("aipass.memory.apps.handlers.json.entry_limits")
def _get_lint_handler():
"""Import and return the lint_handler module."""
return importlib.import_module("aipass.memory.apps.handlers.json.lint_handler")
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _make_limits(entry_types: dict[str, Any] | None = None) -> dict[str, Any]:
"""Build a limits dict matching the shape returned by load_entry_limits."""
if entry_types is None:
entry_types = {
"key_learnings": {
"file": "local.json",
"container": "key_learnings",
"kind": "dict",
"field": "value",
"max_chars": 10,
},
}
return {"enabled": True, "enforce": False, "entry_types": entry_types}
# ===========================================================================
# 1. check_entry tests
# ===========================================================================
class TestCheckEntryAtCap:
"""length == cap is OK (not over)."""
def test_at_cap_is_ok(self) -> None:
mod = _get_entry_limits()
limits = _make_limits()
text = "a" * 10 # exactly at cap
result = mod.check_entry("key_learnings", text, limits)
assert result["ok"] is True
assert result["length"] == 10
assert result["cap"] == 10
assert result["over_by"] == 0
assert result["entry_type"] == "key_learnings"
class TestCheckEntryCapPlusOne:
"""length == cap+1 is OVER."""
def test_cap_plus_one_is_over(self) -> None:
mod = _get_entry_limits()
limits = _make_limits()
text = "a" * 11 # one over cap
result = mod.check_entry("key_learnings", text, limits)
assert result["ok"] is False
assert result["length"] == 11
assert result["cap"] == 10
assert result["over_by"] == 1
class TestCheckEntryLargerOver:
"""over_by calculation correct for strings well over cap."""
def test_over_by_large(self) -> None:
mod = _get_entry_limits()
limits = _make_limits()
text = "a" * 25 # 15 over cap of 10
result = mod.check_entry("key_learnings", text, limits)
assert result["ok"] is False
assert result["length"] == 25
assert result["over_by"] == 15
class TestCheckEntryCharNotByte:
"""Em-dash is 3 bytes UTF-8 but 1 character -- count chars not bytes."""
def test_em_dash_counts_as_one_char(self) -> None:
mod = _get_entry_limits()
# "a—b" is 3 characters, not 5 bytes
text = "a—b"
assert len(text) == 3
assert len(text.encode("utf-8")) == 5 # prove multi-byte
limits = _make_limits()
result = mod.check_entry("key_learnings", text, limits)
assert result["length"] == 3 # chars, not bytes
assert result["ok"] is True
def test_tree_glyph_counts_as_one_char(self) -> None:
mod = _get_entry_limits()
# tree glyph is multi-byte UTF-8 but one character
text = "a└b"
assert len(text) == 3
assert len(text.encode("utf-8")) == 5
limits = _make_limits()
result = mod.check_entry("key_learnings", text, limits)
assert result["length"] == 3
class TestCheckEntryUnknownType:
"""Unknown entry_type returns ok=True, cap=0."""
def test_unknown_type_always_ok(self) -> None:
mod = _get_entry_limits()
limits = _make_limits()
result = mod.check_entry("nonexistent_type", "any text", limits)
assert result["ok"] is True
assert result["cap"] == 0
assert result["over_by"] == 0
assert result["entry_type"] == "nonexistent_type"
assert result["length"] == len("any text")
# ===========================================================================
# 2. Container handling tests
# ===========================================================================
class TestDictContainerStringValue:
"""Dict container where value is a plain string (key_learnings style)."""
def test_dict_string_value_measured(self, tmp_path: Path) -> None:
handler = _get_lint_handler()
# Build a branch with a dict container whose values are plain strings
trinity = tmp_path / "branch" / ".trinity"
trinity.mkdir(parents=True)
local_data = {
"key_learnings": {
"learn1": "short", # 5 chars, under cap of 10
"learn2": "this is way too long for the cap", # over
},
}
(trinity / "local.json").write_text(json.dumps(local_data), encoding="utf-8")
limits = _make_limits()
violations = handler._lint_branch("test", str(tmp_path / "branch"), limits)
assert len(violations) == 1
assert violations[0]["key"] == "learn2"
assert violations[0]["container"] == "key_learnings"
class TestDictContainerDictValue:
"""Dict container where value is a dict with a field key."""
def test_dict_with_field_measured(self, tmp_path: Path) -> None:
handler = _get_lint_handler()
trinity = tmp_path / "branch" / ".trinity"
trinity.mkdir(parents=True)
local_data = {
"key_learnings": {
"learn1": {"value": "ok", "meta": "x"}, # 2 chars
"learn2": {"value": "this exceeds the limit!!", "meta": "y"}, # over
},
}
(trinity / "local.json").write_text(json.dumps(local_data), encoding="utf-8")
limits = _make_limits(
{
"key_learnings": {
"file": "local.json",
"container": "key_learnings",
"kind": "dict",
"field": "value",
"max_chars": 10,
},
}
)
violations = handler._lint_branch("test", str(tmp_path / "branch"), limits)
assert len(violations) == 1
assert violations[0]["key"] == "learn2"
class TestListContainer:
"""List container (sessions/observations style)."""
def test_list_items_measured(self, tmp_path: Path) -> None:
handler = _get_lint_handler()
trinity = tmp_path / "branch" / ".trinity"
trinity.mkdir(parents=True)
obs_data = {
"observations": [
{"note": "short"}, # 5 chars
{"note": "this observation is way too long for the cap"}, # over
],
}
(trinity / "observations.json").write_text(json.dumps(obs_data), encoding="utf-8")
limits = _make_limits(
{
"obs": {
"file": "observations.json",
"container": "observations",
"kind": "list",
"field": "note",
"max_chars": 10,
},
}
)
violations = handler._lint_branch("test", str(tmp_path / "branch"), limits)
assert len(violations) == 1
assert violations[0]["key"] == "[1]"
assert violations[0]["entry_type"] == "obs"
class TestListContainerMissingField:
"""Missing field in a list item is skipped, no crash."""
def test_missing_field_skipped(self, tmp_path: Path) -> None:
handler = _get_lint_handler()
trinity = tmp_path / "branch" / ".trinity"
trinity.mkdir(parents=True)
obs_data = {
"observations": [
{"note": "short"}, # has field
{"other_key": "no note here"}, # missing field
{"note": "also short"}, # has field
],
}
(trinity / "observations.json").write_text(json.dumps(obs_data), encoding="utf-8")
limits = _make_limits(
{
"obs": {
"file": "observations.json",
"container": "observations",
"kind": "list",
"field": "note",
"max_chars": 100,
},
}
)
# Should not crash, and no violations (all within cap)
violations = handler._lint_branch("test", str(tmp_path / "branch"), limits)
assert len(violations) == 0
# ===========================================================================
# 3. Lint handler integration tests
# ===========================================================================
class TestLintHandlerFindsViolations:
"""Lint handler finds planted violations with correct counts."""
def test_finds_violations(self, tmp_path: Path) -> None:
handler = _get_lint_handler()
# Create a branch with planted over-limit entries
trinity = tmp_path / "branch_a" / ".trinity"
trinity.mkdir(parents=True)
local_data = {
"key_learnings": {
"ok_entry": "fine",
"bad_entry": "x" * 15, # 15 chars, cap 10 -> over by 5
},
}
(trinity / "local.json").write_text(json.dumps(local_data), encoding="utf-8")
branches = [{"name": "branch_a", "path": str(tmp_path / "branch_a")}]
limits = _make_limits()
# Monkeypatch load_entry_limits to return our test limits
with patch.object(handler, "load_entry_limits", return_value=limits):
result = handler.run_lint(branches)
assert result["success"] is True
assert result["total_violations"] == 1
assert result["branches_scanned"] == 1
v = result["violations"][0]
assert v["branch"] == "branch_a"
assert v["key"] == "bad_entry"
assert v["over_by"] == 5
assert v["length"] == 15
assert v["cap"] == 10
class TestLintHandlerReadOnly:
"""Lint handler must be strictly read-only -- files unchanged after scan."""
def test_files_unchanged_after_lint(self, tmp_path: Path) -> None:
handler = _get_lint_handler()
# Create branch with violations
trinity = tmp_path / "branch_b" / ".trinity"
trinity.mkdir(parents=True)
local_data = {
"key_learnings": {
"big": "x" * 50,
},
}
local_path = trinity / "local.json"
local_content = json.dumps(local_data, indent=2)
local_path.write_text(local_content, encoding="utf-8")
obs_data = {
"observations": [
{"note": "y" * 50},
],
}
obs_path = trinity / "observations.json"
obs_content = json.dumps(obs_data, indent=2)
obs_path.write_text(obs_content, encoding="utf-8")
# Read content before lint
local_before = local_path.read_text(encoding="utf-8")
obs_before = obs_path.read_text(encoding="utf-8")
branches = [{"name": "branch_b", "path": str(tmp_path / "branch_b")}]
limits = _make_limits(
{
"key_learnings": {
"file": "local.json",
"container": "key_learnings",
"kind": "dict",
"field": "value",
"max_chars": 10,
},
"observations": {
"file": "observations.json",
"container": "observations",
"kind": "list",
"field": "note",
"max_chars": 10,
},
}
)
with patch.object(handler, "load_entry_limits", return_value=limits):
handler.run_lint(branches)
# Assert files are UNCHANGED
local_after = local_path.read_text(encoding="utf-8")
obs_after = obs_path.read_text(encoding="utf-8")
assert local_before == local_after, "local.json was modified by lint!"
assert obs_before == obs_after, "observations.json was modified by lint!"