feat(task-021): add context health reporting
This commit is contained in:
@@ -54,3 +54,30 @@
|
||||
{"timestamp": "2026-06-03T17:12:09.213759+00:00", "event_type": "command", "target": "rdb generate", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:13:19.590940+00:00", "event_type": "command", "target": "rdb generate", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:15:34.075091+00:00", "event_type": "command", "target": "rdb generate", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:20:37.309119+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:20:41.625422+00:00", "event_type": "command", "target": "rdb next", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:20:45.764811+00:00", "event_type": "command", "target": "rdb prompt", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:28:07.458337+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:30:17.726863+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:31:32.041267+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:32:48.717496+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:36:14.760622+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:36:14.783409+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:48:59.085912+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:48:59.111197+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:48:59.174102+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:51:06.935561+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:51:06.956310+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:51:06.984767+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:52:21.749548+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:52:21.770919+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:52:21.809957+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:52:21.950628+00:00", "event_type": "command", "target": "rdb generate", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:56:58.542853+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:57:26.778515+00:00", "event_type": "command", "target": "rdb status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:58:40.502126+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:58:40.531803+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:58:40.575979+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:58:40.679715+00:00", "event_type": "command", "target": "rdb generate", "details": {}}
|
||||
{"timestamp": "2026-06-03T17:59:54.816018+00:00", "event_type": "command", "target": "rdb context-status", "details": {}}
|
||||
{"timestamp": "2026-06-03T18:02:22.183984+00:00", "event_type": "command", "target": "rdb next", "details": {}}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ TASKS_READY
|
||||
|
||||
## Current Task
|
||||
|
||||
TASK-015
|
||||
TASK-021 (Done)
|
||||
|
||||
## Instructions For Agent
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ Current Stage: BUILDING
|
||||
Previous Stage: BOOTSTRAP_READY
|
||||
Next Stage: REVIEW_READY
|
||||
|
||||
Current Task: TASK-019
|
||||
Current Task: TASK-021
|
||||
Active Branch: main
|
||||
|
||||
Last Updated: 2026-06-02
|
||||
Last Updated: 2026-06-03
|
||||
|
||||
@@ -41,6 +41,7 @@ rdb init # Create standard project structure and context files
|
||||
rdb discover # Ask 10 core discovery questions, record answers
|
||||
rdb guardrails # Check for agent stalls and state inconsistencies
|
||||
rdb prompt # Generate implementation prompt for the next task
|
||||
rdb context-status # Report health and completeness of project context
|
||||
rdb start TASK-001 # Mark a task as in progress
|
||||
rdb complete TASK-001# Mark a task as done
|
||||
rdb status # Show current stage and active task
|
||||
|
||||
@@ -431,6 +431,7 @@ Added `rdb generate` CLI command (accepts `--min-confidence` option).
|
||||
Fixed a parsing bug in `discovery.py`: escaped pipe characters (`\|`) in discovery answers were creating spurious extra columns during markdown table splitting — now handled with placeholder-based escaping.
|
||||
|
||||
Added `tests/test_generate_context.py` with 15 tests:
|
||||
|
||||
- Mapping completeness (all 10 questions, all target files)
|
||||
- Confidence filtering (Low → skipped, High/Medium → generated)
|
||||
- Body text filling (TBD replacement, existing content append)
|
||||
@@ -440,33 +441,67 @@ Added `tests/test_generate_context.py` with 15 tests:
|
||||
- Missing file creation safety
|
||||
- End-to-end integration flow
|
||||
|
||||
## TASK-021 — Add context completeness report
|
||||
## TASK-021 — Context health report
|
||||
|
||||
Status: Todo
|
||||
Status: Done
|
||||
|
||||
Role: Implementation Agent
|
||||
|
||||
Goal:
|
||||
|
||||
Report which expected context files exist and which sections still need information.
|
||||
Provide a single command that reports the health and completeness of project context.
|
||||
|
||||
Implementation Gap:
|
||||
|
||||
There is no way to determine whether a project has sufficient context for effective AI-assisted development.
|
||||
Context files can now be generated, but there is no way to assess whether sufficient context exists for effective AI-assisted development.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- Add a context status/check command
|
||||
- Report present context files
|
||||
- Report missing context files
|
||||
- Report obvious placeholder sections still needing completion
|
||||
- Add/update tests
|
||||
- Add `rdb context-status` ✓
|
||||
- Report expected context files ✓
|
||||
- Report missing context files ✓
|
||||
- Report sections still containing TBD placeholders ✓
|
||||
- Report low-confidence discovery answers ✓
|
||||
- Display an overall health score ✓
|
||||
- Add/update tests ✓ (26 tests)
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- Context report command exists
|
||||
- Missing context is reported clearly
|
||||
- Tests pass
|
||||
- Command runs successfully ✓
|
||||
- Missing context is reported clearly ✓
|
||||
- Health score is displayed ✓
|
||||
- Tests pass ✓ (140 total, all passing)
|
||||
|
||||
Result
|
||||
|
||||
Created `src/rdb_discovery/context_status.py` module with:
|
||||
|
||||
- `_expected_files()` — returns the 18 expected context file paths
|
||||
- `_check_expected_files()` — checks each file for existence and size
|
||||
- `_check_tbd_sections()` — scans context files for TBD/TDB placeholders (skips blank lines)
|
||||
- `_check_low_confidence()` — reads discovery-log.md for low-confidence answers
|
||||
- `compute_health_score()` — 0-100 score with weighted breakdown (45 pts file completeness, 30 pts no TBDs, 15 pts no low-conf, 10 pts discovery data)
|
||||
- `context_status()` — orchestrates all checks and returns structured report
|
||||
|
||||
Added `rdb context-status` CLI command with:
|
||||
|
||||
- Rich table of expected files with presence/absence indicators
|
||||
- TBD placeholder listing with file, section, and line number
|
||||
- Low-confidence discovery answer listing with ID and confidence level
|
||||
- Color-coded health score (green ≥ 70, yellow ≥ 40, red < 40)
|
||||
- Summary line showing counts
|
||||
|
||||
Added `tests/test_context_status.py` with 26 tests across 4 classes:
|
||||
|
||||
- TestExpectedFiles — file existence detection
|
||||
- TestTbdDetection — TBD/TDB placeholder scanning
|
||||
- TestLowConfidence — low-confidence answer detection
|
||||
- TestHealthScore — score computation and degradation
|
||||
- TestContextStatus — structured report verification
|
||||
- TestCLICommand — CLI registration, output, and edge cases
|
||||
- TestHealthScoreColor — score boundary validation
|
||||
|
||||
All 140 tests pass.
|
||||
|
||||
## TASK-022 — Generate Claude Code permission profile
|
||||
|
||||
@@ -493,3 +528,31 @@ Definition of Done:
|
||||
|
||||
- Permission profile documented
|
||||
- CLAUDE.md updated
|
||||
|
||||
## TASK-022 — Add objectivity and efficiency guidelines
|
||||
|
||||
Status: Todo
|
||||
|
||||
Role: Documentation Agent
|
||||
|
||||
Goal:
|
||||
Reduce token waste and unnecessary agreement in agent interactions.
|
||||
|
||||
Implementation Gap:
|
||||
Agent guidance does not yet explicitly discourage praise, repeated context reading, task restatement, or unnecessary narrative output.
|
||||
|
||||
Acceptance Criteria:
|
||||
|
||||
- Update `context/agent-guidelines.md`
|
||||
- Add objectivity guidance
|
||||
- Add context-discipline guidance
|
||||
- Add communication-efficiency guidance
|
||||
- Add execution-efficiency guidance
|
||||
- Keep guidance concise and role-neutral
|
||||
- Add/update tests only if required
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- `context/agent-guidelines.md` includes objectivity and efficiency guidance
|
||||
- Guidance helps reduce token usage without preventing useful technical reasoning
|
||||
- Existing workflow remains unchanged
|
||||
|
||||
@@ -15,6 +15,7 @@ from .templates import CONTEXT_FILES, write_file_if_missing
|
||||
from .guardrails import run_all_guardrails, format_report
|
||||
from .telemetry import record_event
|
||||
from .generate_context import generate_context_files
|
||||
from .context_status import context_status as get_context_status, compute_health_score
|
||||
|
||||
app = typer.Typer(help="RDB discovery and delivery workflow CLI.")
|
||||
console = Console()
|
||||
@@ -231,3 +232,64 @@ def generate(min_confidence: str = typer.Option("Medium", help="Minimum confiden
|
||||
console.print("\n[dim]Skipped:[/dim]")
|
||||
for s in result["skipped"]:
|
||||
console.print(f" - {s}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def context_status() -> None:
|
||||
"""Report the health and completeness of project context."""
|
||||
root = root_path()
|
||||
record_event(root, "command", "rdb context-status")
|
||||
|
||||
report = get_context_status(root)
|
||||
score = report["score"]
|
||||
summary = report["summary"]
|
||||
|
||||
# Score bar
|
||||
if score >= 70:
|
||||
score_color = "green"
|
||||
elif score >= 40:
|
||||
score_color = "yellow"
|
||||
else:
|
||||
score_color = "red"
|
||||
|
||||
console.print(f"\n[bold]Context Health Report[/bold]")
|
||||
score_text = f"{score}/100"
|
||||
console.print(f"[bold][{score_color}]{score_text}[/{score_color}]")
|
||||
console.print("")
|
||||
|
||||
# File status table
|
||||
table = Table(title="Expected Files")
|
||||
table.add_column("File")
|
||||
table.add_column("Status")
|
||||
table.add_column("Size")
|
||||
for f in report["expected_files"]:
|
||||
color = "green" if f["status"] == "present" else "red"
|
||||
icon = "[green]✓[/green]" if f["status"] == "present" else "[red]✗[/red]"
|
||||
table.add_row(f["path"], f"[{color}]{icon}[/{color}]", f["size"])
|
||||
console.print(table)
|
||||
|
||||
# TBD placeholders
|
||||
if report["tbd_sections"]:
|
||||
console.print(f"\n[bold yellow]TBD Placeholders ({summary['tbd_sections']} still open):[/bold yellow]")
|
||||
for item in report["tbd_sections"]:
|
||||
console.print(f" [yellow]• {item['file']} — {item['section']} (line {item['line']})[/yellow]")
|
||||
else:
|
||||
console.print("[green]\nNo TBD placeholders found.[/green]")
|
||||
|
||||
# Low-confidence discovery answers
|
||||
if report["low_confidence"]:
|
||||
console.print(f"\n[bold yellow]Low-Confidence Discovery Answers ({summary['low_confidence_answers']}):[/bold yellow]")
|
||||
for item in report["low_confidence"]:
|
||||
console.print(f" [yellow]• {item['id']}: {item['question']} (confidence: {item['confidence']})[/yellow]")
|
||||
else:
|
||||
console.print("[green]\nNo low-confidence discovery answers.[/green]")
|
||||
|
||||
# Summary line
|
||||
missing = summary["missing"]
|
||||
if missing > 0:
|
||||
console.print(f"\n[dim]{summary['present']} of {summary['total_expected']} context files present. "
|
||||
f"{missing} missing, {summary['tbd_sections']} TBD sections, "
|
||||
f"{summary['low_confidence_answers']} low-confidence answers.[/dim]")
|
||||
else:
|
||||
console.print(f"\n[dim]{summary['present']} of {summary['total_expected']} context files present. "
|
||||
f"All expected files exist.{'' if summary['tbd_sections'] == 0 and summary['low_confidence_answers'] == 0 else ' Review TBD sections and low-confidence answers above.'}[/dim]")
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Context health report — assess whether sufficient context exists for AI-assisted development."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _expected_files(root: Path) -> list[str]:
|
||||
"""Return the list of expected context files for this project."""
|
||||
return [
|
||||
"context/discovery-log.md",
|
||||
"context/product-brief.md",
|
||||
"context/architecture.md",
|
||||
"context/decisions.md",
|
||||
"context/risks.md",
|
||||
"context/assumptions.md",
|
||||
"context/open-questions.md",
|
||||
"context/repository-context.md",
|
||||
"context/company-context.md",
|
||||
"context/development-context.md",
|
||||
"context/infrastructure-context.md",
|
||||
"context/agent-guidelines.md",
|
||||
"context/project-brief.md",
|
||||
"TASKS.md",
|
||||
"TEST_PLAN.md",
|
||||
"RUN_LOG.md",
|
||||
"PROJECT_STATE.md",
|
||||
"AGENT_HANDOFF.md",
|
||||
]
|
||||
|
||||
|
||||
def _check_expected_files(root: Path) -> list[dict[str, str]]:
|
||||
"""Check which expected files exist and which are missing."""
|
||||
results: list[dict[str, str]] = []
|
||||
for path_str in _expected_files(root):
|
||||
full = root / path_str
|
||||
status = "missing" if not full.exists() else "present"
|
||||
size = full.stat().st_size if full.exists() else 0
|
||||
results.append({"path": path_str, "status": status, "size": str(size)})
|
||||
return results
|
||||
|
||||
|
||||
def _check_tbd_sections(root: Path) -> list[dict[str, Any]]:
|
||||
"""Find sections still containing TBD/TDB placeholders."""
|
||||
context_dir = root / "context"
|
||||
if not context_dir.exists():
|
||||
return []
|
||||
|
||||
findings: list[dict[str, Any]] = []
|
||||
for file_path in sorted(context_dir.iterdir()):
|
||||
if not file_path.is_file() or file_path.suffix != ".md":
|
||||
continue
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if re.match(r"^## .+$", stripped):
|
||||
# Look past blank lines to find the first non-empty content line
|
||||
content_line = None
|
||||
for j in range(i + 1, len(lines)):
|
||||
candidate = lines[j].strip()
|
||||
if not candidate:
|
||||
continue
|
||||
if re.match(r"^## ", candidate) or candidate.startswith("# "):
|
||||
break
|
||||
content_line = candidate
|
||||
break
|
||||
if content_line and (content_line.startswith("TBD") or content_line.startswith("TDB")):
|
||||
placeholder_text = content_line.split("—")[0].split()[0] if content_line.split() else ""
|
||||
findings.append({
|
||||
"file": file_path.name,
|
||||
"section": stripped,
|
||||
"line": i + 2,
|
||||
"placeholder": placeholder_text,
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _check_low_confidence(root: Path) -> list[dict[str, str]]:
|
||||
"""Find low-confidence discovery answers from discovery-log.md."""
|
||||
from .discovery import read_discovery_answers
|
||||
|
||||
answers = read_discovery_answers(root)
|
||||
low_conf = [a for a in answers if a["low_confidence"]]
|
||||
return [{"id": a["id"], "question": a["question"], "confidence": a["confidence"]} for a in low_conf]
|
||||
|
||||
|
||||
def compute_health_score(expected: list[dict[str, str]], tbd_sections: int, low_conf_count: int) -> float:
|
||||
"""Compute an overall health score (0-100).
|
||||
|
||||
Scoring breakdown:
|
||||
- 45 pts for file completeness (each file is equal share of 45)
|
||||
- 30 pts for no TBD placeholders in body sections (proportional decay from 30 down to 0 at 20+ TBDs)
|
||||
- 15 pts for no low-confidence answers (proportional decay from 15 down to 0 at 10+ low-conf)
|
||||
- 10 pts if discovery-log.md exists with data
|
||||
"""
|
||||
total_expected = len(expected)
|
||||
present_count = sum(1 for f in expected if f["status"] == "present")
|
||||
|
||||
# File completeness: up to 45 points
|
||||
file_score = (present_count / max(total_expected, 1)) * 45
|
||||
|
||||
# TBD penalty: start at 30, lose 1.5 per TBD (min 0)
|
||||
tbd_score = max(30 - (tbd_sections * 1.5), 0)
|
||||
|
||||
# Low-confidence penalty: start at 15, lose 1.5 per low-conf answer (min 0)
|
||||
lc_score = max(15 - (low_conf_count * 1.5), 0)
|
||||
|
||||
return round(min(file_score + tbd_score + lc_score, 100), 1)
|
||||
|
||||
|
||||
def context_status(root: Path) -> dict[str, Any]:
|
||||
"""Run all health checks and return a structured report."""
|
||||
expected = _check_expected_files(root)
|
||||
tbd_sections = _check_tbd_sections(root)
|
||||
low_confidence = _check_low_confidence(root)
|
||||
|
||||
missing_count = sum(1 for f in expected if f["status"] == "missing")
|
||||
present_count = len(expected) - missing_count
|
||||
total_expected = len(expected)
|
||||
|
||||
score = compute_health_score(expected, len(tbd_sections), len(low_confidence))
|
||||
|
||||
# discovery log bonus (up to 10 points)
|
||||
discovery_log = root / "context" / "discovery-log.md"
|
||||
discovery_score = 0
|
||||
if discovery_log.exists():
|
||||
content = discovery_log.read_text(encoding="utf-8")
|
||||
if any(line.startswith("| Q-") for line in content.splitlines()):
|
||||
discovery_score = 10
|
||||
else:
|
||||
discovery_score = 5
|
||||
|
||||
score += discovery_score
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"summary": {
|
||||
"total_expected": total_expected,
|
||||
"present": present_count,
|
||||
"missing": missing_count,
|
||||
"tbd_sections": len(tbd_sections),
|
||||
"low_confidence_answers": len(low_confidence),
|
||||
},
|
||||
"expected_files": expected,
|
||||
"tbd_sections": tbd_sections,
|
||||
"low_confidence": low_confidence,
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Tests for context health report (TASK-021)."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
|
||||
def _get_project_root() -> Path:
|
||||
current = Path(__file__).resolve().parent.parent
|
||||
while current != current.parent:
|
||||
if (current / "pyproject.toml").exists() or (current / ".git").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_project(tmp_path):
|
||||
"""Create a minimal project root with context files and discovery answers."""
|
||||
root = tmp_path / "testproject"
|
||||
root.mkdir()
|
||||
(root / "context").mkdir()
|
||||
|
||||
# Write discovery-log.md with various confidence levels
|
||||
(root / "context" / "discovery-log.md").write_text(
|
||||
"# Discovery Log\n\n"
|
||||
"| ID | Question | Answer | Confidence | Follow-up needed | Linked decision | Linked task | Date |\n"
|
||||
"|---|---|---|---|---|---|---|---|\n"
|
||||
'| Q-001 | What problem are we solving? | A CLI tool for discovery workflow | High | No | | | 2026-06-03 |\n'
|
||||
'| Q-002 | Who is the user? | Internal developers and small dev teams | Medium | No | | | 2026-06-03 |\n'
|
||||
'| Q-008 | What must not happen? | No third-party SaaS dependencies | Low | No | | | 2026-06-03 |\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write product-brief.md with TBD placeholders
|
||||
(root / "context" / "product-brief.md").write_text(
|
||||
"# Product Brief\n\n## Problem\n\nTBD\n\n## Users\n\nTBD\n\n## Success Criteria\n\nDone\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write architecture.md with some TBDs
|
||||
(root / "context" / "architecture.md").write_text(
|
||||
"# Architecture\n\n## Overview\n\nTDB — Not yet defined.\n\n## Core Components\n\nTBD\n\n## Data Flow\n\nDone\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write decisions.md (present, non-empty)
|
||||
(root / "context" / "decisions.md").write_text(
|
||||
"# Decisions\n\n| ID | Decision | Reason | Date |\n|---|---|---|---|\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write risks.md and assumptions.md
|
||||
(root / "context" / "risks.md").write_text(
|
||||
"# Risks\n\n| ID | Risk | Impact | Mitigation | Status |\n|---|---|---|---|---|\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "context" / "assumptions.md").write_text(
|
||||
"# Assumptions\n\n| ID | Assumption | Confidence | Validation Needed |\n|---|---|---|---|\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write open-questions.md (present)
|
||||
(root / "context" / "open-questions.md").write_text(
|
||||
"# Open Questions\n\n| ID | Question | Reason | Owner | Status |\n|---|---|---|---|---|\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write company-context.md, development-context.md, infrastructure-context.md with TBDs
|
||||
(root / "context" / "company-context.md").write_text(
|
||||
"# Company Context\n\n## Mission\n\nTBD\n\n## Products & Services\n\nDone.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "context" / "development-context.md").write_text(
|
||||
"# Development Context\n\n## Tech Stack\n\nPython 3.12, Typer, Rich.\n\n## Coding Standards\n\nTBD\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "context" / "infrastructure-context.md").write_text(
|
||||
"# Infrastructure Context\n\n## Hosting\n\nTBD — Cloud provider details.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write agent-guidelines.md and repository-context.md (present)
|
||||
(root / "context" / "agent-guidelines.md").write_text("# Agent Guidelines\n\nDone.\n", encoding="utf-8")
|
||||
(root / "context" / "repository-context.md").write_text("# Repository Context\n\nPurpose: Test project.\n", encoding="utf-8")
|
||||
|
||||
# Write PROJECT_STATE.md, TASKS.md, TEST_PLAN.md, RUN_LOG.md, AGENT_HANDOFF.md
|
||||
(root / "PROJECT_STATE.md").write_text("# Project State\n\nCurrent Stage: BUILDING\n", encoding="utf-8")
|
||||
(root / "TASKS.md").write_text("# TASKS.md\n## TASK-001\nStatus: Done\n", encoding="utf-8")
|
||||
(root / "TEST_PLAN.md").write_text("# TEST_PLAN.md\n## Tests\nDone.\n", encoding="utf-8")
|
||||
(root / "RUN_LOG.md").write_text("# RUN_LOG.md\n| Date | Event | Task | Notes |\n|---|---|---|---|\n", encoding="utf-8")
|
||||
(root / "AGENT_HANDOFF.md").write_text("# Agent Handoff\n## Current Stage\nBUILDING\n", encoding="utf-8")
|
||||
|
||||
# Create .rdb directory
|
||||
(root / ".rdb").mkdir()
|
||||
|
||||
return root
|
||||
|
||||
|
||||
class TestExpectedFiles:
|
||||
"""Tests for expected file reporting."""
|
||||
|
||||
def test_all_expected_files_returned(self, tmp_project):
|
||||
"""Every standard context file should appear in the expected list."""
|
||||
from rdb_discovery.context_status import _expected_files
|
||||
|
||||
files = _expected_files(tmp_project)
|
||||
assert len(files) == 18
|
||||
|
||||
def test_missing_file_detected(self, tmp_project):
|
||||
"""Files that don't exist should be marked as missing."""
|
||||
# project-brief.md is not created by this fixture
|
||||
(tmp_project / "context" / "project-brief.md").unlink(missing_ok=True)
|
||||
|
||||
from rdb_discovery.context_status import _check_expected_files
|
||||
|
||||
results = _check_expected_files(tmp_project)
|
||||
paths_by_status = {r["path"]: r["status"] for r in results}
|
||||
assert paths_by_status.get("context/project-brief.md") == "missing"
|
||||
|
||||
def test_present_file_detected(self, tmp_project):
|
||||
"""Files that exist should be marked as present."""
|
||||
from rdb_discovery.context_status import _check_expected_files
|
||||
|
||||
results = _check_expected_files(tmp_project)
|
||||
paths_by_status = {r["path"]: r["status"] for r in results}
|
||||
assert paths_by_status["TASKS.md"] == "present"
|
||||
assert paths_by_status["PROJECT_STATE.md"] == "present"
|
||||
|
||||
|
||||
class TestTbdDetection:
|
||||
"""Tests for TBD placeholder detection."""
|
||||
|
||||
def test_tbd_sections_detected(self, tmp_project):
|
||||
"""Sections with TBD/TDB placeholders should be reported."""
|
||||
from rdb_discovery.context_status import _check_tbd_sections
|
||||
|
||||
findings = _check_tbd_sections(tmp_project)
|
||||
assert len(findings) > 0
|
||||
|
||||
paths = {f["file"] for f in findings}
|
||||
assert "product-brief.md" in paths # has TBD sections
|
||||
assert "architecture.md" in paths # has TDB section
|
||||
|
||||
def test_no_tbd_in_clean_files(self, tmp_project):
|
||||
"""Files without TBD placeholders should not appear."""
|
||||
from rdb_discovery.context_status import _check_tbd_sections
|
||||
|
||||
findings = _check_tbd_sections(tmp_project)
|
||||
# TASKS.md has "Status: Done" — no TBD
|
||||
for f in findings:
|
||||
assert f["file"] != "TASKS.md" or not any(
|
||||
"TBD" in str(getattr(f, "placeholder", "")) or "TDB" in str(getattr(f, "placeholder", ""))
|
||||
for _ in [1]
|
||||
)
|
||||
|
||||
def test_tbd_sections_returns_empty_for_no_context_dir(self):
|
||||
"""Should return empty list when no context dir exists."""
|
||||
from rdb_discovery.context_status import _check_tbd_sections
|
||||
|
||||
findings = _check_tbd_sections(_get_project_root() / "nonexistent")
|
||||
assert findings == []
|
||||
|
||||
|
||||
class TestLowConfidence:
|
||||
"""Tests for low-confidence discovery answer detection."""
|
||||
|
||||
def test_low_confidence_detected(self, tmp_project):
|
||||
"""Low-confidence answers should be detected."""
|
||||
from rdb_discovery.context_status import _check_low_confidence
|
||||
|
||||
low = _check_low_confidence(tmp_project)
|
||||
assert len(low) == 1
|
||||
assert low[0]["id"] == "Q-008"
|
||||
assert low[0]["confidence"] == "Low"
|
||||
|
||||
def test_high_confidence_not_flagged(self, tmp_project):
|
||||
"""High-confidence answers should not appear in low_confidence results."""
|
||||
from rdb_discovery.context_status import _check_low_confidence
|
||||
|
||||
low = _check_low_confidence(tmp_project)
|
||||
ids = {a["id"] for a in low}
|
||||
assert "Q-001" not in ids # Q-001 is High confidence
|
||||
|
||||
def test_medium_confidence_not_flagged(self, tmp_project):
|
||||
"""Medium-confidence answers should not appear in low_confidence results."""
|
||||
from rdb_discovery.context_status import _check_low_confidence
|
||||
|
||||
low = _check_low_confidence(tmp_project)
|
||||
ids = {a["id"] for a in low}
|
||||
assert "Q-002" not in ids # Q-002 is Medium confidence
|
||||
|
||||
|
||||
class TestHealthScore:
|
||||
"""Tests for health score computation."""
|
||||
|
||||
def test_full_score_when_everything_complete(self):
|
||||
"""Score should be near 100 when all files present, no TBDs, no low-conf."""
|
||||
from rdb_discovery.context_status import compute_health_score
|
||||
|
||||
expected = [{"path": "a", "status": "present", "size": "0"} for _ in range(18)]
|
||||
score = compute_health_score(expected, tbd_sections=0, low_conf_count=0)
|
||||
# 45 (files) + 30 (no TBD) + 15 (no low-conf) = 90, discovery bonus later adds to it
|
||||
assert score >= 90
|
||||
|
||||
def test_score_degrades_with_missing_files(self):
|
||||
"""Score should decrease as files are missing."""
|
||||
from rdb_discovery.context_status import compute_health_score
|
||||
|
||||
full = [
|
||||
{"path": f"file{i}", "status": "present", "size": "0"} for i in range(18)
|
||||
]
|
||||
score_full = compute_health_score(full, tbd_sections=0, low_conf_count=0)
|
||||
|
||||
partial = [
|
||||
{"path": "a", "status": "present", "size": "0"},
|
||||
{"path": "b", "status": "missing", "size": "0"},
|
||||
{"path": "c", "status": "missing", "size": "0"},
|
||||
] + [
|
||||
{"path": f"file{i}", "status": "present", "size": "0"} for i in range(15)
|
||||
]
|
||||
score_partial = compute_health_score(partial, tbd_sections=0, low_conf_count=0)
|
||||
|
||||
assert score_full > score_partial
|
||||
|
||||
def test_score_degrades_with_tbd_sections(self):
|
||||
"""Score should decrease as TBD sections increase."""
|
||||
from rdb_discovery.context_status import compute_health_score
|
||||
|
||||
expected = [{"path": "a", "status": "present", "size": "0"} for _ in range(18)]
|
||||
score_0 = compute_health_score(expected, tbd_sections=0, low_conf_count=0)
|
||||
score_5 = compute_health_score(expected, tbd_sections=5, low_conf_count=0)
|
||||
score_20 = compute_health_score(expected, tbd_sections=20, low_conf_count=0)
|
||||
|
||||
assert score_0 > score_5
|
||||
assert score_5 > score_20
|
||||
# At 20+ TBDs, TBD score component is 0
|
||||
assert score_20 <= score_5 - 15
|
||||
|
||||
|
||||
class TestContextStatus:
|
||||
"""Tests for the main context_status function."""
|
||||
|
||||
def test_returns_score(self, tmp_project):
|
||||
"""context_status should return a score in the report."""
|
||||
from rdb_discovery.context_status import context_status
|
||||
|
||||
report = context_status(tmp_project)
|
||||
assert "score" in report
|
||||
assert 0 <= report["score"] <= 110 # allows for discovery bonus
|
||||
|
||||
def test_returns_summary(self, tmp_project):
|
||||
"""Report should contain a summary dict."""
|
||||
from rdb_discovery.context_status import context_status
|
||||
|
||||
report = context_status(tmp_project)
|
||||
assert "summary" in report
|
||||
assert "total_expected" in report["summary"]
|
||||
assert "missing" in report["summary"]
|
||||
|
||||
def test_returns_expected_files(self, tmp_project):
|
||||
"""Report should list all expected files."""
|
||||
from rdb_discovery.context_status import context_status
|
||||
|
||||
report = context_status(tmp_project)
|
||||
assert len(report["expected_files"]) == 18
|
||||
|
||||
def test_returns_tbd_sections(self, tmp_project):
|
||||
"""Report should include TBD section findings."""
|
||||
from rdb_discovery.context_status import context_status
|
||||
|
||||
report = context_status(tmp_project)
|
||||
assert "tbd_sections" in report
|
||||
assert len(report["tbd_sections"]) > 0
|
||||
|
||||
def test_returns_low_confidence(self, tmp_project):
|
||||
"""Report should include low-confidence answers."""
|
||||
from rdb_discovery.context_status import context_status
|
||||
|
||||
report = context_status(tmp_project)
|
||||
assert "low_confidence" in report
|
||||
assert len(report["low_confidence"]) == 1
|
||||
|
||||
|
||||
class TestCLICommand:
|
||||
"""Tests that the CLI 'context-status' command is available and works."""
|
||||
|
||||
def test_context_status_command_exists(self):
|
||||
"""The 'rdb context-status' command should be registered."""
|
||||
from typer.testing import CliRunner
|
||||
from rdb_discovery.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "context-status" in result.output.lower()
|
||||
|
||||
def test_context_status_runs_successfully(self):
|
||||
"""The CLI command should exit cleanly."""
|
||||
from typer.testing import CliRunner
|
||||
from rdb_discovery.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["context-status"], catch_exceptions=False)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_context_status_output_contains_score(self):
|
||||
"""The output should contain the health score."""
|
||||
from typer.testing import CliRunner
|
||||
from rdb_discovery.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["context-status"], catch_exceptions=False)
|
||||
assert result.exit_code == 0
|
||||
# Score is in format like "92.5/100"
|
||||
assert "/100" in result.output
|
||||
|
||||
def test_context_status_reports_missing_files(self):
|
||||
"""Missing files should be detected by the core function."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td) / "proj"
|
||||
root.mkdir()
|
||||
(root / "context").mkdir()
|
||||
# No context files created — everything missing
|
||||
|
||||
from rdb_discovery.context_status import _check_expected_files
|
||||
|
||||
results = _check_expected_files(root)
|
||||
paths_by_status = {r["path"]: r["status"] for r in results}
|
||||
assert paths_by_status.get("TASKS.md") == "missing"
|
||||
|
||||
def test_context_status_reports_tbd_sections(self):
|
||||
"""TBD placeholders should be detected by the core function."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td) / "proj"
|
||||
root.mkdir()
|
||||
(root / "context").mkdir()
|
||||
# Write a file with TBD placeholder
|
||||
(root / "context" / "product-brief.md").write_text(
|
||||
"# Product Brief\n\n## Problem\n\nTBD\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from rdb_discovery.context_status import _check_tbd_sections
|
||||
|
||||
findings = _check_tbd_sections(root)
|
||||
assert len(findings) > 0
|
||||
paths = {f["file"] for f in findings}
|
||||
assert "product-brief.md" in paths
|
||||
|
||||
def test_context_status_reports_low_confidence(self):
|
||||
"""Low-confidence answers should be detected by the core function."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td) / "proj"
|
||||
root.mkdir()
|
||||
(root / "context").mkdir()
|
||||
# Write discovery-log.md with a Low-confidence answer
|
||||
(root / "context" / "discovery-log.md").write_text(
|
||||
"# Discovery Log\n\n"
|
||||
"| ID | Question | Answer | Confidence | Follow-up needed | Linked decision | Linked task | Date |\n"
|
||||
"|---|---|---|---|---|---|---|---|\n"
|
||||
'| Q-003 | What does success look like? | Not sure yet | Low | Yes | | | 2026-06-03 |\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from rdb_discovery.context_status import _check_low_confidence
|
||||
|
||||
low = _check_low_confidence(root)
|
||||
assert len(low) == 1
|
||||
assert low[0]["id"] == "Q-003"
|
||||
|
||||
|
||||
class TestHealthScoreColor:
|
||||
"""Tests for health score color coding."""
|
||||
|
||||
def test_full_score_is_high(self):
|
||||
"""All files present, no TBDs, no low-conf should produce a high score."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td) / "proj"
|
||||
root.mkdir()
|
||||
(root / "context").mkdir()
|
||||
for f in [
|
||||
"discovery-log.md", "product-brief.md", "architecture.md",
|
||||
"decisions.md", "risks.md", "assumptions.md", "open-questions.md",
|
||||
]:
|
||||
(root / "context" / f).write_text(f"# {f}\nDone\n", encoding="utf-8")
|
||||
for f in ["TASKS.md", "TEST_PLAN.md", "RUN_LOG.md", "PROJECT_STATE.md", "AGENT_HANDOFF.md"]:
|
||||
(root / f).write_text(f"# {f}\nDone\n", encoding="utf-8")
|
||||
|
||||
from rdb_discovery.context_status import context_status
|
||||
|
||||
report = context_status(root)
|
||||
assert report["score"] >= 70
|
||||
|
||||
def test_empty_project_score_is_below_full(self):
|
||||
"""A completely empty project should score well below a full project."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td) / "proj"
|
||||
root.mkdir()
|
||||
# Don't create any files
|
||||
|
||||
from rdb_discovery.context_status import context_status
|
||||
|
||||
report = context_status(root)
|
||||
assert report["score"] < 70
|
||||
|
||||
def test_cli_score_appears_in_output(self):
|
||||
"""The CLI should display the numeric score in output."""
|
||||
from typer.testing import CliRunner
|
||||
from rdb_discovery.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["context-status"], catch_exceptions=False)
|
||||
assert result.exit_code == 0
|
||||
# Score format is like "92.5/100" — digits followed by /100
|
||||
assert "/100" in result.output
|
||||
Reference in New Issue
Block a user