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:
co-authored by
Claude Opus 4.8
parent
e47d4f0463
commit
828cc1c8d8
@@ -632,9 +632,19 @@
|
|||||||
"reason": "Test file — test functions don't require docstrings."
|
"reason": "Test file — test functions don't require docstrings."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/json/entry_limits.py",
|
"file": "tests/test_lint.py",
|
||||||
"standard": "unused_function",
|
"standard": "architecture",
|
||||||
"reason": "Phase 1 of FPLAN-0270: load_entry_limits() is deliberately uncalled — Phase 3 wires it into write_memory_file(). Tests cover it."
|
"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",
|
"file": "tests/test_entry_limits.py",
|
||||||
|
|||||||
@@ -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 diff-templates # Show template differences per branch
|
||||||
drone @memory templates template-status # Show template version and push status
|
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 verify FPLAN-XXXX # Check if plan is vectorized in ChromaDB
|
||||||
drone @memory watch # Auto-rollover watcher daemon (Ctrl+C to stop)
|
drone @memory watch # Auto-rollover watcher daemon (Ctrl+C to stop)
|
||||||
```
|
```
|
||||||
@@ -42,7 +45,8 @@ drone @memory watch # Auto-rollover watcher daemon (Ctrl+
|
|||||||
memory/
|
memory/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── memory.py # Entry point — auto-discovers modules
|
│ ├── 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
|
│ │ ├── rollover.py # Rollover orchestration, status, sync-lines
|
||||||
│ │ ├── search.py # Semantic query routing
|
│ │ ├── search.py # Semantic query routing
|
||||||
│ │ ├── symbolic.py # Fragmented memory extraction and search
|
│ │ ├── symbolic.py # Fragmented memory extraction and search
|
||||||
@@ -51,7 +55,7 @@ memory/
|
|||||||
│ └── handlers/ # 14 handler groups
|
│ └── handlers/ # 14 handler groups
|
||||||
│ ├── archive/ # indexer.py
|
│ ├── archive/ # indexer.py
|
||||||
│ ├── intake/ # plans_processor.py, pool_processor.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
|
│ ├── learnings/ # manager.py
|
||||||
│ ├── monitor/ # detector.py, memory_watcher.py
|
│ ├── monitor/ # detector.py, memory_watcher.py
|
||||||
│ ├── rollover/ # extractor.py, orchestrator.py
|
│ ├── rollover/ # extractor.py, orchestrator.py
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: entry_limits.py
|
# Name: entry_limits.py
|
||||||
# Description: Entry limits config reader for memory files
|
# Description: Entry limits config reader and validator for memory files
|
||||||
# Version: 1.0.0
|
# Version: 1.1.0
|
||||||
# Created: 2026-06-13
|
# Created: 2026-06-13
|
||||||
# Modified: 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
|
Reads the entry_limits section from memory.config.json and returns
|
||||||
the effective limits for a given branch, with per_branch overrides
|
the effective limits for a given branch, with per_branch overrides
|
||||||
deep-merged over the default entry_types.
|
deep-merged over the default entry_types.
|
||||||
|
|
||||||
Phase 1 only: reader + safe defaults. No enforcement, no validation,
|
Provides ``check_entry()`` — a pure validator that checks whether a
|
||||||
no write-path integration.
|
single entry text exceeds its character cap.
|
||||||
|
|
||||||
Usage:
|
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")
|
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
|
import copy
|
||||||
@@ -188,3 +189,57 @@ def load_entry_limits(branch: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return result
|
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,
|
||||||
|
}
|
||||||
@@ -82,7 +82,8 @@ def print_help():
|
|||||||
console.print()
|
console.print()
|
||||||
console.print(
|
console.print(
|
||||||
Panel.fit(
|
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",
|
border_style="cyan",
|
||||||
box=box.ROUNDED,
|
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 process", "Process pool files + check/run rollover")
|
||||||
table.add_row("pool status", "Show pool file count, config, vector stats")
|
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("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)")
|
table.add_row("watch", "Start memory watcher (auto-rollover on changes)")
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
@@ -166,7 +168,8 @@ def print_help():
|
|||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
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()
|
console.print()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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!"
|
||||||
Reference in New Issue
Block a user