diff --git a/default/agents/write-plan.md b/default/agents/write-plan.md index e03f3b44..961b2d73 100644 --- a/default/agents/write-plan.md +++ b/default/agents/write-plan.md @@ -23,9 +23,6 @@ output_schema: - name: "Global Prerequisites" pattern: "^\\*\\*Global Prerequisites:\\*\\*" required: true - - name: "Historical Precedent" - pattern: "^## Historical Precedent" - required: true - name: "Task" pattern: "^### Task \\d+:" required: true @@ -86,94 +83,6 @@ You are a specialized agent that writes detailed implementation plans. Your plan - Implementation agents apply language-specific standards (golang.md, typescript.md, etc.) - This separation of concerns prevents planning from being language-coupled -## Historical Precedent Integration - -**MANDATORY:** Before creating any plan, query the artifact index for historical context. - -### Query Process - -**Step 1: Extract and sanitize topic keywords from the planning request** - -From the user's request, extract 3-5 keywords that describe the feature/task. - -**Keyword Sanitization (MANDATORY):** -- Keep only: alphanumeric characters, hyphens, spaces -- Remove: punctuation, shell metacharacters (`;`, `$`, `` ` ``, `|`, `&`, `\`) -- Example: "Implement OAuth2 authentication with JWT tokens!" → keywords: "authentication oauth jwt tokens" - -**Step 2: Query for precedent** - -Run: -```bash -# Keywords MUST be sanitized (alphanumeric, hyphens, spaces only) -python3 default/lib/artifact-index/artifact_query.py --mode planning "sanitized keywords" --json -``` - -**Step 3: Interpret results** - -| Result | Action | -|--------|--------| -| `is_empty_index: true` | Proceed without historical context (normal for new projects) | -| `successful_handoffs` not empty | Reference these patterns in plan, note what worked | -| `failed_handoffs` not empty | WARN in plan about these failure patterns, design to avoid them | -| `relevant_plans` not empty | Review for approach ideas, avoid duplicating past mistakes | - -**Step 4: Add Historical Precedent section to plan** - -After the plan header and before the first task, include: - -```markdown -## Historical Precedent - -**Query:** "[keywords used]" -**Index Status:** [Populated / Empty (new project)] - -### Successful Patterns to Reference -- **[session/task-N]**: [Brief summary of what worked] -- **[session/task-M]**: [Brief summary of what worked] - -### Failure Patterns to AVOID -- **[session/task-X]**: [What failed and why - DESIGN TO AVOID THIS] -- **[session/task-Y]**: [What failed and why - DESIGN TO AVOID THIS] - -### Related Past Plans -- `[plan-file.md]`: [Brief relevance note] - ---- -``` - -### Empty Index Handling - -If the index is empty (new project): - -```markdown -## Historical Precedent - -**Query:** "[keywords used]" -**Index Status:** Empty (new project) - -No historical data available. This is normal for new projects. -Proceeding with standard planning approach. - ---- -``` - -### Performance Requirement - -The precedent query MUST complete in <200ms. If it takes longer: -- Reduce keyword count -- Use more specific terms -- Report performance issue in plan - -### Anti-Rationalization - -| Rationalization | Why It's WRONG | Required Action | -|-----------------|----------------|-----------------| -| "Skip precedent query, feature is unique" | Every feature has relevant history. Query anyway. | **ALWAYS query before planning** | -| "Index is empty, don't bother" | Empty index is valid result - document it | **Always include Historical Precedent section** | -| "Query was slow, skip it" | Slow query indicates issue - report it | **Report performance, still include results** | -| "No relevant results" | Document that fact for future reference | **Include section even if empty** | - ## Blocker Criteria - STOP and Report **You MUST distinguish between decisions you can make and situations requiring escalation.** @@ -414,24 +323,10 @@ git status # Expected: clean working tree pytest --version # Expected: 7.0+ ``` -## Historical Precedent - -**Query:** "[keywords from request]" -**Index Status:** [Populated / Empty] - -### Successful Patterns to Reference -[From artifact query results] - -### Failure Patterns to AVOID -[From artifact query results - CRITICAL: Design to avoid these] - -### Related Past Plans -[From artifact query results] - --- ``` -Adapt the prerequisites, verification commands, and historical precedent to the actual request. +Adapt the prerequisites and verification commands to the actual request. ## Task Structure Template @@ -574,8 +469,6 @@ Add this step after every 3-5 tasks (or after significant features): Before saving the plan, verify: -- [ ] **Historical precedent queried** (artifact-query --mode planning) -- [ ] Historical Precedent section included in plan - [ ] Header with goal, architecture, tech stack, prerequisites - [ ] Verification commands with expected output - [ ] Tasks broken into bite-sized steps (2-5 min each) @@ -586,7 +479,6 @@ Before saving the plan, verify: - [ ] Code review checkpoints after batches - [ ] Severity-based issue handling documented - [ ] Passes Zero-Context Test -- [ ] **Plan avoids known failure patterns** (if any found in precedent) ## After Saving the Plan diff --git a/default/commands/compound-learnings.md b/default/commands/compound-learnings.md deleted file mode 100644 index 5955348c..00000000 --- a/default/commands/compound-learnings.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: ring:compound-learnings -description: Analyze session learnings and propose new rules/skills -argument-hint: "[--approve ] [--reject ] [--list]" ---- - -Analyze accumulated session learnings to detect recurring patterns and propose new rules, skills, or hooks. Requires user approval before creating any permanent artifacts. - -## Usage - -``` -/compound-learnings [options] -``` - -## Options - -| Option | Description | -|--------|-------------| -| (none) | Analyze learnings and generate new proposals | -| `--list` | List pending proposals without analyzing | -| `--approve ` | Approve a specific proposal (e.g., `--approve proposal-1`) | -| `--reject ` | Reject a specific proposal with optional reason | -| `--history` | Show history of approved/rejected proposals | - -## Examples - -### Analyze Learnings (Default) -``` -/compound-learnings -``` -Analyzes all session learnings, detects patterns appearing 3+ times, and presents proposals. - -### List Pending Proposals -``` -/compound-learnings --list -``` -Shows proposals waiting for approval without re-analyzing. - -### Approve a Proposal -``` -/compound-learnings --approve proposal-1 -``` -Creates the rule/skill from the approved proposal. - -### Reject a Proposal -``` -/compound-learnings --reject proposal-2 "Too project-specific" -``` -Marks proposal as rejected with reason. - -### View History -``` -/compound-learnings --history -``` -Shows all past approvals and rejections. - -## Process - -The command follows this workflow: - -### 1. Gather (Automatic) -- Reads `.ring/cache/learnings/*.md` files -- Counts total sessions available - -### 2. Analyze (Automatic) -- Extracts patterns from What Worked, What Failed, Key Decisions -- Consolidates similar patterns using fuzzy matching -- Detects patterns appearing in 3+ sessions - -### 3. Categorize (Automatic) -- Classifies patterns as rule, skill, or hook candidates -- Generates preview content for each - -### 4. Propose (Interactive) -- Presents each qualifying pattern -- Shows draft content -- Waits for your decision - -### 5. Create (On Approval) -- Creates rule/skill/hook in appropriate location -- Updates proposal history -- Archives processed learnings - -## Output Example - -```markdown -## Compound Learnings Analysis - -**Sessions Analyzed:** 12 -**Patterns Detected:** 8 -**Qualifying (3+ sessions):** 3 - ---- - -### Proposal 1: Always use explicit file paths - -**Signal:** 5 sessions (abc, def, ghi, jkl, mno) -**Category:** Rule -**Sources:** patterns, what_worked - -**Preview:** -# Always use explicit file paths -## Pattern -Never use relative paths like "./file" - always use absolute paths. -... - -**Action Required:** Type `approve`, `reject`, or `modify` - ---- -``` - -## Related Commands/Skills - -| Command/Skill | Relationship | -|---------------|--------------| -| `compound-learnings` skill | Underlying skill with full process details | -| `ring:handoff-tracking` | Provides source data (what_worked, what_failed) | -| `artifact-query` | Search past handoffs for context | - -## Troubleshooting - -### "No learnings found" -No `.ring/cache/learnings/*.md` files exist. Complete some sessions with outcome tracking enabled. - -### "Insufficient data for patterns" -Fewer than 3 learning files. Continue working - patterns emerge after multiple sessions. - -### "Pattern already exists" -Check `.ring/generated/rules/` and `.ring/generated/skills/` in your project, or `default/rules/` and `default/skills/` in the plugin - a similar rule or skill may already exist. - -### "Proposal not found" -The proposal ID doesn't exist in pending.json. Run `/compound-learnings --list` to see current proposals. diff --git a/default/commands/query-artifacts.md b/default/commands/query-artifacts.md deleted file mode 100644 index 03de469d..00000000 --- a/default/commands/query-artifacts.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -name: ring:query-artifacts -description: Search the Artifact Index for relevant historical context -argument-hint: " [--type TYPE] [--outcome OUTCOME]" ---- - -Search the Artifact Index for relevant handoffs, plans, and continuity ledgers using FTS5 full-text search with BM25 ranking. - -## Usage - -``` -/query-artifacts [options] -``` - -## Arguments - -| Argument | Required | Description | -|----------|----------|-------------| -| `search-terms` | Yes | Keywords to search for (e.g., "authentication OAuth", "error handling") | -| `--mode` | No | Query mode: `search` (default) or `planning` (structured precedent) | -| `--type` | No | Filter by type: `handoffs`, `plans`, `continuity`, `all` (default: all) | -| `--outcome` | No | Filter handoffs: `SUCCEEDED`, `PARTIAL_PLUS`, `PARTIAL_MINUS`, `FAILED` | -| `--limit` | No | Maximum results per category (1-100, default: 5) | - -## Examples - -### Search for Authentication Work - -``` -/query-artifacts authentication OAuth JWT -``` - -Returns handoffs, plans, and continuity ledgers related to authentication. - -### Find Successful Implementations - -``` -/query-artifacts API design --outcome SUCCEEDED -``` - -Returns only handoffs that were marked as successful. - -### Search Plans Only - -``` -/query-artifacts context management --type plans -``` - -Returns only matching plan documents. - -### Limit Results - -``` -/query-artifacts testing --limit 3 -``` - -Returns at most 3 results per category. - -### Planning Mode (Structured Precedent) - -``` -/query-artifacts api rate limiting --mode planning -``` - -Returns structured precedent for creating implementation plans: -- **Successful Implementations** - What worked (reference these) -- **Failed Implementations** - What failed (AVOID these patterns) -- **Relevant Past Plans** - Similar approaches - -This mode is used automatically by `/ring:write-plan` to inform new plans with historical context. - -## Output - -Results are displayed in markdown format: - -```markdown -## Relevant Handoffs -### [OK] session-name/task-01 -**Summary:** Implemented OAuth2 authentication... -**What worked:** Token refresh mechanism... -**What failed:** Initial PKCE implementation... -**File:** `/path/to/handoff.md` - -## Relevant Plans -### OAuth2 Integration Plan -**Overview:** Implement OAuth2 with PKCE flow... -**File:** `/path/to/plan.md` - -## Related Sessions -### Session: auth-implementation -**Goal:** Add authentication to the API... -**Key learnings:** Use refresh tokens... -**File:** `/path/to/ledger.md` -``` - -## Index Management - -### Check Index Statistics - -``` -/query-artifacts --stats -``` - -Shows counts of indexed artifacts. - -### Initialize/Rebuild Index - -If the index is empty or out of date: - -```bash -python3 default/lib/artifact-index/artifact_index.py --all -``` - -## Related Commands/Skills - -| Command/Skill | Relationship | -|---------------|--------------| -| `/ring:write-plan` | Query before planning to inform decisions | -| `/ring:create-handoff` | Creates handoffs that get indexed | -| `artifact-query` | The underlying skill | -| `ring:writing-plans` | Uses query results for RAG-enhanced planning | - -## Troubleshooting - -### "Database not found" - -The artifact index hasn't been initialized. Run: - -```bash -python3 default/lib/artifact-index/artifact_index.py --all -``` - -### "No results found" - -1. Check that artifacts exist: `ls docs/handoffs/ docs/plans/` -2. Re-index: `python3 default/lib/artifact-index/artifact_index.py --all` -3. Try broader search terms - -### Slow queries - -Queries should complete in < 100ms. If slow: - -1. Check database size: `ls -la .ring/cache/artifact-index/` -2. Rebuild indexes: `python3 default/lib/artifact-index/artifact_index.py --all` - ---- - -## MANDATORY: Load Full Skill - -**This command MUST load the skill for complete workflow execution.** - -``` -Use Skill tool: ring:artifact-query -``` - -The skill contains the complete workflow with: -- Query formulation guidance -- Mode selection (search, planning) -- Result interpretation -- Learnings application -- Index initialization diff --git a/default/docs/CONTEXT_WARNINGS.md b/default/docs/CONTEXT_WARNINGS.md deleted file mode 100644 index 33c8660a..00000000 --- a/default/docs/CONTEXT_WARNINGS.md +++ /dev/null @@ -1,163 +0,0 @@ -# Context Usage Warnings - -Ring monitors your estimated context usage and provides proactive warnings before you hit Claude's context limits. - -## How It Works - -A UserPromptSubmit hook estimates context usage based on: -- Turn count in current session -- Conservative token-per-turn estimates - -Warnings are injected into the conversation at these thresholds: - -| Threshold | Level | What Happens | -|-----------|-------|--------------| -| 50% | Info | Gentle reminder to consider summarizing | -| 70% | Warning | Recommend creating continuity ledger | -| 85% | Critical | Urgent - must clear context soon | - -## Warning Behavior - -- **50% and 70% warnings** are shown once per session -- **85% critical warnings** are always shown (safety feature) -- Warnings reset when you run `/clear` or start a new session - -## Resetting Warnings - -If you've dismissed a warning but want to see it again: - -```bash -# Method 1: Clear context (recommended) -# Run /clear in Claude Code - -# Method 2: Force reset via environment -export RING_RESET_CONTEXT_WARNING=1 -# Then submit your next prompt -``` - -## Estimation Formula - -The estimation uses conservative defaults: - -| Constant | Value | Description | -|----------|-------|-------------| -| TOKENS_PER_TURN | 2,500 | Average tokens per conversation turn | -| BASE_SYSTEM_OVERHEAD | 45,000 | System prompt, tools, etc. | -| DEFAULT_CONTEXT_SIZE | 200,000 | Claude's typical context window | - -**Formula:** `percentage = (45000 + turns * 2500) * 100 / 200000` - -| Turn Count | Estimated % | Tier | -|------------|-------------|------| -| 1 | 23% | none | -| 22 | 50% | info | -| 38 | 70% | warning | -| 50 | 85% | critical | - -## Customization - -### Adjusting Thresholds - -Edit `shared/lib/context-check.sh` and modify the `get_warning_tier` function: - -```bash -get_warning_tier() { - local pct="${1:-0}" - - if [[ "$pct" -ge 85 ]]; then # Adjust critical threshold - echo "critical" - elif [[ "$pct" -ge 70 ]]; then # Adjust warning threshold - echo "warning" - elif [[ "$pct" -ge 50 ]]; then # Adjust info threshold - echo "info" - else - echo "none" - fi -} -``` - -### Adjusting Estimation - -Modify the constants in `shared/lib/context-check.sh`: - -```bash -readonly TOKENS_PER_TURN=2500 # Average tokens per turn -readonly BASE_SYSTEM_OVERHEAD=45000 # System prompt overhead -readonly DEFAULT_CONTEXT_SIZE=200000 # Total context window -``` - -### Disabling Warnings - -To disable context warnings entirely, remove the hook from `default/hooks/hooks.json`: - -```json -{ - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/claude-md-reminder.sh" - } - // Remove context-usage-check.sh entry - ] - } - ] - } -} -``` - -## State Files - -Context state is stored in: -- `.ring/state/context-usage-{session_id}.json` (if `.ring/` exists) -- `/tmp/context-usage-{session_id}.json` (fallback) - -State files are automatically cleaned up on session start. - -## Performance - -The hook is designed to execute in <50ms to avoid slowing down prompts. Performance is achieved by: -- Simple turn-count based estimation (no API calls) -- Atomic file operations -- Minimal JSON processing - -## Troubleshooting - -### Warnings not appearing - -1. Check hook is registered: - ```bash - cat default/hooks/hooks.json | grep context-usage - ``` - -2. Check hook is executable: - ```bash - ls -la default/hooks/context-usage-check.sh - ``` - -3. Run hook manually: - ```bash - echo '{"prompt": "test"}' | default/hooks/context-usage-check.sh - ``` - -### Warnings appearing too early/late - -Adjust the `TOKENS_PER_TURN` constant in `shared/lib/context-check.sh`. Higher values = warnings appear earlier. - -### State file issues - -Clear state manually: -```bash -rm -f .ring/state/context-usage-*.json -rm -f /tmp/context-usage-*.json -``` - -## Security - -The hook includes security measures: -- **Session ID validation**: Only alphanumeric, hyphens, and underscores allowed -- **JSON escaping**: Prevents injection attacks in state files -- **Atomic writes**: Uses temp file + mv pattern to prevent corruption -- **Symlink resolution**: PROJECT_DIR resolved to canonical path diff --git a/default/docs/RAG_PLANNING.md b/default/docs/RAG_PLANNING.md deleted file mode 100644 index 983899df..00000000 --- a/default/docs/RAG_PLANNING.md +++ /dev/null @@ -1,240 +0,0 @@ -# RAG-Enhanced Plan Judging - -Ring's plan creation workflow automatically queries historical context before generating new plans. This enables plans that learn from past successes and avoid known failure patterns. - -## How It Works - -``` -1. User requests: "Create plan for authentication" - │ - ▼ -2. Query artifact index for "authentication" precedent - │ - ▼ -3. Results inform plan: - - Successful patterns → Reference these - - Failed patterns → Design to AVOID - - Past plans → Review for approach ideas - │ - ▼ -4. Write plan with Historical Precedent section - │ - ▼ -5. Validate plan against failure patterns - │ - ▼ -6. Execute plan with confidence -``` - -## Components - -### 1. Planning Query Mode - -Query the artifact index for structured planning context: - -```bash -python3 default/lib/artifact-index/artifact_query.py --mode planning "keywords" --json -``` - -**Returns:** -- `successful_handoffs` - Implementations that worked -- `failed_handoffs` - Implementations that failed (AVOID these) -- `relevant_plans` - Similar past plans -- `is_empty_index` - True if no historical data (normal for new projects) -- `query_time_ms` - Performance metric (target <200ms) - -### 2. Historical Precedent Section - -Every plan created by the ring:write-plan agent includes: - -```markdown -## Historical Precedent - -**Query:** "authentication oauth jwt" -**Index Status:** Populated - -### Successful Patterns to Reference -- **[auth-impl/task-3]**: JWT refresh mechanism worked well -- **[api-v2/task-7]**: Token validation approach succeeded - -### Failure Patterns to AVOID -- **[auth-v1/task-5]**: localStorage token storage caused XSS -- **[session-fix/task-2]**: Missing CSRF protection - -### Related Past Plans -- `2024-01-15-oauth-integration.md`: Similar OAuth2 approach - ---- -``` - -### 3. Plan Validation - -After creating a plan, validate it against known failures: - -```bash -python3 default/lib/validate-plan-precedent.py docs/plans/YYYY-MM-DD-feature.md -``` - -**Interpretation:** -- `PASS` - No significant overlap with failure patterns -- `WARNING` - >30% keyword overlap with past failures (review required) - -**Exit codes:** -- `0` - Plan passes validation -- `1` - Warning: overlap detected (plan may repeat failures) -- `2` - Error (invalid file, etc.) - -## Usage - -### Automatic (Recommended) - -When using `/ring:write-plan` or the `ring:writing-plans` skill, RAG enhancement happens automatically: - -1. Keywords are extracted from your planning request -2. Artifact index is queried for precedent -3. Historical Precedent section is added to plan -4. Plan is validated before execution - -### Manual Query - -To query precedent manually: - -```bash -# Get structured planning context -python3 default/lib/artifact-index/artifact_query.py --mode planning "api rate limiting" --json - -# Validate an existing plan -python3 default/lib/validate-plan-precedent.py docs/plans/my-plan.md -``` - -## Empty Index Handling - -For new projects or empty indexes: - -```json -{ - "is_empty_index": true, - "message": "No artifact index found. This is normal for new projects." -} -``` - -Plans will include: -```markdown -## Historical Precedent - -**Query:** "feature keywords" -**Index Status:** Empty (new project) - -No historical data available. This is normal for new projects. -Proceeding with standard planning approach. -``` - -## Performance - -| Component | Target | Measured | -|-----------|--------|----------| -| Planning query | <200ms | ~50ms | -| Plan validation | <500ms | ~100ms | -| Keyword extraction | <100ms | ~20ms | - -## Overlap Detection Algorithm - -The validation uses **bidirectional keyword overlap**: - -1. Extract keywords from plan (Goal, Architecture, Tech Stack, Task names) -2. Extract keywords from failed handoffs (task_summary, what_failed, key_decisions) -3. Calculate overlap in both directions: - - `plan_coverage` = intersection / plan_keywords - - `failure_coverage` = intersection / handoff_keywords -4. Return maximum (catches both large and small plans) - -**Threshold:** 30% overlap triggers warning - -This bidirectional approach prevents large plans from escaping detection via keyword dilution. - -## Keyword Extraction - -Keywords are extracted from: - -**Plans:** -- `**Goal:**` section -- `**Architecture:**` section -- `**Tech Stack:**` section -- `### Task N:` names -- `**Query:**` in Historical Precedent - -**Handoffs:** -- `task_summary` field -- `what_failed` field -- `key_decisions` field - -**Filtering:** -- 3+ character words only -- Stopwords removed (common English + technical generics) -- Case-insensitive matching - -## Configuration - -### Validation Threshold - -Default: 30% overlap - -```bash -# More sensitive (catches more potential issues) -python3 validate-plan-precedent.py plan.md --threshold 20 - -# Less sensitive (fewer warnings) -python3 validate-plan-precedent.py plan.md --threshold 50 -``` - -### Keyword Sanitization - -For security, keywords extracted from planning requests are sanitized: -- Only alphanumeric characters, hyphens, spaces allowed -- Shell metacharacters removed (`;`, `$`, `` ` ``, `|`, `&`) - -## Troubleshooting - -### "No historical data available" - -Normal for: -- New projects -- Projects without `.ring/` initialized -- Projects where `artifact_index.py --all` hasn't been run - -Solution: Run indexing once handoffs/plans exist: -```bash -python3 default/lib/artifact-index/artifact_index.py --all -``` - -### Query taking >200ms - -Possible causes: -- Very large artifact index -- Complex query with many keywords - -Solutions: -- Use more specific keywords -- Limit query scope with `--limit` - -### Validation showing false positives - -If validation warns about unrelated failures: -- Increase threshold: `--threshold 50` -- Review the overlapping keywords in output -- Refine plan wording to be more specific - -## Tests - -Run integration tests: - -```bash -bash default/lib/tests/test-rag-planning.sh -``` - -Tests cover: -- Planning mode query functionality -- Performance (<200ms target) -- Keyword extraction quality -- Edge cases (missing files, invalid thresholds) -- Tech Stack extraction diff --git a/default/hooks/artifact-index-write.sh b/default/hooks/artifact-index-write.sh deleted file mode 100755 index 07602e7c..00000000 --- a/default/hooks/artifact-index-write.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -# PostToolUse hook: Index handoffs/plans immediately on Write -# Matcher: Write tool -# Purpose: Immediate indexing for searchability - -set -euo pipefail - -# Read input from stdin -INPUT=$(cat) - -# Determine project root early for path validation -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" - -# Extract file path from Write tool output -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') - -if [[ -z "$FILE_PATH" ]]; then - # No file path in input, skip - echo '{"result": "continue"}' - exit 0 -fi - -# Path boundary validation: ensure FILE_PATH is within PROJECT_ROOT -# This prevents path traversal attacks (e.g., ../../etc/passwd) -validate_path_boundary() { - local file_path="$1" - local project_root="$2" - - # Resolve to absolute paths (handles ../ and symlinks) - local resolved_file resolved_root - - # For file path, resolve the directory portion (file may not exist yet) - local file_dir - file_dir=$(dirname "$file_path") - - # If directory doesn't exist, walk up to find existing parent - while [[ ! -d "$file_dir" ]] && [[ "$file_dir" != "/" ]]; do - file_dir=$(dirname "$file_dir") - done - - if [[ -d "$file_dir" ]]; then - resolved_file="$(cd "$file_dir" && pwd)/$(basename "$file_path")" - else - # Cannot resolve, reject - return 1 - fi - - resolved_root="$(cd "$project_root" && pwd)" - - # Check if resolved file path starts with resolved project root - if [[ "$resolved_file" == "$resolved_root"/* ]]; then - return 0 - else - return 1 - fi -} - -# Validate path is within project boundary -if ! validate_path_boundary "$FILE_PATH" "$PROJECT_ROOT"; then - # Path outside project root, skip silently (security: don't reveal validation) - echo '{"result": "continue"}' - exit 0 -fi - -# Check if file is a handoff, plan, or continuity ledger -FILE_BASENAME="$(basename "$FILE_PATH")" -if [[ "$FILE_PATH" == *"/handoffs/"* ]] || \ - [[ "$FILE_PATH" == *"/plans/"* ]] || \ - [[ "$FILE_PATH" == *"/ledgers/"* ]] || \ - [[ "$FILE_BASENAME" == CONTINUITY* ]]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" - PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" - - # Path to artifact indexer (try plugin lib first, then project .ring) - INDEXER="${PLUGIN_ROOT}/lib/artifact-index/artifact_index.py" - - if [[ ! -f "$INDEXER" ]]; then - INDEXER="${PROJECT_ROOT}/.ring/lib/artifact-index/artifact_index.py" - fi - - if [[ -f "$INDEXER" ]]; then - # Index the single file (fast path) - python3 "$INDEXER" --file "$FILE_PATH" --project "$PROJECT_ROOT" 2>/dev/null || true - fi -fi - -# Continue processing -cat <<'HOOKEOF' -{ - "result": "continue" -} -HOOKEOF diff --git a/default/hooks/context-usage-check.sh b/default/hooks/context-usage-check.sh deleted file mode 100755 index 905a3aef..00000000 --- a/default/hooks/context-usage-check.sh +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2034 # Unused variables OK for exported config -# Context Usage Check - UserPromptSubmit hook -# Estimates context usage and injects tiered warnings -# Performance target: <50ms execution time - -set -euo pipefail - -# Determine plugin root directory -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -MONOREPO_ROOT="$(cd "${PLUGIN_ROOT}/.." && pwd)" - -# Session identification -# CLAUDE_SESSION_ID is provided by Claude Code, fallback to PPID for session isolation -SESSION_ID="${CLAUDE_SESSION_ID:-$PPID}" - -# REQUIRED: Validate session ID - alphanumeric, hyphens, underscores only -if [[ ! "$SESSION_ID" =~ ^[a-zA-Z0-9_-]+$ ]]; then - # Fall back to PPID if invalid - SESSION_ID="$PPID" -fi - -# Allow users to reset warnings via environment variable -# Set RING_RESET_CONTEXT_WARNING=1 to re-enable warnings -if [[ "${RING_RESET_CONTEXT_WARNING:-}" == "1" ]]; then - # Will be cleaned up below after STATE_FILE is set - RESET_REQUESTED=true -else - RESET_REQUESTED=false -fi - -# State file location - use project's .ring/state/ if available, else /tmp -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" -# Resolve symlinks to canonical path (prevents path confusion, allows /tmp -> /private/tmp) -if [[ -L "$PROJECT_DIR" ]] && [[ -d "$PROJECT_DIR" ]]; then - PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd -P)" -fi -if [[ -d "${PROJECT_DIR}/.ring/state" ]]; then - STATE_DIR="${PROJECT_DIR}/.ring/state" -else - # Fallback to /tmp for projects without .ring/ initialized - STATE_DIR="/tmp" -fi -STATE_FILE="${STATE_DIR}/context-usage-${SESSION_ID}.json" -TEMP_STATE="${STATE_FILE}.tmp.$$" - -# Cleanup trap for temp file on exit (prevents orphaned files) -trap 'rm -f "$TEMP_STATE" 2>/dev/null' EXIT - -# Handle reset request now that STATE_FILE is defined -if [[ "$RESET_REQUESTED" == "true" ]]; then - rm -f "$STATE_FILE" 2>/dev/null || true -fi - -# Source shared libraries -SHARED_LIB="${MONOREPO_ROOT}/shared/lib" - -# NEW: Try to get REAL context usage from session file -get_real_context_usage() { - if [[ -f "${SHARED_LIB}/get-context-usage.sh" ]]; then - # shellcheck source=/dev/null - source "${SHARED_LIB}/get-context-usage.sh" - local pct - pct=$(get_context_usage 2>/dev/null) - if [[ "$pct" != "unknown" ]] && [[ "$pct" =~ ^[0-9]+$ ]]; then - echo "$pct" - return 0 - fi - fi - echo "" - return 1 -} - -if [[ -f "${SHARED_LIB}/context-check.sh" ]]; then - # shellcheck source=/dev/null - source "${SHARED_LIB}/context-check.sh" -else - # Fallback: inline minimal estimation (used when real usage unavailable) - estimate_context_pct() { - local turn_count="${1:-0}" - # Match shared lib: (45000 + turns*2500) * 100 / 200000 = 22.5 + turns*1.25 - # Simplified: 23 + (turns * 5 / 4) for integer math (rounds up base for accuracy) - local pct=$(( 23 + (turn_count * 5 / 4) )) - [[ "$pct" -gt 100 ]] && pct=100 - echo "$pct" - } - get_warning_tier() { - local pct="${1:-0}" - if [[ "$pct" -ge 85 ]]; then echo "critical" - elif [[ "$pct" -ge 70 ]]; then echo "warning" - elif [[ "$pct" -ge 50 ]]; then echo "info" - else echo "none" - fi - } - format_context_warning() { - local tier="$1" - local pct="$2" - case "$tier" in - critical) - cat < -[!!!] CONTEXT CRITICAL: ${pct}% usage (from session data). - -**MANDATORY ACTIONS:** -1. STOP current task immediately -2. Run /create-handoff to save progress NOW -3. Create continuity-ledger with current state -4. Run /clear to reset context -5. Resume from handoff in new session - -**Verify with /context if needed.** - -EOF - ;; - warning) - cat < -[!!] Context Warning: ${pct}% usage (from session data). - -**RECOMMENDED ACTIONS:** -- Create a continuity-ledger to preserve session state -- Run: /create-handoff or manually create ledger - -**Recommended:** Complete current task, then /clear before starting new work. - -EOF - ;; - info) echo "[i] Context at ${pct}%." ;; - *) echo "" ;; - esac - } -fi - -if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then - # shellcheck source=/dev/null - source "${SHARED_LIB}/json-escape.sh" -else - # Fallback: minimal JSON escaping - json_escape() { - local input="$1" - if command -v jq &>/dev/null; then - printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//' - else - printf '%s' "$input" | sed \ - -e 's/\\/\\\\/g' \ - -e 's/"/\\"/g' \ - -e 's/\t/\\t/g' \ - -e 's/\r/\\r/g' \ - -e ':a;N;$!ba;s/\n/\\n/g' - fi - } -fi - -# Helper function to write state file (consolidates duplicate code) -# Args: $1 = acknowledged_tier value -write_state_file() { - local ack_tier="$1" - cat > "$TEMP_STATE" </dev/null; then - turn_count=$(jq -r '.turn_count // 0' "$STATE_FILE" 2>/dev/null || echo 0) - acknowledged_tier=$(jq -r '.acknowledged_tier // "none"' "$STATE_FILE" 2>/dev/null || echo "none") - else - # Fallback: grep-based parsing - turn_count=$(grep -o '"turn_count":[0-9]*' "$STATE_FILE" 2>/dev/null | grep -o '[0-9]*' || echo 0) - acknowledged_tier=$(grep -o '"acknowledged_tier":"[^"]*"' "$STATE_FILE" 2>/dev/null | sed 's/.*://;s/"//g' || echo "none") - fi -else - turn_count=0 - acknowledged_tier="none" -fi - -# Increment turn count -turn_count=$((turn_count + 1)) - -# Get context percentage - try REAL usage first, fall back to estimate -real_pct=$(get_real_context_usage 2>/dev/null || echo "") -if [[ -n "$real_pct" ]] && [[ "$real_pct" =~ ^[0-9]+$ ]]; then - # Using REAL context usage from session file - estimated_pct="$real_pct" - usage_source="real" -else - # Fallback to turn-count estimate - estimated_pct=$(estimate_context_pct "$turn_count") - usage_source="estimate" -fi - -# Determine warning tier -current_tier=$(get_warning_tier "$estimated_pct") - -# Write updated state using helper function -# Uses atomic write pattern: write to temp, then mv (TEMP_STATE defined at top with trap) -mkdir -p "$(dirname "$STATE_FILE")" - -# JSON escape session ID to prevent injection -SESSION_ID_ESCAPED=$(json_escape "$SESSION_ID") - -write_state_file "$acknowledged_tier" - -# Determine if we should show a warning -# Don't repeat same-tier warnings within session (user has seen it) -# But DO show if tier has escalated -show_warning=false -if [[ "$current_tier" != "none" ]]; then - case "$current_tier" in - critical) - # Always show critical warnings - show_warning=true - ;; - warning) - # Show if not already acknowledged at warning or higher - if [[ "$acknowledged_tier" != "warning" && "$acknowledged_tier" != "critical" ]]; then - show_warning=true - fi - ;; - info) - # Show if not already acknowledged at any tier - if [[ "$acknowledged_tier" == "none" ]]; then - show_warning=true - fi - ;; - esac -fi - -# Update acknowledged tier if showing a warning -if [[ "$show_warning" == "true" ]]; then - # Update state with current tier as acknowledged - write_state_file "$current_tier" -fi - -# Generate output -if [[ "$show_warning" == "true" ]]; then - warning_content=$(format_context_warning "$current_tier" "$estimated_pct") - warning_escaped=$(json_escape "$warning_content") - - cat < dict: - """Read JSON input from stdin.""" - try: - return json.loads(sys.stdin.read()) - except json.JSONDecodeError: - return {} - - -def get_project_root() -> Path: - """Get the project root directory.""" - # Try CLAUDE_PROJECT_DIR first - if os.environ.get("CLAUDE_PROJECT_DIR"): - return Path(os.environ["CLAUDE_PROJECT_DIR"]) - # Fall back to current directory - return Path.cwd() - - -def sanitize_session_id(session_id: str) -> str: - """Sanitize session ID for safe use in filenames. - - SECURITY: Prevents path traversal attacks via malicious session_id. - Only allows alphanumeric characters, hyphens, and underscores. - """ - import re - # Remove any path separators or special characters - sanitized = re.sub(r'[^a-zA-Z0-9_-]', '', session_id) - # Ensure non-empty and reasonable length - if not sanitized: - return "unknown" - return sanitized[:64] # Limit length - - -def get_session_id(input_data: dict) -> str: - """Get session identifier from input or generate one.""" - if input_data.get("session_id"): - # SECURITY: Sanitize user-provided session_id - return sanitize_session_id(input_data["session_id"]) - # Generate based on timestamp with microseconds to prevent collision - return datetime.now().strftime("%H%M%S-%f")[:13] - - -def extract_learnings_from_handoffs(project_root: Path) -> dict: - """Extract learnings from recent handoffs in the artifact index. - - This queries the artifact index for handoffs from this session - and extracts their what_worked, what_failed, and key_decisions. - """ - learnings = { - "what_worked": [], - "what_failed": [], - "key_decisions": [], - "patterns": [], - } - - # Try to query artifact index if available - db_path = project_root / ".ring" / "cache" / "artifact-index" / "context.db" - if not db_path.exists(): - return learnings - - try: - import sqlite3 - # Use context manager for proper connection cleanup - with sqlite3.connect(str(db_path), timeout=5.0) as conn: - conn.row_factory = sqlite3.Row - - # Get recent handoffs (from last 24 hours instead of calendar date) - yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S") - cursor = conn.execute( - """ - SELECT outcome, what_worked, what_failed, key_decisions - FROM handoffs - WHERE created_at >= ? - ORDER BY created_at DESC - LIMIT 10 - """, - (yesterday,) - ) - - seen_worked = set() - seen_failed = set() - seen_decisions = set() - - for row in cursor.fetchall(): - outcome = row["outcome"] - if outcome in ("SUCCEEDED", "PARTIAL_PLUS"): - if row["what_worked"] and row["what_worked"] not in seen_worked: - learnings["what_worked"].append(row["what_worked"]) - seen_worked.add(row["what_worked"]) - elif outcome in ("FAILED", "PARTIAL_MINUS"): - if row["what_failed"] and row["what_failed"] not in seen_failed: - learnings["what_failed"].append(row["what_failed"]) - seen_failed.add(row["what_failed"]) - - if row["key_decisions"] and row["key_decisions"] not in seen_decisions: - learnings["key_decisions"].append(row["key_decisions"]) - seen_decisions.add(row["key_decisions"]) - - except Exception as e: - # Log error for debugging but continue gracefully - import sys - print(f"Warning: Failed to extract learnings from handoffs: {e}", file=sys.stderr) - - return learnings - - -def extract_learnings_from_outcomes(project_root: Path, session_id: str = None) -> dict: - """Extract learnings from session outcomes file (if exists). - - This reads from .ring/state/session-outcome-{session}.json if it exists. - Note: This file is optional - primary learnings come from handoffs database. - The file is created by outcome-inference.sh during session end. - - Args: - project_root: Project root directory - session_id: Optional session identifier for session-isolated file lookup - """ - learnings = { - "what_worked": [], - "what_failed": [], - "key_decisions": [], - "patterns": [], - } - - # Check for outcome file (optional - may not exist) - state_dir = project_root / ".ring" / "state" - - # Try session-isolated file first (from outcome-inference.sh) - if session_id: - session_id_safe = sanitize_session_id(session_id) - outcome_file = state_dir / f"session-outcome-{session_id_safe}.json" - else: - # Fallback: find most recent session-outcome-*.json - outcome_files = list(state_dir.glob("session-outcome-*.json")) - if outcome_files: - outcome_file = max(outcome_files, key=lambda f: f.stat().st_mtime) - else: - outcome_file = state_dir / "session-outcome.json" # Legacy fallback - - if not outcome_file.exists(): - # File doesn't exist - this is normal, learnings come from handoffs - return learnings - - try: - with open(outcome_file, encoding='utf-8') as f: - outcome = json.load(f) - - status = outcome.get("status", "unknown") - if status in ["SUCCEEDED", "PARTIAL_PLUS"]: - if outcome.get("summary"): - learnings["what_worked"].append(outcome["summary"]) - elif status in ["FAILED", "PARTIAL_MINUS"]: - if outcome.get("summary"): - learnings["what_failed"].append(outcome["summary"]) - - if outcome.get("key_decisions"): - if isinstance(outcome["key_decisions"], list): - learnings["key_decisions"].extend(outcome["key_decisions"]) - else: - learnings["key_decisions"].append(outcome["key_decisions"]) - - except (json.JSONDecodeError, KeyError, IOError) as e: - import sys - import traceback - print(f"Warning: Failed to read session outcome file: {e}", file=sys.stderr) - traceback.print_exc(file=sys.stderr) - - return learnings - - -def save_learnings(project_root: Path, session_id: str, learnings: dict) -> Path: - """Save learnings to cache file. - - Args: - project_root: Project root directory - session_id: Session identifier (must be pre-sanitized) - learnings: Extracted learnings dictionary - - Returns: - Path to saved learnings file - - Raises: - ValueError: If resolved path escapes learnings directory - """ - # Create learnings directory - learnings_dir = project_root / ".ring" / "cache" / "learnings" - learnings_dir.mkdir(parents=True, exist_ok=True) - - # Generate filename - date_str = datetime.now().strftime("%Y-%m-%d") - filename = f"{date_str}-{session_id}.md" - file_path = learnings_dir / filename - - # SECURITY: Verify the resolved path is within learnings_dir - resolved_path = file_path.resolve() - resolved_learnings = learnings_dir.resolve() - if not str(resolved_path).startswith(str(resolved_learnings)): - raise ValueError( - f"Security: session_id would escape learnings directory: {session_id}" - ) - - # Generate content - content = f"""# Learnings from Session {session_id} - -**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - -## What Worked - -""" - for item in learnings.get("what_worked", []): - content += f"- {item}\n" - if not learnings.get("what_worked"): - content += "- (No successful patterns recorded)\n" - - content += """ -## What Failed - -""" - for item in learnings.get("what_failed", []): - content += f"- {item}\n" - if not learnings.get("what_failed"): - content += "- (No failures recorded)\n" - - content += """ -## Key Decisions - -""" - for item in learnings.get("key_decisions", []): - content += f"- {item}\n" - if not learnings.get("key_decisions"): - content += "- (No key decisions recorded)\n" - - content += """ -## Patterns - -""" - for item in learnings.get("patterns", []): - content += f"- {item}\n" - if not learnings.get("patterns"): - content += "- (No patterns identified)\n" - - # Write file - file_path.write_text(content, encoding='utf-8') - return file_path - - -def main(): - """Main hook entry point. - - Extraction Strategy: - 1. Extract learnings from handoffs database (primary source) - 2. Extract learnings from session outcomes file (secondary source) - 3. Perform cross-source deduplication to prevent duplicates when the same - learning appears in both sources (normalized comparison preserves order) - """ - input_data = read_stdin() - - # Only process on actual session end (not errors) - if input_data.get("type") != "stop": - print(json.dumps({"result": "continue"})) - return - - project_root = get_project_root() - session_id = get_session_id(input_data) - - # Combine learnings from different sources - learnings = { - "what_worked": [], - "what_failed": [], - "key_decisions": [], - "patterns": [], - } - - # Extract from handoffs (if artifact index available) - handoff_learnings = extract_learnings_from_handoffs(project_root) - for key in learnings: - learnings[key].extend(handoff_learnings.get(key, [])) - - # Extract from outcomes (if outcome tracking available) - outcome_learnings = extract_learnings_from_outcomes(project_root, session_id) - for key in learnings: - learnings[key].extend(outcome_learnings.get(key, [])) - - # Cross-source deduplication: Remove duplicates while preserving order - # This prevents the same learning from being counted multiple times - # when it appears in both handoffs and session outcomes - for key in learnings: - seen = set() - deduplicated = [] - for item in learnings[key]: - # Normalize for comparison (lowercase, strip whitespace) - normalized = item.lower().strip() if isinstance(item, str) else str(item) - if normalized not in seen: - seen.add(normalized) - deduplicated.append(item) # Keep original formatting - learnings[key] = deduplicated - - # Check if we have any learnings to save - has_learnings = any(learnings.get(key) for key in learnings) - - if has_learnings: - file_path = save_learnings(project_root, session_id, learnings) - message = f"Session learnings saved to {file_path.relative_to(project_root)}" - else: - message = "No learnings extracted from this session (no handoffs or outcomes recorded)" - - # Output hook response - output = { - "result": "continue", - "message": message, - } - print(json.dumps(output)) - - -if __name__ == "__main__": - main() diff --git a/default/hooks/learning-extract.sh b/default/hooks/learning-extract.sh deleted file mode 100755 index dff50dc3..00000000 --- a/default/hooks/learning-extract.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# Learning extraction hook wrapper -# Calls Python script for SessionEnd learning extraction - -set -euo pipefail - -# Get the directory where this script lives -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Find Python interpreter -PYTHON_CMD="" -for cmd in python3 python; do - if command -v "$cmd" &> /dev/null; then - PYTHON_CMD="$cmd" - break - fi -done - -if [[ -z "$PYTHON_CMD" ]]; then - # Python not available - output minimal JSON response - echo '{"result": "continue", "error": "Python not available for learning extraction"}' - exit 0 -fi - -# Construct input JSON for Stop event (framework doesn't provide stdin) -INPUT_JSON='{"type": "stop"}' - -# Pass constructed input to Python script -echo "$INPUT_JSON" | "$PYTHON_CMD" "${SCRIPT_DIR}/learning-extract.py" diff --git a/default/hooks/ledger-save.sh b/default/hooks/ledger-save.sh deleted file mode 100755 index d91ee1f5..00000000 --- a/default/hooks/ledger-save.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash -# Ledger save hook - auto-saves active ledger before compaction or stop -# Triggered by: PreCompact, Stop events - -set -euo pipefail - -# Determine paths -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" -LEDGER_DIR="${PROJECT_ROOT}/.ring/ledgers" - -# Source shared utilities -SHARED_LIB="${SCRIPT_DIR}/../../shared/lib" - -# Source JSON escaping utility -if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then - # shellcheck source=/dev/null - source "${SHARED_LIB}/json-escape.sh" -else - # Fallback: define json_escape locally - json_escape() { - local input="$1" - if command -v jq &>/dev/null; then - printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//' - else - printf '%s' "$input" | sed \ - -e 's/\\/\\\\/g' \ - -e 's/"/\\"/g' \ - -e 's/\t/\\t/g' \ - -e 's/\r/\\r/g' \ - -e ':a;N;$!ba;s/\n/\\n/g' - fi - } -fi - -# Source ledger utilities -if [[ -f "${SHARED_LIB}/ledger-utils.sh" ]]; then - # shellcheck source=/dev/null - source "${SHARED_LIB}/ledger-utils.sh" -else - # Fallback: define find_active_ledger locally if shared lib not found - find_active_ledger() { - local ledger_dir="$1" - [[ ! -d "$ledger_dir" ]] && echo "" && return 0 - local newest="" newest_time=0 - while IFS= read -r -d '' file; do - [[ -L "$file" ]] && continue - local mtime - if [[ "$(uname)" == "Darwin" ]]; then - mtime=$(stat -f %m "$file" 2>/dev/null || echo 0) - else - mtime=$(stat -c %Y "$file" 2>/dev/null || echo 0) - fi - (( mtime > newest_time )) && newest_time=$mtime && newest="$file" - done < <(find "$ledger_dir" -maxdepth 1 -name "CONTINUITY-*.md" -type f -print0 2>/dev/null) - echo "$newest" - } -fi - -# Update ledger timestamp - SAFE implementation -# Fixes: Silent failure when Updated: line missing -update_ledger_timestamp() { - local ledger_file="$1" - local timestamp - timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - if grep -q "^Updated:" "$ledger_file" 2>/dev/null; then - # Update existing timestamp - if [[ "$(uname)" == "Darwin" ]]; then - sed -i '' "s/^Updated:.*$/Updated: ${timestamp}/" "$ledger_file" - else - sed -i "s/^Updated:.*$/Updated: ${timestamp}/" "$ledger_file" - fi - else - # Append if missing - echo "Updated: ${timestamp}" >> "$ledger_file" - fi - - # Always update filesystem mtime as fallback - touch "$ledger_file" -} - -# Main logic -main() { - local active_ledger - active_ledger=$(find_active_ledger "$LEDGER_DIR") - - if [[ -z "$active_ledger" ]]; then - # No active ledger - nothing to save - cat <<'EOF' -{ - "result": "continue" -} -EOF - exit 0 - fi - - # Update timestamp - update_ledger_timestamp "$active_ledger" - - local ledger_name - ledger_name=$(basename "$active_ledger" .md) - - # Extract current phase for status message - local current_phase="" - current_phase=$(grep -E '\[->' "$active_ledger" 2>/dev/null | head -1 | sed 's/^[[:space:]]*//' || echo "") - - local message="Ledger saved: ${ledger_name}.md" - if [[ -n "$current_phase" ]]; then - message="${message}\nCurrent phase: ${current_phase}" - fi - message="${message}\n\nAfter clear/compact, state will auto-restore on resume." - - local message_escaped - message_escaped=$(json_escape "$message") - - cat </dev/null; then - printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//' - else - printf '%s' "$input" | sed \ - -e 's/\\/\\\\/g' \ - -e 's/"/\\"/g' \ - -e 's/\t/\\t/g' \ - -e 's/\r/\\r/g' \ - -e ':a;N;$!ba;s/\n/\\n/g' - fi - } -fi - -# Read input from stdin -INPUT=$(cat) - -# Find Python interpreter -PYTHON_CMD="" -for cmd in python3 python; do - if command -v "$cmd" &> /dev/null; then - PYTHON_CMD="$cmd" - break - fi -done - -if [[ -z "$PYTHON_CMD" ]]; then - # Python not available - skip inference - echo '{"result": "continue", "message": "Outcome inference skipped: Python not available"}' - exit 0 -fi - -# Check if module exists -if [[ ! -f "${LIB_DIR}/outcome_inference.py" ]]; then - echo '{"result": "continue", "message": "Outcome inference skipped: Module not found"}' - exit 0 -fi - -# Session identification - use CLAUDE_SESSION_ID if available, fallback to PPID -SESSION_ID="${CLAUDE_SESSION_ID:-$PPID}" -# Sanitize session ID: keep only alphanumeric, hyphens, underscores -SESSION_ID_SAFE=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-') -[[ -z "$SESSION_ID_SAFE" ]] && SESSION_ID_SAFE="unknown" - -# Read persisted todos from task-completion-check.sh -# This solves the data flow break where Stop hook doesn't have direct access to todos -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" -STATE_DIR="${PROJECT_ROOT}/.ring/state" -TODOS_FILE="${STATE_DIR}/todos-state-${SESSION_ID_SAFE}.json" - -INPUT_FOR_PYTHON='{"todos": []}' -if [[ -f "$TODOS_FILE" ]]; then - TODOS_DATA=$(cat "$TODOS_FILE" 2>/dev/null || echo '[]') - if command -v jq &>/dev/null; then - # Wrap in expected format for Python: {"todos": [...]} - INPUT_FOR_PYTHON=$(echo "$TODOS_DATA" | jq '{todos: .}' 2>/dev/null || echo '{"todos": []}') - else - # Fallback: simple string wrapping (assumes valid JSON array) - INPUT_FOR_PYTHON="{\"todos\": ${TODOS_DATA}}" - fi -fi - -# Run inference with persisted todos piped -RESULT=$(echo "$INPUT_FOR_PYTHON" | "$PYTHON_CMD" "${LIB_DIR}/outcome_inference.py" 2>/dev/null || echo '{}') - -# Extract outcome and reason using jq if available -if command -v jq &>/dev/null; then - OUTCOME=$(echo "$RESULT" | jq -r '.outcome // "UNKNOWN"') - REASON=$(echo "$RESULT" | jq -r '.reason // "Unable to determine"') - CONFIDENCE=$(echo "$RESULT" | jq -r '.confidence // "low"') -else - # Fallback: grep-based extraction - OUTCOME=$(echo "$RESULT" | grep -o '"outcome": *"[^"]*"' | cut -d'"' -f4 || echo "UNKNOWN") - REASON=$(echo "$RESULT" | grep -o '"reason": *"[^"]*"' | cut -d'"' -f4 || echo "Unable to determine") - CONFIDENCE=$(echo "$RESULT" | grep -o '"confidence": *"[^"]*"' | cut -d'"' -f4 || echo "low") -fi - -# Save outcome to state file for learning-extract.py to pick up -# Note: PROJECT_ROOT, STATE_DIR, and SESSION_ID_SAFE already defined above -mkdir -p "$STATE_DIR" - -# Escape variables for safe JSON output -OUTCOME_ESCAPED=$(json_escape "$OUTCOME") -REASON_ESCAPED=$(json_escape "$REASON") -CONFIDENCE_ESCAPED=$(json_escape "$CONFIDENCE") - -# Write outcome state (session-isolated filename) -cat > "${STATE_DIR}/session-outcome-${SESSION_ID_SAFE}.json" << EOF -{ - "status": "${OUTCOME_ESCAPED}", - "summary": "${REASON_ESCAPED}", - "confidence": "${CONFIDENCE_ESCAPED}", - "inferred_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "key_decisions": [] -} -EOF - -# Build message and escape for JSON -MESSAGE="Session outcome inferred: ${OUTCOME} (${CONFIDENCE} confidence) - ${REASON}" -MESSAGE_ESCAPED=$(json_escape "$MESSAGE") - -# Return hook response -cat << EOF -{ - "result": "continue", - "message": "${MESSAGE_ESCAPED}" -} -EOF diff --git a/default/hooks/session-outcome.sh b/default/hooks/session-outcome.sh deleted file mode 100755 index aedf6357..00000000 --- a/default/hooks/session-outcome.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# SessionStart hook: Prompt for previous session outcome grade -# Purpose: Capture user feedback on session success/failure AFTER /clear or /compact -# Event: SessionStart (matcher: clear|compact) - AI is active and can prompt user -# -# Why SessionStart instead of PreCompact? -# - PreCompact additionalContext is NOT visible to AI (architectural limitation) -# - SessionStart additionalContext IS visible to AI -# - State files (todos, ledger, handoffs) persist on disk after clear/compact - -set -euo pipefail - -# Read input from stdin (not used currently, but available) -INPUT=$(cat) - -# Determine project root -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" - -# Session identification -SESSION_ID="${CLAUDE_SESSION_ID:-$PPID}" -SESSION_ID_SAFE=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-') -[[ -z "$SESSION_ID_SAFE" ]] && SESSION_ID_SAFE="unknown" - -# Load shared JSON escape utility -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -SHARED_LIB="${SCRIPT_DIR}/../../shared/lib" -if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then - # shellcheck source=../../shared/lib/json-escape.sh - source "${SHARED_LIB}/json-escape.sh" -else - # Fallback if shared lib not found - json_escape() { - local input="$1" - if command -v jq &>/dev/null; then - printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//' - else - printf '%s' "$input" | sed \ - -e 's/\\/\\\\/g' \ - -e 's/"/\\"/g' \ - -e 's/\t/\\t/g' \ - -e 's/\r/\\r/g' \ - -e ':a;N;$!ba;s/\n/\\n/g' - fi - } -fi - -# Check if there's session work to grade -# Priority: 1) todos state, 2) active ledger, 3) recent handoff -HAS_SESSION_WORK=false -SESSION_CONTEXT="" - -# Check for todos state (indicates TodoWrite was used this session) -TODOS_FILE="${PROJECT_ROOT}/.ring/state/todos-state-${SESSION_ID_SAFE}.json" -if [[ -f "$TODOS_FILE" ]]; then - HAS_SESSION_WORK=true - if command -v jq &>/dev/null; then - TOTAL=$(jq 'length' "$TODOS_FILE" 2>/dev/null || echo 0) - COMPLETED=$(jq '[.[] | select(.status == "completed")] | length' "$TODOS_FILE" 2>/dev/null || echo 0) - SESSION_CONTEXT="Task progress: ${COMPLETED}/${TOTAL} completed" - fi -fi - -# Check for active ledger -LEDGER_DIR="${PROJECT_ROOT}/.ring/ledgers" -if [[ -d "$LEDGER_DIR" ]]; then - ACTIVE_LEDGER=$(find "$LEDGER_DIR" -maxdepth 1 -name "CONTINUITY-*.md" -type f -mmin -120 2>/dev/null | head -1) - if [[ -n "$ACTIVE_LEDGER" ]]; then - HAS_SESSION_WORK=true - LEDGER_NAME=$(basename "$ACTIVE_LEDGER" .md) - if [[ -n "$SESSION_CONTEXT" ]]; then - SESSION_CONTEXT="${SESSION_CONTEXT}, Ledger: ${LEDGER_NAME}" - else - SESSION_CONTEXT="Ledger: ${LEDGER_NAME}" - fi - fi -fi - -# Check for recent handoffs (modified in last 2 hours) -# Store the path for later use with artifact_mark.py -HANDOFF_FILE_PATH="" -HANDOFFS_DIRS=("${PROJECT_ROOT}/docs/handoffs" "${PROJECT_ROOT}/.ring/handoffs") -for HANDOFFS_DIR in "${HANDOFFS_DIRS[@]}"; do - if [[ -d "$HANDOFFS_DIR" ]]; then - RECENT_HANDOFF=$(find "$HANDOFFS_DIR" -name "*.md" -mmin -120 -type f 2>/dev/null | head -1) - if [[ -n "$RECENT_HANDOFF" ]]; then - HAS_SESSION_WORK=true - HANDOFF_FILE_PATH="$RECENT_HANDOFF" - HANDOFF_NAME=$(basename "$RECENT_HANDOFF") - if [[ -n "$SESSION_CONTEXT" ]]; then - SESSION_CONTEXT="${SESSION_CONTEXT}, Handoff: ${HANDOFF_NAME}" - else - SESSION_CONTEXT="Handoff: ${HANDOFF_NAME}" - fi - break - fi - fi -done - -# If no session work detected, skip prompting -if [[ "$HAS_SESSION_WORK" != "true" ]]; then - cat <<'HOOKEOF' -{ - "hookSpecificOutput": { - "hookEventName": "SessionStart" - } -} -HOOKEOF - exit 0 -fi - -# Build artifact_mark instruction only if we have a handoff file to mark -ARTIFACT_MARK_INSTRUCTION="" -if [[ -n "$HANDOFF_FILE_PATH" ]]; then - # Make path relative to project root for cleaner output - RELATIVE_HANDOFF="${HANDOFF_FILE_PATH#"${PROJECT_ROOT}/"}" - # Use absolute path to artifact_mark.py (SCRIPT_DIR points to hooks/, lib/ is sibling) - ARTIFACT_MARK_SCRIPT="${SCRIPT_DIR}/../lib/artifact-index/artifact_mark.py" - ARTIFACT_MARK_INSTRUCTION=" - -After user responds, save the grade using: -\`python3 ${ARTIFACT_MARK_SCRIPT} --file ${RELATIVE_HANDOFF} --outcome \`" -fi - -# Build prompt message for the AI to ask user -MESSAGE=" -PREVIOUS SESSION OUTCOME GRADE REQUESTED - -You just cleared/compacted the previous session. Please ask the user to rate the PREVIOUS session's outcome. - -**Previous Session Context:** ${SESSION_CONTEXT} - -**MANDATORY ACTION:** Use AskUserQuestion to ask: -\"How would you rate the previous session's outcome?\" - -Options: -- SUCCEEDED: All goals achieved, high quality output -- PARTIAL_PLUS: Most goals achieved (≥80%), minor issues -- PARTIAL_MINUS: Some progress (50-79%), significant gaps -- FAILED: Goals not achieved (<50%), major issues -${ARTIFACT_MARK_INSTRUCTION} - -Then continue with the new session. -" - -MESSAGE_ESCAPED=$(json_escape "$MESSAGE") - -# Return hook response with additionalContext (AI will process this) -cat </dev/null || true - rm -f "/tmp/context-usage-${session_id}.json" 2>/dev/null || true -} - -# Always reset context state on SessionStart -# (whether startup, resume, clear, or compact) -reset_context_state - -# Cleanup old state files (older than 7 days) -cleanup_old_state_files() { - local project_dir="${CLAUDE_PROJECT_DIR:-.}" - local state_dir="${project_dir}/.ring/state" - [[ ! -d "$state_dir" ]] && return 0 - # Remove state files older than 7 days - find "$state_dir" -name "*.json" -type f -mtime +7 -delete 2>/dev/null || true -} - -# Cleanup old state files early in session -cleanup_old_state_files # Auto-install PyYAML if Python is available but PyYAML is not # Set RING_AUTO_INSTALL_DEPS=false to disable automatic dependency installation @@ -127,84 +93,6 @@ State assumption → Explain why → Note what would change it **Full pattern:** See shared-patterns/doubt-triggered-questions.md ' -# Source shared ledger utilities -SHARED_LIB_LEDGER="${MONOREPO_ROOT}/shared/lib" -if [[ -f "${SHARED_LIB_LEDGER}/ledger-utils.sh" ]]; then - # shellcheck source=/dev/null - source "${SHARED_LIB_LEDGER}/ledger-utils.sh" -else - # Fallback: define find_active_ledger locally if shared lib not found - find_active_ledger() { - local ledger_dir="$1" - [[ ! -d "$ledger_dir" ]] && echo "" && return 0 - local newest="" newest_time=0 - while IFS= read -r -d '' file; do - [[ -L "$file" ]] && continue - local mtime - if [[ "$(uname)" == "Darwin" ]]; then - mtime=$(stat -f %m "$file" 2>/dev/null || echo 0) - else - mtime=$(stat -c %Y "$file" 2>/dev/null || echo 0) - fi - (( mtime > newest_time )) && newest_time=$mtime && newest="$file" - done < <(find "$ledger_dir" -maxdepth 1 -name "CONTINUITY-*.md" -type f -print0 2>/dev/null) - echo "$newest" - } -fi - -# Detect and load active continuity ledger -detect_active_ledger() { - local project_root="${CLAUDE_PROJECT_DIR:-$(pwd)}" - local ledger_dir="${project_root}/.ring/ledgers" - local active_ledger="" - local ledger_content="" - - # Check if ledger directory exists - if [[ ! -d "$ledger_dir" ]]; then - echo "" - return 0 - fi - - # Find most recently modified ledger (safe implementation) - active_ledger=$(find_active_ledger "$ledger_dir") - - if [[ -z "$active_ledger" ]]; then - echo "" - return 0 - fi - - # Read ledger content - ledger_content=$(cat "$active_ledger" 2>/dev/null || echo "") - - if [[ -z "$ledger_content" ]]; then - echo "" - return 0 - fi - - # Extract current phase (line with [->] marker) - local current_phase="" - current_phase=$(grep -E '\[->' "$active_ledger" 2>/dev/null | head -1 | sed 's/^[[:space:]]*//' || echo "") - - # Format output - local ledger_name - ledger_name=$(basename "$active_ledger" .md) - - cat < -${ledger_content} - - -**Instructions:** -1. Review the State section - find [->] for current work -2. Check Open Questions for UNCONFIRMED items -3. Continue from where you left off -LEDGER_EOF -} - # Generate skills overview with cascading fallback # Priority: Python+PyYAML > Python regex > Bash fallback > Error message generate_skills_overview() { @@ -244,9 +132,6 @@ generate_skills_overview() { skills_overview=$(generate_skills_overview || echo "Error generating skills quick reference") -# Detect active ledger (if any) -ledger_context=$(detect_active_ledger) - # Source shared JSON escaping utility SHARED_LIB="${PLUGIN_ROOT}/../shared/lib" if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then @@ -275,16 +160,10 @@ fi overview_escaped=$(json_escape "$skills_overview") critical_rules_escaped=$(json_escape "$CRITICAL_RULES") doubt_questions_escaped=$(json_escape "$DOUBT_QUESTIONS") -ledger_context_escaped=$(json_escape "$ledger_context") -# Build additionalContext with optional ledger section +# Build additionalContext additional_context="\n${critical_rules_escaped}\n\n\n\n${doubt_questions_escaped}\n\n\n\n${overview_escaped}\n" -# Append ledger context if present -if [[ -n "$ledger_context" ]]; then - additional_context="${additional_context}\n\n\n${ledger_context_escaped}\n" -fi - # Build JSON output cat </dev/null; then - printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//' - else - printf '%s' "$input" | sed \ - -e 's/\\/\\\\/g' \ - -e 's/"/\\"/g' \ - -e 's/\t/\\t/g' \ - -e 's/\r/\\r/g' \ - -e ':a;N;$!ba;s/\n/\\n/g' - fi - } -fi - -# Parse TodoWrite tool output from input -# Expected input structure: -# { -# "tool_name": "TodoWrite", -# "tool_input": { "todos": [...] }, -# "tool_output": { ... } -# } - -# Extract todos array using jq if available, otherwise skip -if ! command -v jq &>/dev/null; then - # No jq - cannot parse todos, return minimal response - cat <<'EOF' -{ - "result": "continue" -} -EOF - exit 0 -fi - -# Parse the todos from tool_input -TODOS=$(echo "$INPUT" | jq -r '.tool_input.todos // []' 2>/dev/null) - -# Persist todos state for outcome-inference.sh to read later -# This solves the data flow break where Stop hook doesn't have access to todos -if [[ -n "$TODOS" ]] && [[ "$TODOS" != "null" ]] && [[ "$TODOS" != "[]" ]]; then - PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" - STATE_DIR="${PROJECT_ROOT}/.ring/state" - mkdir -p "$STATE_DIR" - - # Session ID isolation - use CLAUDE_SESSION_ID if available, fallback to PPID - SESSION_ID="${CLAUDE_SESSION_ID:-$PPID}" - # Sanitize session ID: keep only alphanumeric, hyphens, underscores - SESSION_ID_SAFE=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-') - [[ -z "$SESSION_ID_SAFE" ]] && SESSION_ID_SAFE="unknown" - - # Atomic write: temp file + mv - TODOS_STATE_FILE="${STATE_DIR}/todos-state-${SESSION_ID_SAFE}.json" - TEMP_TODOS_FILE="${TODOS_STATE_FILE}.tmp.$$" - echo "$TODOS" > "$TEMP_TODOS_FILE" - mv "$TEMP_TODOS_FILE" "$TODOS_STATE_FILE" -fi - -if [[ -z "$TODOS" ]] || [[ "$TODOS" == "null" ]] || [[ "$TODOS" == "[]" ]]; then - # No todos or empty - nothing to check - cat <<'EOF' -{ - "result": "continue" -} -EOF - exit 0 -fi - -# Count total and completed todos -TOTAL_COUNT=$(echo "$TODOS" | jq 'length') -COMPLETED_COUNT=$(echo "$TODOS" | jq '[.[] | select(.status == "completed")] | length') -IN_PROGRESS_COUNT=$(echo "$TODOS" | jq '[.[] | select(.status == "in_progress")] | length') -PENDING_COUNT=$(echo "$TODOS" | jq '[.[] | select(.status == "pending")] | length') - -# Check if all todos are complete -if [[ "$TOTAL_COUNT" -gt 0 ]] && [[ "$COMPLETED_COUNT" -eq "$TOTAL_COUNT" ]]; then - # All todos complete - trigger handoff creation - TASK_LIST=$(echo "$TODOS" | jq -r '.[] | "- " + .content') - MESSAGE=" -TASK COMPLETION DETECTED - HANDOFF RECOMMENDED - -All ${TOTAL_COUNT} todos are marked complete. - -**FIRST:** Run /context to check actual context usage. - -**IF context is high (>70%):** Create handoff NOW: -1. Run /create-handoff to preserve this session's learnings -2. Document what worked and what failed in the handoff - -**IF context is low (<70%):** Handoff is optional - you can continue with new tasks. - -Completed tasks: -${TASK_LIST} -" - - MESSAGE_ESCAPED=$(json_escape "$MESSAGE") - - cat </dev/null || true) - - if echo "$output" | grep -q "$expected"; then - echo -e "${GREEN}PASS${NC}: $name" - TESTS_PASSED=$((TESTS_PASSED + 1)) - else - echo -e "${RED}FAIL${NC}: $name" - echo " Expected to contain: $expected" - echo " Got: $output" - fi - - # Clean up - rm -f "${TMP_DIR}/context-usage-test-"*.json -} - -echo "Running context-usage-check.sh tests..." -echo "========================================" - -# Test 1: First prompt returns valid JSON -run_test "First prompt returns valid JSON" \ - "hookSpecificOutput" \ - '{"prompt": "test", "session_id": "test"}' - -# Test 2: Hook is fast (<50ms) -echo -n "Testing performance... " -TESTS_RUN=$((TESTS_RUN + 1)) -rm -f "${TMP_DIR}/context-usage-perf-"*.json -export CLAUDE_SESSION_ID="perf-$$" -export CLAUDE_PROJECT_DIR="/tmp" -start=$(date +%s%N 2>/dev/null || python3 -c "import time; print(int(time.time() * 1e9))") -echo '{"prompt": "test"}' | "$HOOK" > /dev/null 2>&1 -end=$(date +%s%N 2>/dev/null || python3 -c "import time; print(int(time.time() * 1e9))") -duration=$(( (end - start) / 1000000 )) # Convert to ms -if [[ $duration -lt 50 ]]; then - echo -e "${GREEN}PASS${NC}: Hook executed in ${duration}ms (<50ms)" - TESTS_PASSED=$((TESTS_PASSED + 1)) -else - echo -e "${RED}FAIL${NC}: Hook took ${duration}ms (>50ms)" -fi -rm -f "${TMP_DIR}/context-usage-perf-"*.json - -# Test 3: State file is created -echo -n "Testing state file creation... " -TESTS_RUN=$((TESTS_RUN + 1)) -rm -f "${TMP_DIR}/context-usage-state-"*.json -rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true -export CLAUDE_SESSION_ID="state-$$" -export CLAUDE_PROJECT_DIR="/tmp" -echo '{"prompt": "test"}' | "$HOOK" > /dev/null 2>&1 -# Hook uses TMP_DIR directly when .ring/state doesn't exist -if [[ -f "${TMP_DIR}/context-usage-state-$$.json" ]]; then - echo -e "${GREEN}PASS${NC}: State file created" - TESTS_PASSED=$((TESTS_PASSED + 1)) -else - echo -e "${RED}FAIL${NC}: State file not found at ${TMP_DIR}/context-usage-state-$$.json" -fi -rm -f "${TMP_DIR}/context-usage-state-"*.json - -# Test 4: Turn count increments -echo -n "Testing turn count increment... " -TESTS_RUN=$((TESTS_RUN + 1)) -rm -f "${TMP_DIR}/context-usage-count-"*.json -rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true -export CLAUDE_SESSION_ID="count-$$" -export CLAUDE_PROJECT_DIR="/tmp" - -# Run hook 3 times -for _ in {1..3}; do - echo '{"prompt": "test"}' | "$HOOK" > /dev/null 2>&1 -done - -# Check turn count (hook uses TMP_DIR when .ring/state doesn't exist) -STATE_FILE="${TMP_DIR}/context-usage-count-$$.json" -if [[ -f "$STATE_FILE" ]]; then - if command -v jq &>/dev/null; then - count=$(jq -r '.turn_count' "$STATE_FILE") - else - count=$(grep -o '"turn_count":[0-9]*' "$STATE_FILE" | grep -o '[0-9]*') - fi - if [[ "$count" == "3" ]]; then - echo -e "${GREEN}PASS${NC}: Turn count is 3 after 3 prompts" - TESTS_PASSED=$((TESTS_PASSED + 1)) - else - echo -e "${RED}FAIL${NC}: Turn count is $count, expected 3" - fi -else - echo -e "${RED}FAIL${NC}: State file not found" -fi -rm -f "${TMP_DIR}/context-usage-count-"*.json - -# Test 5: Warning deduplication (info tier) -echo -n "Testing warning deduplication... " -TESTS_RUN=$((TESTS_RUN + 1)) -rm -f "${TMP_DIR}/context-usage-dedup-"*.json -rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true -export CLAUDE_SESSION_ID="dedup-$$" -export CLAUDE_PROJECT_DIR="/tmp" - -# Create state file at 51% (just over info threshold) -# Note: without .ring/state, hook uses TMP_DIR directly -cat > "${TMP_DIR}/context-usage-dedup-$$.json" </dev/null || true) -if echo "$output" | grep -q "additionalContext"; then - echo -e "${RED}FAIL${NC}: Warning shown when tier already acknowledged" -else - echo -e "${GREEN}PASS${NC}: Warning not repeated for same tier" - TESTS_PASSED=$((TESTS_PASSED + 1)) -fi -rm -f "${TMP_DIR}/context-usage-dedup-"*.json - -# Test 6: Critical always shows -echo -n "Testing critical always shows... " -TESTS_RUN=$((TESTS_RUN + 1)) -rm -f "${TMP_DIR}/context-usage-crit-"*.json -rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true -export CLAUDE_SESSION_ID="crit-$$" -export CLAUDE_PROJECT_DIR="/tmp" - -# Create state at high turn count (68 turns = ~85%) with critical already acknowledged -# The hook recalculates tier from turn_count, so we need enough turns to trigger critical -# Formula: pct = (45000 + turns*2500) * 100 / 200000 -# For 85%: 170000 = 45000 + turns*2500 => turns = 50 -# For 86%: 172000 = 45000 + turns*2500 => turns = 50.8 => 51 -cat > "${TMP_DIR}/context-usage-crit-$$.json" </dev/null || true) -if echo "$output" | grep -q "additionalContext"; then - echo -e "${GREEN}PASS${NC}: Critical warning shown even when acknowledged" - TESTS_PASSED=$((TESTS_PASSED + 1)) -else - echo -e "${RED}FAIL${NC}: Critical warning not shown" -fi -rm -f "${TMP_DIR}/context-usage-crit-"*.json - -# Test 7: Reset via environment variable -echo -n "Testing reset via env var... " -TESTS_RUN=$((TESTS_RUN + 1)) -rm -f "${TMP_DIR}/context-usage-reset-"*.json -rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true -export CLAUDE_SESSION_ID="reset-$$" -export CLAUDE_PROJECT_DIR="/tmp" - -# Create state file (without .ring/state, hook uses TMP_DIR directly) -cat > "${TMP_DIR}/context-usage-reset-$$.json" < /dev/null 2>&1 -unset RING_RESET_CONTEXT_WARNING - -# Check state was reset (turn count should be 1 now) -STATE_FILE="${TMP_DIR}/context-usage-reset-$$.json" -if [[ -f "$STATE_FILE" ]]; then - if command -v jq &>/dev/null; then - count=$(jq -r '.turn_count' "$STATE_FILE") - else - count=$(grep -o '"turn_count":[0-9]*' "$STATE_FILE" | grep -o '[0-9]*') - fi - if [[ "$count" == "1" ]]; then - echo -e "${GREEN}PASS${NC}: State reset, turn count is 1" - TESTS_PASSED=$((TESTS_PASSED + 1)) - else - echo -e "${RED}FAIL${NC}: Turn count is $count, expected 1 after reset" - fi -else - echo -e "${RED}FAIL${NC}: State file not found" -fi -rm -f "${TMP_DIR}/context-usage-reset-"*.json - -# Summary -echo "========================================" -echo "Tests: $TESTS_PASSED/$TESTS_RUN passed" - -if [[ $TESTS_PASSED -eq $TESTS_RUN ]]; then - echo -e "${GREEN}All tests passed!${NC}" - exit 0 -else - echo -e "${RED}Some tests failed${NC}" - exit 1 -fi diff --git a/default/lib/artifact-index/__init__.py b/default/lib/artifact-index/__init__.py deleted file mode 100644 index 9367f21e..00000000 --- a/default/lib/artifact-index/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Artifact Index - SQLite-based indexing and search for Ring artifacts. - -This package provides: -- artifact_index.py: Index markdown files into SQLite with FTS5 -- artifact_query.py: Search artifacts with BM25 ranking -- artifact_mark.py: Mark handoff outcomes -- utils.py: Shared utilities (get_project_root, get_db_path, validate_limit) -""" - -from utils import get_project_root, get_db_path, validate_limit - -__version__ = "1.0.0" -__all__ = ["get_project_root", "get_db_path", "validate_limit"] diff --git a/default/lib/artifact-index/artifact_index.py b/default/lib/artifact-index/artifact_index.py deleted file mode 100755 index 363841ac..00000000 --- a/default/lib/artifact-index/artifact_index.py +++ /dev/null @@ -1,566 +0,0 @@ -#!/usr/bin/env python3 -""" -USAGE: artifact_index.py [--handoffs] [--plans] [--continuity] [--all] [--file PATH] [--db PATH] [--project PATH] - -Index handoffs, plans, and continuity ledgers into the Artifact Index database. - -Examples: - # Index all handoffs in current project - python3 artifact_index.py --handoffs - - # Index everything - python3 artifact_index.py --all - - # Index a single handoff file (fast, for hooks) - python3 artifact_index.py --file docs/handoffs/session/task-01.md - - # Use custom database path - python3 artifact_index.py --all --db /path/to/context.db - - # Index from specific project root - python3 artifact_index.py --all --project /path/to/project -""" - -import argparse -import hashlib -import json -import os -import re -import sqlite3 -import sys -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional, Any - -from utils import get_project_root, get_db_path - - -def get_schema_path() -> Path: - """Get path to schema file relative to this script.""" - return Path(__file__).parent / "artifact_schema.sql" - - -def init_db(db_path: Path) -> sqlite3.Connection: - """Initialize database with schema.""" - conn = sqlite3.connect(str(db_path), timeout=30.0) - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA synchronous=NORMAL") - conn.execute("PRAGMA cache_size=10000") - - schema_path = get_schema_path() - if schema_path.exists(): - conn.executescript(schema_path.read_text(encoding='utf-8')) - else: - print(f"Warning: Schema file not found at {schema_path}", file=sys.stderr) - - return conn - - -def parse_frontmatter(content: str) -> Dict[str, str]: - """Extract YAML frontmatter from markdown content.""" - frontmatter: Dict[str, str] = {} - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - for line in parts[1].strip().split("\n"): - if ":" in line: - key, value = line.split(":", 1) - frontmatter[key.strip()] = value.strip().strip('"').strip("'") - return frontmatter - - -def parse_sections(content: str) -> Dict[str, str]: - """Extract h2 and h3 sections from markdown content.""" - sections: Dict[str, str] = {} - current_section: Optional[str] = None - current_content: List[str] = [] - - # Skip frontmatter - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - content = parts[2] - - for line in content.split("\n"): - # h2 sections - if line.startswith("## "): - if current_section: - sections[current_section] = "\n".join(current_content).strip() - current_section = line[3:].strip().lower().replace(" ", "_").replace("-", "_") - current_content = [] - # h3 sections (nested) - elif line.startswith("### "): - if current_section: - sections[current_section] = "\n".join(current_content).strip() - current_section = line[4:].strip().lower().replace(" ", "_").replace("-", "_") - current_content = [] - elif current_section: - current_content.append(line) - - if current_section: - sections[current_section] = "\n".join(current_content).strip() - - return sections - - -def extract_files_from_content(content: str) -> List[str]: - """Extract file paths from markdown content.""" - files: List[str] = [] - for line in content.split("\n"): - # Match common patterns like "- `path/to/file.py`" or "- `path/to/file.py:123`" - matches = re.findall(r"`([^`]+\.[a-z]+)(:[^`]*)?`", line) - files.extend([m[0] for m in matches]) - # Match **File**: format - matches = re.findall(r"\*\*File\*\*:\s*`?([^\s`]+)`?", line) - files.extend(matches) - return list(set(files)) # Deduplicate - - -def parse_handoff(file_path: Path) -> Dict[str, Any]: - """Parse a handoff markdown file into structured data.""" - content = file_path.read_text(encoding='utf-8') - frontmatter = parse_frontmatter(content) - sections = parse_sections(content) - - # Generate ID from file path - file_id = hashlib.md5(str(file_path.resolve()).encode()).hexdigest()[:12] - - # Extract session name from path (docs/handoffs//task-XX.md) - parts = file_path.parts - session_name = "" - if "handoffs" in parts: - idx = parts.index("handoffs") - if idx + 1 < len(parts) and parts[idx + 1] != file_path.name: - session_name = parts[idx + 1] - - # Extract task number - task_match = re.search(r"task-(\d+)", file_path.stem) - task_number = int(task_match.group(1)) if task_match else None - - # Map status values to canonical outcome values - status = frontmatter.get("status", frontmatter.get("outcome", "UNKNOWN")).upper() - outcome_map = { - "SUCCESS": "SUCCEEDED", - "SUCCEEDED": "SUCCEEDED", - "PARTIAL": "PARTIAL_PLUS", - "PARTIAL_PLUS": "PARTIAL_PLUS", - "PARTIAL_MINUS": "PARTIAL_MINUS", - "FAILED": "FAILED", - "FAILURE": "FAILED", - "UNKNOWN": "UNKNOWN", - } - outcome = outcome_map.get(status, "UNKNOWN") - - # Extract content from various section names - task_summary = ( - sections.get("what_was_done") or - sections.get("summary") or - sections.get("task_summary") or - "" - )[:500] - - what_worked = ( - sections.get("what_worked") or - sections.get("successes") or - "" - ) - - what_failed = ( - sections.get("what_failed") or - sections.get("failures") or - sections.get("issues") or - "" - ) - - key_decisions = ( - sections.get("key_decisions") or - sections.get("decisions") or - "" - ) - - files_modified_section = ( - sections.get("files_modified") or - sections.get("files_changed") or - sections.get("files") or - "" - ) - - return { - "id": file_id, - "session_name": session_name or frontmatter.get("session", "default"), - "task_number": task_number, - "file_path": str(file_path.resolve()), - "task_summary": task_summary, - "what_worked": what_worked, - "what_failed": what_failed, - "key_decisions": key_decisions, - "files_modified": json.dumps(extract_files_from_content(files_modified_section)), - "outcome": outcome, - "created_at": frontmatter.get("date", frontmatter.get("created", datetime.now().isoformat())), - } - - -def parse_plan(file_path: Path) -> Dict[str, Any]: - """Parse a plan markdown file into structured data.""" - content = file_path.read_text(encoding='utf-8') - frontmatter = parse_frontmatter(content) - sections = parse_sections(content) - - # Generate ID from file path - file_id = hashlib.md5(str(file_path.resolve()).encode()).hexdigest()[:12] - - # Extract title from first H1 - title_match = re.search(r"^# (.+)$", content, re.MULTILINE) - title = title_match.group(1).strip() if title_match else file_path.stem - - # Extract session from path or frontmatter - session_name = frontmatter.get("session", "") - - # Extract phases - phases: List[Dict[str, str]] = [] - for key, value in sections.items(): - if key.startswith("phase") or key.startswith("task"): - phases.append({"name": key, "content": value[:500]}) - - return { - "id": file_id, - "session_name": session_name, - "title": title, - "file_path": str(file_path.resolve()), - "overview": (sections.get("overview") or sections.get("goal") or "")[:1000], - "approach": (sections.get("implementation_approach") or sections.get("approach") or sections.get("architecture") or "")[:1000], - "phases": json.dumps(phases), - "constraints": sections.get("constraints") or sections.get("what_we're_not_doing") or "", - "created_at": frontmatter.get("date", frontmatter.get("created", datetime.now().isoformat())), - } - - -def parse_continuity(file_path: Path) -> Dict[str, Any]: - """Parse a continuity ledger into structured data.""" - content = file_path.read_text(encoding='utf-8') - frontmatter = parse_frontmatter(content) - sections = parse_sections(content) - - # Generate ID from file path - file_id = hashlib.md5(str(file_path.resolve()).encode()).hexdigest()[:12] - - # Extract session name from filename (CONTINUITY-.md or CONTINUITY_CLAUDE-.md) - session_match = re.search(r"CONTINUITY[-_](?:CLAUDE-)?(.+)\.md", file_path.name, re.IGNORECASE) - session_name = session_match.group(1) if session_match else file_path.stem - - # Parse state section - state = sections.get("state", "") - state_done: List[str] = [] - state_now = "" - state_next = "" - - for line in state.split("\n"): - line_lower = line.lower() - if "[x]" in line_lower: - state_done.append(line.strip()) - elif "[->]" in line or "now:" in line_lower or "current:" in line_lower: - state_now = line.strip() - elif "[ ]" in line or "next:" in line_lower: - state_next = line.strip() - - # Validate snapshot_reason against allowed values - valid_reasons = {'phase_complete', 'session_end', 'milestone', 'manual', 'clear', 'compact'} - raw_reason = frontmatter.get("reason", "manual").lower() - snapshot_reason = raw_reason if raw_reason in valid_reasons else "manual" - - return { - "id": file_id, - "session_name": session_name, - "file_path": str(file_path.resolve()), - "goal": (sections.get("goal") or "")[:500], - "state_done": json.dumps(state_done), - "state_now": state_now, - "state_next": state_next, - "key_learnings": sections.get("key_learnings") or sections.get("key_learnings_(this_session)") or "", - "key_decisions": sections.get("key_decisions") or "", - "snapshot_reason": snapshot_reason, - } - - -def index_handoffs(conn: sqlite3.Connection, project_root: Path) -> int: - """Index all handoffs into the database.""" - # Check multiple possible handoff locations - handoff_paths = [ - project_root / "docs" / "handoffs", - project_root / ".ring" / "handoffs", - ] - - count = 0 - for base_path in handoff_paths: - if not base_path.exists(): - continue - - for handoff_file in base_path.rglob("*.md"): - try: - data = parse_handoff(handoff_file) - conn.execute(""" - INSERT OR REPLACE INTO handoffs - (id, session_name, task_number, file_path, task_summary, what_worked, - what_failed, key_decisions, files_modified, outcome, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - data["id"], data["session_name"], data["task_number"], data["file_path"], - data["task_summary"], data["what_worked"], data["what_failed"], - data["key_decisions"], data["files_modified"], data["outcome"], - data["created_at"] - )) - count += 1 - except Exception as e: - print(f"Error indexing {handoff_file}: {e}", file=sys.stderr) - - conn.commit() - if count > 0: - print(f"Indexed {count} handoffs") - return count - - -def index_plans(conn: sqlite3.Connection, project_root: Path) -> int: - """Index all plans into the database.""" - plans_path = project_root / "docs" / "plans" - - if not plans_path.exists(): - return 0 - - count = 0 - for plan_file in plans_path.glob("*.md"): - try: - data = parse_plan(plan_file) - conn.execute(""" - INSERT OR REPLACE INTO plans - (id, session_name, title, file_path, overview, approach, phases, constraints, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - data["id"], data["session_name"], data["title"], data["file_path"], - data["overview"], data["approach"], data["phases"], data["constraints"], - data["created_at"] - )) - count += 1 - except Exception as e: - print(f"Error indexing {plan_file}: {e}", file=sys.stderr) - - conn.commit() - if count > 0: - print(f"Indexed {count} plans") - return count - - -def index_continuity(conn: sqlite3.Connection, project_root: Path) -> int: - """Index all continuity ledgers into the database.""" - # Check multiple locations - ledger_paths = [ - project_root / ".ring" / "ledgers", - project_root, # Root level CONTINUITY-*.md - ] - - count = 0 - for base_path in ledger_paths: - if not base_path.exists(): - continue - - pattern = "CONTINUITY*.md" if base_path == project_root else "*.md" - for ledger_file in base_path.glob(pattern): - if not ledger_file.name.upper().startswith("CONTINUITY"): - continue - try: - data = parse_continuity(ledger_file) - conn.execute(""" - INSERT OR REPLACE INTO continuity - (id, session_name, file_path, goal, state_done, state_now, state_next, - key_learnings, key_decisions, snapshot_reason) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - data["id"], data["session_name"], data["file_path"], data["goal"], - data["state_done"], data["state_now"], data["state_next"], - data["key_learnings"], data["key_decisions"], data["snapshot_reason"] - )) - count += 1 - except Exception as e: - print(f"Error indexing {ledger_file}: {e}", file=sys.stderr) - - conn.commit() - if count > 0: - print(f"Indexed {count} continuity ledgers") - return count - - -def index_single_file(conn: sqlite3.Connection, file_path: Path) -> bool: - """Index a single file based on its location/type. - - Returns True if indexed successfully, False otherwise. - """ - file_path = Path(file_path).resolve() - - if not file_path.exists(): - print(f"File not found: {file_path}", file=sys.stderr) - return False - - if file_path.suffix != ".md": - print(f"Not a markdown file: {file_path}", file=sys.stderr) - return False - - path_str = str(file_path).lower() - - # Determine type by path - if "handoff" in path_str: - try: - data = parse_handoff(file_path) - conn.execute(""" - INSERT OR REPLACE INTO handoffs - (id, session_name, task_number, file_path, task_summary, what_worked, - what_failed, key_decisions, files_modified, outcome, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - data["id"], data["session_name"], data["task_number"], data["file_path"], - data["task_summary"], data["what_worked"], data["what_failed"], - data["key_decisions"], data["files_modified"], data["outcome"], - data["created_at"] - )) - conn.commit() - print(f"Indexed handoff: {file_path.name}") - return True - except Exception as e: - print(f"Error indexing handoff {file_path}: {e}", file=sys.stderr) - return False - - elif "plan" in path_str: - try: - data = parse_plan(file_path) - conn.execute(""" - INSERT OR REPLACE INTO plans - (id, session_name, title, file_path, overview, approach, phases, constraints, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - data["id"], data["session_name"], data["title"], data["file_path"], - data["overview"], data["approach"], data["phases"], data["constraints"], - data["created_at"] - )) - conn.commit() - print(f"Indexed plan: {file_path.name}") - return True - except Exception as e: - print(f"Error indexing plan {file_path}: {e}", file=sys.stderr) - return False - - elif "continuity" in path_str.lower() or file_path.name.upper().startswith("CONTINUITY"): - try: - data = parse_continuity(file_path) - conn.execute(""" - INSERT OR REPLACE INTO continuity - (id, session_name, file_path, goal, state_done, state_now, state_next, - key_learnings, key_decisions, snapshot_reason) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - data["id"], data["session_name"], data["file_path"], data["goal"], - data["state_done"], data["state_now"], data["state_next"], - data["key_learnings"], data["key_decisions"], data["snapshot_reason"] - )) - conn.commit() - print(f"Indexed continuity: {file_path.name}") - return True - except Exception as e: - print(f"Error indexing continuity {file_path}: {e}", file=sys.stderr) - return False - - else: - print(f"Unknown file type (not in handoffs/, plans/, or continuity path): {file_path}", file=sys.stderr) - return False - - -def rebuild_fts_indexes(conn: sqlite3.Connection) -> None: - """Rebuild FTS5 indexes and optimize for query performance.""" - print("Rebuilding FTS5 indexes...") - - try: - conn.execute("INSERT INTO handoffs_fts(handoffs_fts) VALUES('rebuild')") - conn.execute("INSERT INTO plans_fts(plans_fts) VALUES('rebuild')") - conn.execute("INSERT INTO continuity_fts(continuity_fts) VALUES('rebuild')") - conn.execute("INSERT INTO queries_fts(queries_fts) VALUES('rebuild')") - conn.execute("INSERT INTO learnings_fts(learnings_fts) VALUES('rebuild')") - - # Configure BM25 column weights - conn.execute("INSERT OR REPLACE INTO handoffs_fts(handoffs_fts, rank) VALUES('rank', 'bm25(10.0, 5.0, 3.0, 3.0, 1.0)')") - conn.execute("INSERT OR REPLACE INTO plans_fts(plans_fts, rank) VALUES('rank', 'bm25(10.0, 5.0, 3.0, 3.0, 1.0)')") - - print("Optimizing indexes...") - conn.execute("INSERT INTO handoffs_fts(handoffs_fts) VALUES('optimize')") - conn.execute("INSERT INTO plans_fts(plans_fts) VALUES('optimize')") - conn.execute("INSERT INTO continuity_fts(continuity_fts) VALUES('optimize')") - conn.execute("INSERT INTO queries_fts(queries_fts) VALUES('optimize')") - conn.execute("INSERT INTO learnings_fts(learnings_fts) VALUES('optimize')") - - conn.commit() - except sqlite3.OperationalError as e: - # FTS tables may not exist yet if no data - print(f"Note: FTS optimization skipped ({e})", file=sys.stderr) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Index Ring artifacts into SQLite for semantic search", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__ - ) - parser.add_argument("--handoffs", action="store_true", help="Index handoffs") - parser.add_argument("--plans", action="store_true", help="Index plans") - parser.add_argument("--continuity", action="store_true", help="Index continuity ledgers") - parser.add_argument("--all", action="store_true", help="Index everything") - parser.add_argument("--file", type=str, metavar="PATH", help="Index a single file (fast, for hooks)") - parser.add_argument("--db", type=str, metavar="PATH", help="Custom database path") - parser.add_argument("--project", type=str, metavar="PATH", help="Project root path (default: auto-detect)") - - args = parser.parse_args() - - project_root = get_project_root(args.project) - - # Handle single file indexing (fast path for hooks) - if args.file: - file_path = Path(args.file) - if not file_path.is_absolute(): - file_path = project_root / file_path - - if not file_path.exists(): - print(f"File not found: {file_path}", file=sys.stderr) - return 1 - - db_path = get_db_path(args.db, project_root) - conn = init_db(db_path) - success = index_single_file(conn, file_path) - conn.close() - return 0 if success else 1 - - if not any([args.handoffs, args.plans, args.continuity, args.all]): - parser.print_help() - return 0 - - db_path = get_db_path(args.db, project_root) - conn = init_db(db_path) - - print(f"Using database: {db_path}") - print(f"Project root: {project_root}") - - total = 0 - - if args.all or args.handoffs: - total += index_handoffs(conn, project_root) - - if args.all or args.plans: - total += index_plans(conn, project_root) - - if args.all or args.continuity: - total += index_continuity(conn, project_root) - - if total > 0: - rebuild_fts_indexes(conn) - - conn.close() - print(f"Done! Indexed {total} total artifacts.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/default/lib/artifact-index/artifact_mark.py b/default/lib/artifact-index/artifact_mark.py deleted file mode 100755 index 80b0a037..00000000 --- a/default/lib/artifact-index/artifact_mark.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -""" -USAGE: artifact_mark.py --handoff ID --outcome OUTCOME [--notes NOTES] [--db PATH] - -Mark a handoff with user outcome in the Artifact Index database. - -This updates the handoff's outcome and sets confidence to HIGH to indicate -user verification. Used for improving future session recommendations. - -Examples: - # Mark a handoff as succeeded - python3 artifact_mark.py --handoff abc123 --outcome SUCCEEDED - - # Mark with additional notes - python3 artifact_mark.py --handoff abc123 --outcome PARTIAL_PLUS --notes "Almost done, one test failing" - - # List recent handoffs to find IDs - python3 artifact_mark.py --list - - # Mark by file path instead of ID - python3 artifact_mark.py --file docs/handoffs/session/task-01.md --outcome SUCCEEDED -""" - -import argparse -import hashlib -import sqlite3 -import sys -from pathlib import Path -from typing import Optional - -from utils import get_project_root, get_db_path, validate_limit - - -def get_handoff_id_from_file(file_path: Path) -> str: - """Generate handoff ID from file path (same as indexer).""" - return hashlib.md5(str(file_path.resolve()).encode()).hexdigest()[:12] - - -def list_recent_handoffs(conn: sqlite3.Connection, limit: int = 10) -> None: - """List recent handoffs.""" - cursor = conn.execute(""" - SELECT id, session_name, task_number, task_summary, outcome, created_at - FROM handoffs - ORDER BY created_at DESC - LIMIT ? - """, (limit,)) - - print("## Recent Handoffs") - print() - for row in cursor.fetchall(): - handoff_id, session, task, summary, outcome, created = row - task_str = f"task-{task}" if task else "unknown" - summary_short = (summary or "")[:50] - outcome_str = outcome or "UNKNOWN" - print(f"- **{handoff_id}**: {session}/{task_str}") - print(f" Outcome: {outcome_str} | {summary_short}...") - print() - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Mark handoff outcome in Artifact Index", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__ - ) - parser.add_argument( - "--handoff", "-H", - type=str, - metavar="ID", - help="Handoff ID to mark" - ) - parser.add_argument( - "--file", "-f", - type=str, - metavar="PATH", - help="Handoff file path (alternative to --handoff)" - ) - parser.add_argument( - "--outcome", "-o", - choices=["SUCCEEDED", "PARTIAL_PLUS", "PARTIAL_MINUS", "FAILED"], - help="Outcome of the handoff" - ) - parser.add_argument( - "--notes", "-n", - default="", - help="Optional notes about the outcome" - ) - parser.add_argument( - "--db", - type=str, - metavar="PATH", - help="Custom database path" - ) - parser.add_argument( - "--list", "-l", - action="store_true", - help="List recent handoffs" - ) - parser.add_argument( - "--limit", - type=validate_limit, - default=10, - help="Limit for --list (1-100, default: 10)" - ) - - args = parser.parse_args() - - # Check mutual exclusivity of --handoff and --file - if args.handoff and args.file: - print("Error: Cannot use both --handoff and --file. Choose one.", file=sys.stderr) - return 1 - - db_path = get_db_path(args.db) - - if not db_path.exists(): - print(f"Error: Database not found: {db_path}", file=sys.stderr) - print("Run: python3 artifact_index.py --all", file=sys.stderr) - return 1 - - conn = sqlite3.connect(str(db_path), timeout=30.0) - - # List mode - if args.list: - list_recent_handoffs(conn, args.limit) - conn.close() - return 0 - - # Get handoff ID - handoff_id = args.handoff - if args.file: - file_path = Path(args.file) - if not file_path.is_absolute(): - file_path = get_project_root() / file_path - handoff_id = get_handoff_id_from_file(file_path) - - if not handoff_id or not args.outcome: - parser.print_help() - conn.close() - return 0 - - # First, check if handoff exists - cursor = conn.execute( - "SELECT id, session_name, task_number, task_summary FROM handoffs WHERE id = ?", - (handoff_id,) - ) - handoff = cursor.fetchone() - - if not handoff: - print(f"Error: Handoff not found: {handoff_id}", file=sys.stderr) - print("\nRecent handoffs:", file=sys.stderr) - list_recent_handoffs(conn, 5) - conn.close() - return 1 - - # Update the handoff - cursor = conn.execute( - "UPDATE handoffs SET outcome = ?, outcome_notes = ?, confidence = 'HIGH' WHERE id = ?", - (args.outcome, args.notes, handoff_id) - ) - - if cursor.rowcount == 0: - print(f"Error: Failed to update handoff: {handoff_id}", file=sys.stderr) - conn.close() - return 1 - - conn.commit() - - # Show confirmation - handoff_id_db, session, task_num, summary = handoff - print(f"[OK] Marked handoff {handoff_id} as {args.outcome}") - print(f" Session: {session}") - if task_num: - print(f" Task: task-{task_num}") - if summary: - print(f" Summary: {summary[:80]}...") - if args.notes: - print(f" Notes: {args.notes}") - print(f" Confidence: HIGH (user-verified)") - - conn.close() - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/default/lib/artifact-index/artifact_query.py b/default/lib/artifact-index/artifact_query.py deleted file mode 100755 index b39aec0f..00000000 --- a/default/lib/artifact-index/artifact_query.py +++ /dev/null @@ -1,588 +0,0 @@ -#!/usr/bin/env python3 -""" -USAGE: artifact_query.py [--type TYPE] [--outcome OUTCOME] [--limit N] [--db PATH] [--json] - -Search the Artifact Index for relevant precedent using FTS5 full-text search. - -Examples: - # Search for authentication-related work - python3 artifact_query.py "authentication OAuth JWT" - - # Search only successful handoffs - python3 artifact_query.py "implement agent" --outcome SUCCEEDED - - # Search plans only - python3 artifact_query.py "API design" --type plans - - # Output as JSON for programmatic use - python3 artifact_query.py "context management" --json - - # Limit results - python3 artifact_query.py "testing" --limit 3 -""" - -import argparse -import hashlib -import json -import sqlite3 -import sys -import time -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, List, Optional - -from utils import get_project_root, get_db_path, validate_limit - - -def escape_fts5_query(query: str) -> str: - """Escape FTS5 query to prevent syntax errors. - - Splits query into words and joins with OR for flexible matching. - Each word is quoted to handle special characters. - """ - words = query.split() - # Quote each word, escaping internal quotes - quoted_words = [f'"{w.replace(chr(34), chr(34)+chr(34))}"' for w in words if w] - # Join with OR for flexible matching - return " OR ".join(quoted_words) if quoted_words else '""' - - -def search_handoffs( - conn: sqlite3.Connection, - query: str, - outcome: Optional[str] = None, - limit: int = 5 -) -> List[Dict[str, Any]]: - """Search handoffs using FTS5 with BM25 ranking.""" - sql = """ - SELECT h.id, h.session_name, h.task_number, h.task_summary, - h.what_worked, h.what_failed, h.key_decisions, - h.outcome, h.file_path, h.created_at, - handoffs_fts.rank as score - FROM handoffs_fts - JOIN handoffs h ON handoffs_fts.rowid = h.rowid - WHERE handoffs_fts MATCH ? - """ - params: List[Any] = [escape_fts5_query(query)] - - if outcome: - sql += " AND h.outcome = ?" - params.append(outcome) - - sql += " ORDER BY rank LIMIT ?" - params.append(limit) - - try: - cursor = conn.execute(sql, params) - columns = [desc[0] for desc in cursor.description] - return [dict(zip(columns, row)) for row in cursor.fetchall()] - except sqlite3.OperationalError: - # FTS table may be empty - return [] - - -def query_for_planning(conn: sqlite3.Connection, topic: str, limit: int = 5) -> Dict[str, Any]: - """Query artifact index for planning context. - - Returns structured data optimized for plan generation: - - Successful implementations (what worked) - - Failed implementations (what to avoid) - - Relevant past plans - - Performance target: <200ms total query time. - """ - start_time = time.time() - - results: Dict[str, Any] = { - "topic": topic, - "successful_handoffs": [], - "failed_handoffs": [], - "relevant_plans": [], - "query_time_ms": 0, - "is_empty_index": False - } - - # Check if index has any data - try: - cursor = conn.execute("SELECT COUNT(*) FROM handoffs") - total_handoffs = cursor.fetchone()[0] - except sqlite3.OperationalError: - # Treat as empty index if schema is corrupted or table doesn't exist - results["is_empty_index"] = True - results["query_time_ms"] = int((time.time() - start_time) * 1000) - return results - - if total_handoffs == 0: - results["is_empty_index"] = True - results["query_time_ms"] = int((time.time() - start_time) * 1000) - return results - - # Query success and failure categories separately to ensure representation - # This prevents successful handoffs from crowding out failures in results - - # Get successful handoffs (SUCCEEDED and PARTIAL_PLUS) - succeeded = search_handoffs(conn, topic, outcome="SUCCEEDED", limit=limit) - partial_plus = search_handoffs(conn, topic, outcome="PARTIAL_PLUS", limit=limit) - results["successful_handoffs"] = (succeeded + partial_plus)[:limit] - - # Get failed handoffs (FAILED and PARTIAL_MINUS) - failed = search_handoffs(conn, topic, outcome="FAILED", limit=limit) - partial_minus = search_handoffs(conn, topic, outcome="PARTIAL_MINUS", limit=limit) - results["failed_handoffs"] = (failed + partial_minus)[:limit] - - # Query relevant plans (capped at 3 for context size, limit controls handoffs only) - results["relevant_plans"] = search_plans(conn, topic, limit=min(limit, 3)) - - results["query_time_ms"] = int((time.time() - start_time) * 1000) - - return results - - -def format_planning_results(results: Dict[str, Any]) -> str: - """Format planning query results for agent consumption.""" - output: List[str] = [] - - output.append("## Historical Precedent") - output.append("") - - if results.get("is_empty_index"): - output.append("**Note:** No historical data available (new project or empty index).") - output.append("Proceed with standard planning approach.") - output.append("") - return "\n".join(output) - - # Successful implementations - successful = results.get("successful_handoffs", []) - if successful: - output.append("### Successful Implementations (Reference These)") - output.append("") - for h in successful: - session = h.get('session_name', 'unknown') - task = h.get('task_number', '?') - outcome = h.get('outcome', 'UNKNOWN') - output.append(f"**[{session}/task-{task}]** ({outcome})") - - summary = h.get('task_summary', '') - if summary: - output.append(f"- Summary: {summary[:200]}") - - what_worked = h.get('what_worked', '') - if what_worked: - output.append(f"- What worked: {what_worked[:300]}") - - output.append(f"- File: `{h.get('file_path', 'unknown')}`") - output.append("") - else: - output.append("### Successful Implementations") - output.append("No relevant successful implementations found.") - output.append("") - - # Failed implementations - failed = results.get("failed_handoffs", []) - if failed: - output.append("### Failed Implementations (AVOID These Patterns)") - output.append("") - for h in failed: - session = h.get('session_name', 'unknown') - task = h.get('task_number', '?') - outcome = h.get('outcome', 'UNKNOWN') - output.append(f"**[{session}/task-{task}]** ({outcome})") - - summary = h.get('task_summary', '') - if summary: - output.append(f"- Summary: {summary[:200]}") - - what_failed = h.get('what_failed', '') - if what_failed: - output.append(f"- What failed: {what_failed[:300]}") - - output.append(f"- File: `{h.get('file_path', 'unknown')}`") - output.append("") - else: - output.append("### Failed Implementations") - output.append("No relevant failures found (good sign!).") - output.append("") - - # Relevant plans - plans = results.get("relevant_plans", []) - if plans: - output.append("### Relevant Past Plans") - output.append("") - for p in plans: - title = p.get('title', 'Untitled') - output.append(f"**{title}**") - - overview = p.get('overview', '') - if overview: - output.append(f"- Overview: {overview[:200]}") - - output.append(f"- File: `{p.get('file_path', 'unknown')}`") - output.append("") - - query_time = results.get("query_time_ms", 0) - output.append("---") - output.append(f"*Query completed in {query_time}ms*") - - return "\n".join(output) - - -def search_plans( - conn: sqlite3.Connection, - query: str, - limit: int = 3 -) -> List[Dict[str, Any]]: - """Search plans using FTS5 with BM25 ranking.""" - sql = """ - SELECT p.id, p.title, p.overview, p.approach, p.file_path, p.created_at, - plans_fts.rank as score - FROM plans_fts - JOIN plans p ON plans_fts.rowid = p.rowid - WHERE plans_fts MATCH ? - ORDER BY rank - LIMIT ? - """ - try: - cursor = conn.execute(sql, [escape_fts5_query(query), limit]) - columns = [desc[0] for desc in cursor.description] - return [dict(zip(columns, row)) for row in cursor.fetchall()] - except sqlite3.OperationalError: - return [] - - -def search_continuity( - conn: sqlite3.Connection, - query: str, - limit: int = 3 -) -> List[Dict[str, Any]]: - """Search continuity ledgers using FTS5 with BM25 ranking.""" - sql = """ - SELECT c.id, c.session_name, c.goal, c.key_learnings, c.key_decisions, - c.state_now, c.created_at, c.file_path, - continuity_fts.rank as score - FROM continuity_fts - JOIN continuity c ON continuity_fts.rowid = c.rowid - WHERE continuity_fts MATCH ? - ORDER BY rank - LIMIT ? - """ - try: - cursor = conn.execute(sql, [escape_fts5_query(query), limit]) - columns = [desc[0] for desc in cursor.description] - return [dict(zip(columns, row)) for row in cursor.fetchall()] - except sqlite3.OperationalError: - return [] - - -def search_past_queries( - conn: sqlite3.Connection, - query: str, - limit: int = 2 -) -> List[Dict[str, Any]]: - """Check if similar questions have been asked before (compound learning).""" - sql = """ - SELECT q.id, q.question, q.answer, q.was_helpful, q.created_at, - queries_fts.rank as score - FROM queries_fts - JOIN queries q ON queries_fts.rowid = q.rowid - WHERE queries_fts MATCH ? - ORDER BY rank - LIMIT ? - """ - try: - cursor = conn.execute(sql, [escape_fts5_query(query), limit]) - columns = [desc[0] for desc in cursor.description] - return [dict(zip(columns, row)) for row in cursor.fetchall()] - except sqlite3.OperationalError: - return [] - - -def get_handoff_by_id(conn: sqlite3.Connection, handoff_id: str) -> Optional[Dict[str, Any]]: - """Get a handoff by its ID.""" - sql = """ - SELECT id, session_name, task_number, task_summary, - what_worked, what_failed, key_decisions, - outcome, file_path, created_at - FROM handoffs - WHERE id = ? - LIMIT 1 - """ - cursor = conn.execute(sql, [handoff_id]) - columns = [desc[0] for desc in cursor.description] - row = cursor.fetchone() - if row: - return dict(zip(columns, row)) - return None - - -def save_query(conn: sqlite3.Connection, question: str, answer: str, matches: Dict[str, List]) -> None: - """Save query for compound learning.""" - query_id = hashlib.md5(f"{question}{datetime.now().isoformat()}".encode()).hexdigest()[:12] - - conn.execute(""" - INSERT INTO queries (id, question, answer, handoffs_matched, plans_matched, continuity_matched) - VALUES (?, ?, ?, ?, ?, ?) - """, ( - query_id, - question, - answer, - json.dumps([h["id"] for h in matches.get("handoffs", [])]), - json.dumps([p["id"] for p in matches.get("plans", [])]), - json.dumps([c["id"] for c in matches.get("continuity", [])]), - )) - conn.commit() - - -def format_results(results: Dict[str, List], verbose: bool = False) -> str: - """Format search results for human-readable display.""" - output: List[str] = [] - - # Past queries (compound learning) - if results.get("past_queries"): - output.append("## Previously Asked") - for q in results["past_queries"]: - question = q.get('question', '')[:100] - answer = q.get('answer', '')[:200] - output.append(f"- **Q:** {question}...") - output.append(f" **A:** {answer}...") - output.append("") - - # Handoffs - if results.get("handoffs"): - output.append("## Relevant Handoffs") - for h in results["handoffs"]: - status_icon = { - "SUCCEEDED": "[OK]", - "PARTIAL_PLUS": "[~+]", - "PARTIAL_MINUS": "[~-]", - "FAILED": "[X]", - "UNKNOWN": "[?]" - }.get(h.get("outcome", "UNKNOWN"), "[?]") - session = h.get('session_name', 'unknown') - task = h.get('task_number', '?') - output.append(f"### {status_icon} {session}/task-{task}") - summary = h.get('task_summary', '')[:200] - if summary: - output.append(f"**Summary:** {summary}") - what_worked = h.get("what_worked", "") - if what_worked: - output.append(f"**What worked:** {what_worked[:200]}") - what_failed = h.get("what_failed", "") - if what_failed: - output.append(f"**What failed:** {what_failed[:200]}") - output.append(f"**File:** `{h.get('file_path', '')}`") - output.append("") - - # Plans - if results.get("plans"): - output.append("## Relevant Plans") - for p in results["plans"]: - title = p.get('title', 'Untitled') - output.append(f"### {title}") - overview = p.get('overview', '')[:200] - if overview: - output.append(f"**Overview:** {overview}") - output.append(f"**File:** `{p.get('file_path', '')}`") - output.append("") - - # Continuity - if results.get("continuity"): - output.append("## Related Sessions") - for c in results["continuity"]: - session = c.get('session_name', 'unknown') - output.append(f"### Session: {session}") - goal = c.get('goal', '')[:200] - if goal: - output.append(f"**Goal:** {goal}") - key_learnings = c.get("key_learnings", "") - if key_learnings: - output.append(f"**Key learnings:** {key_learnings[:200]}") - output.append(f"**File:** `{c.get('file_path', '')}`") - output.append("") - - if not any(results.values()): - output.append("No relevant precedent found.") - - return "\n".join(output) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Search the Artifact Index for relevant precedent", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__ - ) - parser.add_argument("query", nargs="*", help="Search query") - parser.add_argument( - "--mode", "-m", - choices=["search", "planning"], - default="search", - help="Query mode: 'search' for general queries, 'planning' for structured planning context" - ) - parser.add_argument( - "--type", "-t", - choices=["handoffs", "plans", "continuity", "all"], - default="all", - help="Type of artifacts to search (default: all)" - ) - parser.add_argument( - "--outcome", "-o", - choices=["SUCCEEDED", "PARTIAL_PLUS", "PARTIAL_MINUS", "FAILED"], - help="Filter handoffs by outcome" - ) - parser.add_argument( - "--limit", "-l", - type=validate_limit, - default=5, - help="Maximum results per category (1-100, default: 5)" - ) - parser.add_argument( - "--db", - type=str, - metavar="PATH", - help="Custom database path" - ) - parser.add_argument( - "--no-save", - action="store_true", - help="Disable automatic query saving (saving enabled by default)" - ) - parser.add_argument( - "--json", "-j", - action="store_true", - help="Output as JSON" - ) - parser.add_argument( - "--id", - type=str, - metavar="ID", - help="Get specific handoff by ID" - ) - parser.add_argument( - "--stats", - action="store_true", - help="Show database statistics" - ) - - args = parser.parse_args() - - db_path = get_db_path(args.db) - - # Graceful handling for missing database in planning mode - if not db_path.exists(): - if args.mode == "planning": - # Planning mode: return empty index result (normal for new projects) - query = " ".join(args.query) if args.query else "" - results = { - "topic": query, - "successful_handoffs": [], - "failed_handoffs": [], - "relevant_plans": [], - "query_time_ms": 0, - "is_empty_index": True, - "message": "No artifact index found. This is normal for new projects." - } - if args.json: - print(json.dumps(results, indent=2, default=str)) - else: - print(format_planning_results(results)) - return 0 - else: - print(f"Database not found: {db_path}", file=sys.stderr) - print("Run: python3 artifact_index.py --all", file=sys.stderr) - return 1 - - conn = sqlite3.connect(str(db_path), timeout=30.0) - - # Stats mode - if args.stats: - stats = { - "handoffs": conn.execute("SELECT COUNT(*) FROM handoffs").fetchone()[0], - "plans": conn.execute("SELECT COUNT(*) FROM plans").fetchone()[0], - "continuity": conn.execute("SELECT COUNT(*) FROM continuity").fetchone()[0], - "queries": conn.execute("SELECT COUNT(*) FROM queries").fetchone()[0], - } - if args.json: - print(json.dumps(stats, indent=2)) - else: - print("## Artifact Index Statistics") - print(f"- Handoffs: {stats['handoffs']}") - print(f"- Plans: {stats['plans']}") - print(f"- Continuity ledgers: {stats['continuity']}") - print(f"- Saved queries: {stats['queries']}") - conn.close() - return 0 - - # ID lookup mode - if args.id: - handoff = get_handoff_by_id(conn, args.id) - if args.json: - print(json.dumps(handoff, indent=2, default=str)) - elif handoff: - print(f"## Handoff: {handoff.get('session_name')}/task-{handoff.get('task_number')}") - print(f"**Outcome:** {handoff.get('outcome', 'UNKNOWN')}") - print(f"**Summary:** {handoff.get('task_summary', '')}") - print(f"**File:** {handoff.get('file_path')}") - else: - print(f"Handoff not found: {args.id}", file=sys.stderr) - conn.close() - return 0 if handoff else 1 - - # Planning mode - structured output for plan generation - if args.mode == "planning": - if not args.query: - print("Error: Query required for planning mode", file=sys.stderr) - print("Usage: python3 artifact_query.py --mode planning 'topic keywords'", file=sys.stderr) - conn.close() - return 1 - - query = " ".join(args.query) - results = query_for_planning(conn, query, args.limit) - conn.close() - - if args.json: - print(json.dumps(results, indent=2, default=str)) - else: - print(format_planning_results(results)) - return 0 - - # Regular search mode - if not args.query: - parser.print_help() - conn.close() - return 0 - - query = " ".join(args.query) - - results: Dict[str, List] = {} - - # Always check past queries first - results["past_queries"] = search_past_queries(conn, query) - - if args.type in ["handoffs", "all"]: - results["handoffs"] = search_handoffs(conn, query, args.outcome, args.limit) - - if args.type in ["plans", "all"]: - results["plans"] = search_plans(conn, query, args.limit) - - if args.type in ["continuity", "all"]: - results["continuity"] = search_continuity(conn, query, args.limit) - - if args.json: - formatted = json.dumps(results, indent=2, default=str) - print(formatted) - else: - formatted = format_results(results) - print(formatted) - - # Auto-save queries for compound learning (unless --no-save) - if not args.no_save: - save_query(conn, query, formatted, results) - if not args.json: - print("\n[Query saved for compound learning]") - - conn.close() - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/default/lib/artifact-index/artifact_schema.sql b/default/lib/artifact-index/artifact_schema.sql deleted file mode 100644 index 8dc2ef3a..00000000 --- a/default/lib/artifact-index/artifact_schema.sql +++ /dev/null @@ -1,342 +0,0 @@ --- Ring Artifact Index Schema --- Database location: $PROJECT_ROOT/.ring/cache/artifact-index/context.db --- --- This schema supports indexing and querying Ring session artifacts: --- - Handoffs (completed tasks with post-mortems) --- - Plans (design documents) --- - Continuity ledgers (session state snapshots) --- - Queries (compound learning from Q&A) --- --- FTS5 is used for full-text search with porter stemming. --- Triggers keep FTS5 indexes in sync with main tables. --- Adapted from Continuous-Claude-v2 for Ring plugin architecture. - --- Enable WAL mode for better concurrent read performance -PRAGMA journal_mode=WAL; -PRAGMA synchronous=NORMAL; - --- ============================================================================ --- MAIN TABLES --- ============================================================================ - --- Handoffs (completed tasks with post-mortems) -CREATE TABLE IF NOT EXISTS handoffs ( - id TEXT PRIMARY KEY, - session_name TEXT NOT NULL, - task_number INTEGER, - file_path TEXT NOT NULL, - - -- Core content - task_summary TEXT, - what_worked TEXT, - what_failed TEXT, - key_decisions TEXT, - files_modified TEXT, -- JSON array - - -- Outcome (from user annotation) - outcome TEXT CHECK(outcome IN ('SUCCEEDED', 'PARTIAL_PLUS', 'PARTIAL_MINUS', 'FAILED', 'UNKNOWN')), - outcome_notes TEXT, - confidence TEXT CHECK(confidence IN ('HIGH', 'INFERRED')) DEFAULT 'INFERRED', - - -- Trace correlation (for debugging and analysis) - session_id TEXT, -- Claude session ID - trace_id TEXT, -- Optional external trace ID - - -- Metadata - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Plans (design documents) -CREATE TABLE IF NOT EXISTS plans ( - id TEXT PRIMARY KEY, - session_name TEXT, - title TEXT NOT NULL, - file_path TEXT NOT NULL, - - -- Content - overview TEXT, - approach TEXT, - phases TEXT, -- JSON array - constraints TEXT, - - -- Metadata - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Continuity snapshots (session state at key moments) -CREATE TABLE IF NOT EXISTS continuity ( - id TEXT PRIMARY KEY, - session_name TEXT NOT NULL, - file_path TEXT, - - -- State - goal TEXT, - state_done TEXT, -- JSON array - state_now TEXT, - state_next TEXT, - key_learnings TEXT, - key_decisions TEXT, - - -- Context - snapshot_reason TEXT CHECK(snapshot_reason IN ('phase_complete', 'session_end', 'milestone', 'manual', 'clear', 'compact')), - - -- Metadata - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Queries (compound learning from Q&A) -CREATE TABLE IF NOT EXISTS queries ( - id TEXT PRIMARY KEY, - question TEXT NOT NULL, - answer TEXT NOT NULL, - - -- Matches - handoffs_matched TEXT, -- JSON array of IDs - plans_matched TEXT, - continuity_matched TEXT, - - -- Feedback - was_helpful BOOLEAN, - - -- Metadata - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Learnings (extracted patterns from successful/failed sessions) -CREATE TABLE IF NOT EXISTS learnings ( - id TEXT PRIMARY KEY, - pattern_type TEXT CHECK(pattern_type IN ('success', 'failure', 'efficiency', 'antipattern')), - - -- Content - description TEXT NOT NULL, - context TEXT, -- When this applies - recommendation TEXT, -- What to do - - -- Source tracking - source_handoffs TEXT, -- JSON array of handoff IDs - occurrence_count INTEGER DEFAULT 1, - - -- Status - promoted_to TEXT, -- 'rule', 'skill', 'hook', or NULL - promoted_at TIMESTAMP, - - -- Metadata - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- ============================================================================ --- FTS5 INDEXES (Full-Text Search) --- ============================================================================ - --- FTS5 indexes for full-text search --- Using porter tokenizer for stemming + prefix index for code identifiers -CREATE VIRTUAL TABLE IF NOT EXISTS handoffs_fts USING fts5( - task_summary, what_worked, what_failed, key_decisions, files_modified, - content='handoffs', content_rowid='rowid', - tokenize='porter ascii', - prefix='2 3' -); - -CREATE VIRTUAL TABLE IF NOT EXISTS plans_fts USING fts5( - title, overview, approach, phases, constraints, - content='plans', content_rowid='rowid', - tokenize='porter ascii', - prefix='2 3' -); - -CREATE VIRTUAL TABLE IF NOT EXISTS continuity_fts USING fts5( - goal, key_learnings, key_decisions, state_now, - content='continuity', content_rowid='rowid', - tokenize='porter ascii' -); - -CREATE VIRTUAL TABLE IF NOT EXISTS queries_fts USING fts5( - question, answer, - content='queries', content_rowid='rowid', - tokenize='porter ascii' -); - -CREATE VIRTUAL TABLE IF NOT EXISTS learnings_fts USING fts5( - description, context, recommendation, - content='learnings', content_rowid='rowid', - tokenize='porter ascii' -); - --- ============================================================================ --- SYNC TRIGGERS (Keep FTS5 in sync with main tables) --- ============================================================================ - --- HANDOFFS triggers -CREATE TRIGGER IF NOT EXISTS handoffs_ai AFTER INSERT ON handoffs BEGIN - INSERT INTO handoffs_fts(rowid, task_summary, what_worked, what_failed, key_decisions, files_modified) - VALUES (NEW.rowid, NEW.task_summary, NEW.what_worked, NEW.what_failed, NEW.key_decisions, NEW.files_modified); -END; - -CREATE TRIGGER IF NOT EXISTS handoffs_ad AFTER DELETE ON handoffs BEGIN - INSERT INTO handoffs_fts(handoffs_fts, rowid, task_summary, what_worked, what_failed, key_decisions, files_modified) - VALUES('delete', OLD.rowid, OLD.task_summary, OLD.what_worked, OLD.what_failed, OLD.key_decisions, OLD.files_modified); -END; - -CREATE TRIGGER IF NOT EXISTS handoffs_au AFTER UPDATE ON handoffs BEGIN - INSERT INTO handoffs_fts(handoffs_fts, rowid, task_summary, what_worked, what_failed, key_decisions, files_modified) - VALUES('delete', OLD.rowid, OLD.task_summary, OLD.what_worked, OLD.what_failed, OLD.key_decisions, OLD.files_modified); - INSERT INTO handoffs_fts(rowid, task_summary, what_worked, what_failed, key_decisions, files_modified) - VALUES (NEW.rowid, NEW.task_summary, NEW.what_worked, NEW.what_failed, NEW.key_decisions, NEW.files_modified); -END; - --- PLANS triggers -CREATE TRIGGER IF NOT EXISTS plans_ai AFTER INSERT ON plans BEGIN - INSERT INTO plans_fts(rowid, title, overview, approach, phases, constraints) - VALUES (NEW.rowid, NEW.title, NEW.overview, NEW.approach, NEW.phases, NEW.constraints); -END; - -CREATE TRIGGER IF NOT EXISTS plans_ad AFTER DELETE ON plans BEGIN - INSERT INTO plans_fts(plans_fts, rowid, title, overview, approach, phases, constraints) - VALUES('delete', OLD.rowid, OLD.title, OLD.overview, OLD.approach, OLD.phases, OLD.constraints); -END; - -CREATE TRIGGER IF NOT EXISTS plans_au AFTER UPDATE ON plans BEGIN - INSERT INTO plans_fts(plans_fts, rowid, title, overview, approach, phases, constraints) - VALUES('delete', OLD.rowid, OLD.title, OLD.overview, OLD.approach, OLD.phases, OLD.constraints); - INSERT INTO plans_fts(rowid, title, overview, approach, phases, constraints) - VALUES (NEW.rowid, NEW.title, NEW.overview, NEW.approach, NEW.phases, NEW.constraints); -END; - --- CONTINUITY triggers -CREATE TRIGGER IF NOT EXISTS continuity_ai AFTER INSERT ON continuity BEGIN - INSERT INTO continuity_fts(rowid, goal, key_learnings, key_decisions, state_now) - VALUES (NEW.rowid, NEW.goal, NEW.key_learnings, NEW.key_decisions, NEW.state_now); -END; - -CREATE TRIGGER IF NOT EXISTS continuity_ad AFTER DELETE ON continuity BEGIN - INSERT INTO continuity_fts(continuity_fts, rowid, goal, key_learnings, key_decisions, state_now) - VALUES('delete', OLD.rowid, OLD.goal, OLD.key_learnings, OLD.key_decisions, OLD.state_now); -END; - -CREATE TRIGGER IF NOT EXISTS continuity_au AFTER UPDATE ON continuity BEGIN - INSERT INTO continuity_fts(continuity_fts, rowid, goal, key_learnings, key_decisions, state_now) - VALUES('delete', OLD.rowid, OLD.goal, OLD.key_learnings, OLD.key_decisions, OLD.state_now); - INSERT INTO continuity_fts(rowid, goal, key_learnings, key_decisions, state_now) - VALUES (NEW.rowid, NEW.goal, NEW.key_learnings, NEW.key_decisions, NEW.state_now); -END; - --- QUERIES triggers -CREATE TRIGGER IF NOT EXISTS queries_ai AFTER INSERT ON queries BEGIN - INSERT INTO queries_fts(rowid, question, answer) - VALUES (NEW.rowid, NEW.question, NEW.answer); -END; - -CREATE TRIGGER IF NOT EXISTS queries_ad AFTER DELETE ON queries BEGIN - INSERT INTO queries_fts(queries_fts, rowid, question, answer) - VALUES('delete', OLD.rowid, OLD.question, OLD.answer); -END; - -CREATE TRIGGER IF NOT EXISTS queries_au AFTER UPDATE ON queries BEGIN - INSERT INTO queries_fts(queries_fts, rowid, question, answer) - VALUES('delete', OLD.rowid, OLD.question, OLD.answer); - INSERT INTO queries_fts(rowid, question, answer) - VALUES (NEW.rowid, NEW.question, NEW.answer); -END; - --- LEARNINGS triggers -CREATE TRIGGER IF NOT EXISTS learnings_ai AFTER INSERT ON learnings BEGIN - INSERT INTO learnings_fts(rowid, description, context, recommendation) - VALUES (NEW.rowid, NEW.description, NEW.context, NEW.recommendation); -END; - -CREATE TRIGGER IF NOT EXISTS learnings_ad AFTER DELETE ON learnings BEGIN - INSERT INTO learnings_fts(learnings_fts, rowid, description, context, recommendation) - VALUES('delete', OLD.rowid, OLD.description, OLD.context, OLD.recommendation); -END; - -CREATE TRIGGER IF NOT EXISTS learnings_au AFTER UPDATE ON learnings BEGIN - INSERT INTO learnings_fts(learnings_fts, rowid, description, context, recommendation) - VALUES('delete', OLD.rowid, OLD.description, OLD.context, OLD.recommendation); - INSERT INTO learnings_fts(rowid, description, context, recommendation) - VALUES (NEW.rowid, NEW.description, NEW.context, NEW.recommendation); -END; - --- Auto-update updated_at timestamp on learnings modification -CREATE TRIGGER IF NOT EXISTS learnings_update_timestamp -AFTER UPDATE ON learnings -FOR EACH ROW -WHEN NEW.updated_at = OLD.updated_at -BEGIN - UPDATE learnings SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; -END; - --- ============================================================================ --- INDEXES (Performance optimization) --- ============================================================================ - -CREATE INDEX IF NOT EXISTS idx_handoffs_session ON handoffs(session_name); -CREATE INDEX IF NOT EXISTS idx_handoffs_outcome ON handoffs(outcome); -CREATE INDEX IF NOT EXISTS idx_handoffs_created ON handoffs(created_at); - -CREATE INDEX IF NOT EXISTS idx_plans_session ON plans(session_name); -CREATE INDEX IF NOT EXISTS idx_plans_created ON plans(created_at); - -CREATE INDEX IF NOT EXISTS idx_continuity_session ON continuity(session_name); -CREATE INDEX IF NOT EXISTS idx_continuity_reason ON continuity(snapshot_reason); - -CREATE INDEX IF NOT EXISTS idx_queries_created ON queries(created_at); - -CREATE INDEX IF NOT EXISTS idx_learnings_type ON learnings(pattern_type); -CREATE INDEX IF NOT EXISTS idx_learnings_promoted ON learnings(promoted_to); - --- ============================================================================ --- VIEWS (Convenience queries) --- ============================================================================ - --- Recent handoffs with outcomes -CREATE VIEW IF NOT EXISTS recent_handoffs AS -SELECT - id, - session_name, - task_summary, - outcome, - created_at -FROM handoffs -ORDER BY created_at DESC -LIMIT 100; - --- Success patterns (for compound learning) -CREATE VIEW IF NOT EXISTS success_patterns AS -SELECT - what_worked, - GROUP_CONCAT(DISTINCT task_summary, ' | ') as task_summaries, - GROUP_CONCAT(DISTINCT key_decisions, ' | ') as key_decisions_list, - COUNT(*) as occurrence_count -FROM handoffs -WHERE outcome IN ('SUCCEEDED', 'PARTIAL_PLUS') - AND what_worked IS NOT NULL - AND what_worked != '' -GROUP BY what_worked -HAVING COUNT(*) >= 2 -ORDER BY occurrence_count DESC; - --- Failure patterns (for compound learning) -CREATE VIEW IF NOT EXISTS failure_patterns AS -SELECT - what_failed, - GROUP_CONCAT(DISTINCT task_summary, ' | ') as task_summaries, - GROUP_CONCAT(DISTINCT outcome_notes, ' | ') as outcome_notes_list, - COUNT(*) as occurrence_count -FROM handoffs -WHERE outcome IN ('FAILED', 'PARTIAL_MINUS') - AND what_failed IS NOT NULL - AND what_failed != '' -GROUP BY what_failed -HAVING COUNT(*) >= 2 -ORDER BY occurrence_count DESC; - --- Unpromoted learnings ready for review -CREATE VIEW IF NOT EXISTS pending_learnings AS -SELECT * -FROM learnings -WHERE promoted_to IS NULL - AND occurrence_count >= 3 -ORDER BY occurrence_count DESC, created_at DESC; diff --git a/default/lib/artifact-index/tests/__init__.py b/default/lib/artifact-index/tests/__init__.py deleted file mode 100644 index 476cd7fd..00000000 --- a/default/lib/artifact-index/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Tests for artifact-index modules diff --git a/default/lib/artifact-index/tests/test_artifact_index.py b/default/lib/artifact-index/tests/test_artifact_index.py deleted file mode 100644 index 9ff0eef2..00000000 --- a/default/lib/artifact-index/tests/test_artifact_index.py +++ /dev/null @@ -1,892 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive test suite for artifact-index Python modules. - -Tests cover: -- artifact_index.py: parse_frontmatter, parse_sections, parse_handoff, get_project_root -- artifact_query.py: escape_fts5_query, validate_limit, get_project_root -- artifact_mark.py: validate_limit, get_handoff_id_from_file -""" - -import argparse -import os -import tempfile -from pathlib import Path -from unittest.mock import patch -import sys - -import pytest - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from artifact_index import ( - parse_frontmatter, - parse_sections, - parse_handoff, - extract_files_from_content, - get_project_root as index_get_project_root, -) -from artifact_query import ( - escape_fts5_query, - validate_limit as query_validate_limit, - get_project_root as query_get_project_root, -) -from artifact_mark import ( - validate_limit as mark_validate_limit, - get_project_root as mark_get_project_root, - get_handoff_id_from_file, -) - - -# ============================================================================= -# Tests for parse_frontmatter() -# ============================================================================= - -class TestParseFrontmatter: - """Tests for YAML frontmatter extraction from markdown content.""" - - def test_valid_frontmatter_simple(self): - """Test extraction of simple key-value pairs from frontmatter.""" - content = """--- -title: My Document -author: John Doe -status: SUCCESS ---- -# Content here -""" - result = parse_frontmatter(content) - assert result["title"] == "My Document" - assert result["author"] == "John Doe" - assert result["status"] == "SUCCESS" - - def test_valid_frontmatter_with_quotes(self): - """Test extraction handles quoted values correctly.""" - content = """--- -title: "Quoted Title" -description: 'Single quotes' ---- -Content -""" - result = parse_frontmatter(content) - assert result["title"] == "Quoted Title" - assert result["description"] == "Single quotes" - - def test_missing_frontmatter_no_markers(self): - """Test returns empty dict when no frontmatter markers exist.""" - content = """# Just a markdown document - -No frontmatter here. -""" - result = parse_frontmatter(content) - assert result == {} - - def test_missing_frontmatter_single_marker(self): - """Test returns empty dict with only one --- marker.""" - content = """--- -title: incomplete -# Rest of document -""" - result = parse_frontmatter(content) - assert result == {} - - def test_empty_content(self): - """Test returns empty dict for empty content.""" - result = parse_frontmatter("") - assert result == {} - - def test_frontmatter_only_dashes(self): - """Test handles frontmatter with only dashes (empty frontmatter).""" - content = """--- ---- -# Content -""" - result = parse_frontmatter(content) - assert result == {} - - def test_nested_values_are_flattened(self): - """Test that complex YAML is handled (values with colons).""" - content = """--- -url: https://example.com -time: 12:30:00 ---- -Content -""" - result = parse_frontmatter(content) - # URL should capture everything after first colon - assert result["url"] == "https://example.com" - assert result["time"] == "12:30:00" - - def test_frontmatter_with_empty_values(self): - """Test handles keys with empty values.""" - content = """--- -title: -author: John ---- -Content -""" - result = parse_frontmatter(content) - assert result["title"] == "" - assert result["author"] == "John" - - def test_frontmatter_with_spaces_in_values(self): - """Test preserves spaces in values correctly.""" - content = """--- -title: My Great Title With Spaces -description: A longer description here ---- -Content -""" - result = parse_frontmatter(content) - assert result["title"] == "My Great Title With Spaces" - assert result["description"] == "A longer description here" - - def test_frontmatter_trims_whitespace(self): - """Test that keys and values are trimmed.""" - content = """--- - title : Padded Value ---- -Content -""" - result = parse_frontmatter(content) - assert result["title"] == "Padded Value" - - -# ============================================================================= -# Tests for parse_sections() -# ============================================================================= - -class TestParseSections: - """Tests for markdown section extraction by header.""" - - def test_extract_h2_sections(self): - """Test extraction of h2 (##) sections.""" - content = """## What Was Done - -This is the summary of work. - -## What Worked - -Everything went great. - -## What Failed - -Nothing failed. -""" - result = parse_sections(content) - assert "what_was_done" in result - assert result["what_was_done"] == "This is the summary of work." - assert "what_worked" in result - assert result["what_worked"] == "Everything went great." - assert "what_failed" in result - assert result["what_failed"] == "Nothing failed." - - def test_extract_h3_sections(self): - """Test extraction of h3 (###) sections.""" - content = """### Task Summary - -Summary content here. - -### Key Decisions - -Decision content. -""" - result = parse_sections(content) - assert "task_summary" in result - assert result["task_summary"] == "Summary content here." - assert "key_decisions" in result - assert result["key_decisions"] == "Decision content." - - def test_mixed_h2_and_h3_sections(self): - """Test extraction handles both h2 and h3 sections.""" - content = """## Main Section - -Main content. - -### Sub Section - -Sub content. - -## Another Main - -More content. -""" - result = parse_sections(content) - assert "main_section" in result - assert "sub_section" in result - assert "another_main" in result - - def test_empty_sections(self): - """Test handles sections with no content.""" - content = """## Empty Section - -## Next Section - -Has content. -""" - result = parse_sections(content) - assert result["empty_section"] == "" - assert result["next_section"] == "Has content." - - def test_no_sections(self): - """Test returns empty dict when no sections exist.""" - content = """Just plain text. - -No headers here. -""" - result = parse_sections(content) - assert result == {} - - def test_skips_frontmatter(self): - """Test frontmatter is skipped before parsing sections.""" - content = """--- -title: Document ---- - -## Actual Section - -This is content. -""" - result = parse_sections(content) - # Should not have 'title' as a section - assert "title" not in result - assert "actual_section" in result - assert result["actual_section"] == "This is content." - - def test_section_name_normalization(self): - """Test section names are normalized (lowercase, underscores).""" - content = """## What Was Done - -Content here. - -## Key-Decisions - -More content. -""" - result = parse_sections(content) - assert "what_was_done" in result - assert "key_decisions" in result # hyphen becomes underscore - - def test_multiline_section_content(self): - """Test sections preserve multiline content.""" - content = """## Description - -Line 1 -Line 2 -Line 3 - -## Next -""" - result = parse_sections(content) - assert "Line 1" in result["description"] - assert "Line 2" in result["description"] - assert "Line 3" in result["description"] - - def test_section_content_is_trimmed(self): - """Test section content has leading/trailing whitespace trimmed.""" - content = """## Section - - -Content with extra blank lines above. - - -""" - result = parse_sections(content) - assert result["section"] == "Content with extra blank lines above." - - -# ============================================================================= -# Tests for parse_handoff() -# ============================================================================= - -class TestParseHandoff: - """Tests for handoff markdown file parsing.""" - - def test_valid_handoff_parsing(self): - """Test parsing a complete handoff file with all fields.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create directory structure: docs/handoffs/test-session/task-01.md - handoff_dir = Path(tmpdir) / "docs" / "handoffs" / "test-session" - handoff_dir.mkdir(parents=True) - handoff_file = handoff_dir / "task-01.md" - - content = """--- -status: SUCCESS -date: 2024-01-15 ---- - -## What Was Done - -Implemented the authentication system. - -## What Worked - -- JWT token generation -- Session management - -## What Failed - -Nothing major failed. - -## Key Decisions - -Used RS256 for signing. - -## Files Modified - -- `src/auth/jwt.py` -- `src/auth/session.py` -""" - handoff_file.write_text(content) - - result = parse_handoff(handoff_file) - - assert result["session_name"] == "test-session" - assert result["task_number"] == 1 - assert result["outcome"] == "SUCCEEDED" # SUCCESS maps to SUCCEEDED - assert "Implemented the authentication system" in result["task_summary"] - assert "JWT token generation" in result["what_worked"] - assert "Nothing major failed" in result["what_failed"] - assert "RS256" in result["key_decisions"] - assert result["file_path"] == str(handoff_file.resolve()) - assert result["id"] # Should have generated an ID - - def test_session_name_extraction_from_path(self): - """Test session name is extracted from directory structure.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create: handoffs/my-feature-branch/task-05.md - handoff_dir = Path(tmpdir) / "handoffs" / "my-feature-branch" - handoff_dir.mkdir(parents=True) - handoff_file = handoff_dir / "task-05.md" - handoff_file.write_text("---\nstatus: SUCCESS\n---\n## What Was Done\nTest") - - result = parse_handoff(handoff_file) - - assert result["session_name"] == "my-feature-branch" - assert result["task_number"] == 5 - - def test_task_number_extraction_regex(self): - """Test task number extraction via regex patterns.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_dir = Path(tmpdir) / "handoffs" / "session" - handoff_dir.mkdir(parents=True) - - # Test various task number patterns - test_cases = [ - ("task-01.md", 1), - ("task-12.md", 12), - ("task-099.md", 99), - ] - - for filename, expected_task_num in test_cases: - handoff_file = handoff_dir / filename - handoff_file.write_text("---\nstatus: SUCCESS\n---\n## What Was Done\nTest") - result = parse_handoff(handoff_file) - assert result["task_number"] == expected_task_num, f"Failed for {filename}" - - def test_outcome_mapping_success(self): - """Test outcome mapping: SUCCESS -> SUCCEEDED.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - handoff_file.write_text("---\nstatus: SUCCESS\n---\n## What Was Done\nTest") - result = parse_handoff(handoff_file) - assert result["outcome"] == "SUCCEEDED" - - def test_outcome_mapping_succeeded(self): - """Test outcome mapping: SUCCEEDED stays SUCCEEDED.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - handoff_file.write_text("---\nstatus: SUCCEEDED\n---\n## What Was Done\nTest") - result = parse_handoff(handoff_file) - assert result["outcome"] == "SUCCEEDED" - - def test_outcome_mapping_partial(self): - """Test outcome mapping: PARTIAL -> PARTIAL_PLUS.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - handoff_file.write_text("---\nstatus: PARTIAL\n---\n## What Was Done\nTest") - result = parse_handoff(handoff_file) - assert result["outcome"] == "PARTIAL_PLUS" - - def test_outcome_mapping_failed(self): - """Test outcome mapping: FAILED stays FAILED.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - handoff_file.write_text("---\nstatus: FAILED\n---\n## What Was Done\nTest") - result = parse_handoff(handoff_file) - assert result["outcome"] == "FAILED" - - def test_outcome_mapping_failure(self): - """Test outcome mapping: FAILURE -> FAILED.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - handoff_file.write_text("---\nstatus: FAILURE\n---\n## What Was Done\nTest") - result = parse_handoff(handoff_file) - assert result["outcome"] == "FAILED" - - def test_outcome_mapping_unknown(self): - """Test outcome mapping: unknown values -> UNKNOWN.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - handoff_file.write_text("---\nstatus: FOOBAR\n---\n## What Was Done\nTest") - result = parse_handoff(handoff_file) - assert result["outcome"] == "UNKNOWN" - - def test_missing_frontmatter_uses_defaults(self): - """Test graceful handling when frontmatter fields are missing.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - handoff_file.write_text("## What Was Done\n\nSome work was done.") - - result = parse_handoff(handoff_file) - - assert result["outcome"] == "UNKNOWN" - assert result["task_summary"] == "Some work was done." - - def test_uses_outcome_field_as_fallback(self): - """Test uses 'outcome' frontmatter field when 'status' is missing.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - handoff_file.write_text("---\noutcome: SUCCESS\n---\n## What Was Done\nTest") - result = parse_handoff(handoff_file) - assert result["outcome"] == "SUCCEEDED" - - def test_alternative_section_names(self): - """Test alternative section names are recognized.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - content = """--- -status: SUCCESS ---- - -## Summary - -Using 'Summary' instead of 'What Was Done'. - -## Successes - -Instead of 'What Worked'. - -## Failures - -Instead of 'What Failed'. - -## Decisions - -Instead of 'Key Decisions'. -""" - handoff_file.write_text(content) - - result = parse_handoff(handoff_file) - - assert "Using 'Summary'" in result["task_summary"] - assert "Instead of 'What Worked'" in result["what_worked"] - assert "Instead of 'What Failed'" in result["what_failed"] - assert "Instead of 'Key Decisions'" in result["key_decisions"] - - -# ============================================================================= -# Tests for extract_files_from_content() -# ============================================================================= - -class TestExtractFilesFromContent: - """Tests for file path extraction from markdown content.""" - - def test_extract_backtick_file_paths(self): - """Test extraction of file paths in backticks.""" - content = """ -- `src/auth/jwt.py` -- `src/models/user.py` -- `tests/test_auth.py` -""" - result = extract_files_from_content(content) - assert "src/auth/jwt.py" in result - assert "src/models/user.py" in result - assert "tests/test_auth.py" in result - - def test_extract_file_paths_with_line_numbers(self): - """Test extraction handles file paths with line numbers.""" - content = """ -- `src/auth/jwt.py:45` -- `src/models/user.py:123-456` -""" - result = extract_files_from_content(content) - assert "src/auth/jwt.py" in result - assert "src/models/user.py" in result - - def test_extract_file_format_style(self): - """Test extraction of **File**: format.""" - content = """ -**File**: `src/main.py` -**File**: src/utils.py -""" - result = extract_files_from_content(content) - assert "src/main.py" in result - assert "src/utils.py" in result - - def test_deduplicates_results(self): - """Test duplicate file paths are removed.""" - content = """ -- `src/auth.py` -- `src/auth.py` -- `src/auth.py:10` -""" - result = extract_files_from_content(content) - assert result.count("src/auth.py") == 1 - - def test_empty_content(self): - """Test returns empty list for empty content.""" - result = extract_files_from_content("") - assert result == [] - - -# ============================================================================= -# Tests for escape_fts5_query() -# ============================================================================= - -class TestEscapeFts5Query: - """Tests for FTS5 query escaping.""" - - def test_basic_word_quoting(self): - """Test basic words are quoted for FTS5.""" - result = escape_fts5_query("hello world") - assert '"hello"' in result - assert '"world"' in result - assert " OR " in result - - def test_single_word(self): - """Test single word is quoted.""" - result = escape_fts5_query("authentication") - assert result == '"authentication"' - - def test_special_characters_in_query(self): - """Test special characters are handled.""" - result = escape_fts5_query("user@example.com") - assert '"user@example.com"' in result - - def test_empty_query(self): - """Test empty query returns empty quoted string.""" - result = escape_fts5_query("") - assert result == '""' - - def test_query_with_embedded_quotes(self): - """Test embedded quotes are escaped (doubled).""" - result = escape_fts5_query('say "hello"') - # Double quotes in FTS5 are escaped by doubling them - assert '""' in result or 'hello' in result - - def test_multiple_spaces_between_words(self): - """Test multiple spaces are handled correctly.""" - result = escape_fts5_query("word1 word2") - # Should handle multiple spaces without creating empty words - assert '"word1"' in result - assert '"word2"' in result - - def test_or_joining(self): - """Test words are joined with OR for flexible matching.""" - result = escape_fts5_query("auth jwt oauth") - assert result == '"auth" OR "jwt" OR "oauth"' - - -# ============================================================================= -# Tests for validate_limit() -# ============================================================================= - -class TestValidateLimit: - """Tests for limit validation in both query and mark modules.""" - - def test_valid_limit_minimum(self): - """Test minimum valid limit (1).""" - result = query_validate_limit("1") - assert result == 1 - - result = mark_validate_limit("1") - assert result == 1 - - def test_valid_limit_maximum(self): - """Test maximum valid limit (100).""" - result = query_validate_limit("100") - assert result == 100 - - result = mark_validate_limit("100") - assert result == 100 - - def test_valid_limit_middle(self): - """Test middle range limit.""" - result = query_validate_limit("50") - assert result == 50 - - def test_invalid_limit_below_minimum(self): - """Test limit below minimum raises error.""" - with pytest.raises(argparse.ArgumentTypeError) as exc_info: - query_validate_limit("0") - assert "between 1 and 100" in str(exc_info.value) - - def test_invalid_limit_above_maximum(self): - """Test limit above maximum raises error.""" - with pytest.raises(argparse.ArgumentTypeError) as exc_info: - query_validate_limit("101") - assert "between 1 and 100" in str(exc_info.value) - - def test_invalid_limit_negative(self): - """Test negative limit raises error.""" - with pytest.raises(argparse.ArgumentTypeError) as exc_info: - query_validate_limit("-5") - assert "between 1 and 100" in str(exc_info.value) - - def test_non_numeric_input(self): - """Test non-numeric input raises error.""" - with pytest.raises(argparse.ArgumentTypeError) as exc_info: - query_validate_limit("abc") - assert "Invalid integer value" in str(exc_info.value) - - def test_float_input(self): - """Test float input raises error.""" - with pytest.raises(argparse.ArgumentTypeError) as exc_info: - query_validate_limit("5.5") - assert "Invalid integer value" in str(exc_info.value) - - def test_empty_string(self): - """Test empty string raises error.""" - with pytest.raises(argparse.ArgumentTypeError) as exc_info: - query_validate_limit("") - assert "Invalid integer value" in str(exc_info.value) - - -# ============================================================================= -# Tests for get_project_root() -# ============================================================================= - -class TestGetProjectRoot: - """Tests for project root detection.""" - - def test_finds_ring_directory(self): - """Test finding project root via .ring directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create .ring directory - ring_dir = Path(tmpdir) / ".ring" - ring_dir.mkdir() - - # Create a subdirectory to test from - sub_dir = Path(tmpdir) / "src" / "lib" - sub_dir.mkdir(parents=True) - - # Change to subdirectory and test - original_cwd = os.getcwd() - try: - os.chdir(sub_dir) - result = query_get_project_root() - assert result == Path(tmpdir).resolve() - finally: - os.chdir(original_cwd) - - def test_finds_git_directory(self): - """Test finding project root via .git directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create .git directory - git_dir = Path(tmpdir) / ".git" - git_dir.mkdir() - - # Create a subdirectory to test from - sub_dir = Path(tmpdir) / "deep" / "nested" / "path" - sub_dir.mkdir(parents=True) - - original_cwd = os.getcwd() - try: - os.chdir(sub_dir) - result = query_get_project_root() - assert result == Path(tmpdir).resolve() - finally: - os.chdir(original_cwd) - - def test_prefers_ring_over_git(self): - """Test .ring is found before .git (both exist).""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create both .ring and .git - ring_dir = Path(tmpdir) / ".ring" - ring_dir.mkdir() - git_dir = Path(tmpdir) / ".git" - git_dir.mkdir() - - original_cwd = os.getcwd() - try: - os.chdir(tmpdir) - result = query_get_project_root() - assert result == Path(tmpdir).resolve() - finally: - os.chdir(original_cwd) - - def test_returns_cwd_when_no_marker_found(self): - """Test returns current directory when no .ring or .git found.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Don't create any markers - original_cwd = os.getcwd() - try: - os.chdir(tmpdir) - result = query_get_project_root() - assert result == Path(tmpdir).resolve() - finally: - os.chdir(original_cwd) - - def test_index_get_project_root_with_custom_path(self): - """Test artifact_index.get_project_root with custom path argument.""" - with tempfile.TemporaryDirectory() as tmpdir: - custom_path = Path(tmpdir) / "custom" / "path" - custom_path.mkdir(parents=True) - - result = index_get_project_root(str(custom_path)) - assert result == custom_path.resolve() - - def test_index_get_project_root_without_custom_path(self): - """Test artifact_index.get_project_root without custom path.""" - with tempfile.TemporaryDirectory() as tmpdir: - ring_dir = Path(tmpdir) / ".ring" - ring_dir.mkdir() - - original_cwd = os.getcwd() - try: - os.chdir(tmpdir) - result = index_get_project_root(None) - assert result == Path(tmpdir).resolve() - finally: - os.chdir(original_cwd) - - -# ============================================================================= -# Tests for get_handoff_id_from_file() -# ============================================================================= - -class TestGetHandoffIdFromFile: - """Tests for handoff ID generation from file path.""" - - def test_generates_consistent_id(self): - """Test ID generation is deterministic for same path.""" - with tempfile.TemporaryDirectory() as tmpdir: - file_path = Path(tmpdir) / "task-01.md" - file_path.touch() - - id1 = get_handoff_id_from_file(file_path) - id2 = get_handoff_id_from_file(file_path) - - assert id1 == id2 - - def test_different_paths_different_ids(self): - """Test different paths generate different IDs.""" - with tempfile.TemporaryDirectory() as tmpdir: - file1 = Path(tmpdir) / "task-01.md" - file2 = Path(tmpdir) / "task-02.md" - file1.touch() - file2.touch() - - id1 = get_handoff_id_from_file(file1) - id2 = get_handoff_id_from_file(file2) - - assert id1 != id2 - - def test_id_is_12_characters(self): - """Test ID is exactly 12 characters (truncated MD5).""" - with tempfile.TemporaryDirectory() as tmpdir: - file_path = Path(tmpdir) / "task-01.md" - file_path.touch() - - result = get_handoff_id_from_file(file_path) - - assert len(result) == 12 - - def test_id_is_hexadecimal(self): - """Test ID contains only hexadecimal characters.""" - with tempfile.TemporaryDirectory() as tmpdir: - file_path = Path(tmpdir) / "task-01.md" - file_path.touch() - - result = get_handoff_id_from_file(file_path) - - # All characters should be valid hex - assert all(c in "0123456789abcdef" for c in result) - - -# ============================================================================= -# Integration-like tests -# ============================================================================= - -class TestIntegration: - """Integration-like tests combining multiple functions.""" - - def test_full_handoff_workflow(self): - """Test complete handoff parsing workflow.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create proper directory structure - handoff_dir = Path(tmpdir) / "docs" / "handoffs" / "feature-auth" - handoff_dir.mkdir(parents=True) - handoff_file = handoff_dir / "task-03.md" - - content = """--- -status: SUCCESS -date: 2024-01-20 -session: feature-auth ---- - -# Task 03: Implement OAuth Flow - -## What Was Done - -Implemented OAuth 2.0 authentication flow with Google and GitHub providers. - -## What Worked - -- Google OAuth integration -- GitHub OAuth integration -- Token refresh mechanism - -## What Failed - -Minor issues with rate limiting, resolved. - -## Key Decisions - -- Used passport.js for OAuth abstraction -- Stored tokens in Redis for session management - -## Files Modified - -- `src/auth/oauth.ts` -- `src/auth/providers/google.ts` -- `src/auth/providers/github.ts` -- `src/middleware/auth.ts` -""" - handoff_file.write_text(content) - - # Parse the handoff - result = parse_handoff(handoff_file) - - # Verify all fields - assert result["session_name"] == "feature-auth" - assert result["task_number"] == 3 - assert result["outcome"] == "SUCCEEDED" - assert "OAuth 2.0" in result["task_summary"] - assert "Google OAuth" in result["what_worked"] - assert "rate limiting" in result["what_failed"] - assert "passport.js" in result["key_decisions"] - - # Verify files were extracted - import json - files = json.loads(result["files_modified"]) - assert "src/auth/oauth.ts" in files - assert len(files) >= 3 - - def test_handoff_with_minimal_content(self): - """Test handoff parsing with minimal content.""" - with tempfile.TemporaryDirectory() as tmpdir: - handoff_file = Path(tmpdir) / "task-01.md" - handoff_file.write_text("# Minimal handoff") - - result = parse_handoff(handoff_file) - - # Should not raise, should have defaults - assert result["outcome"] == "UNKNOWN" - assert result["task_summary"] == "" - assert result["id"] # Should still have an ID - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/default/lib/artifact-index/utils.py b/default/lib/artifact-index/utils.py deleted file mode 100644 index 859df37b..00000000 --- a/default/lib/artifact-index/utils.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Shared utilities for artifact-index modules.""" - -import argparse -from pathlib import Path -from typing import Optional - - -def get_project_root(custom_path: Optional[str] = None) -> Path: - """Get project root by finding .ring or .git directory. - - Args: - custom_path: Optional explicit path to use as root - - Returns: - Path to project root, or current directory if not found - """ - if custom_path: - return Path(custom_path).resolve() - current = Path.cwd() - for parent in [current] + list(current.parents): - if (parent / ".ring").exists() or (parent / ".git").exists(): - return parent - return current - - -def get_db_path(custom_path: Optional[str] = None, project_root: Optional[Path] = None) -> Path: - """Get database path, creating parent directory if needed. - - Args: - custom_path: Optional explicit database path - project_root: Optional project root (computed if not provided) - - Returns: - Path to SQLite database file - """ - if custom_path: - path = Path(custom_path) - else: - root = project_root or get_project_root() - path = root / ".ring" / "cache" / "artifact-index" / "context.db" - path.parent.mkdir(parents=True, exist_ok=True) - return path - - -def validate_limit(value: str) -> int: - """Validate limit argument is integer between 1 and 100. - - Args: - value: String value from argparse - - Returns: - Validated integer - - Raises: - argparse.ArgumentTypeError: If value invalid - """ - try: - ivalue = int(value) - except ValueError: - raise argparse.ArgumentTypeError(f"Invalid integer value: {value}") - if ivalue < 1 or ivalue > 100: - raise argparse.ArgumentTypeError(f"Limit must be between 1 and 100, got: {ivalue}") - return ivalue diff --git a/default/lib/compound_learnings/.gitkeep b/default/lib/compound_learnings/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/default/lib/compound_learnings/__init__.py b/default/lib/compound_learnings/__init__.py deleted file mode 100644 index a05b3da5..00000000 --- a/default/lib/compound_learnings/__init__.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Compound Learnings Library - Transform session learnings into permanent capabilities. - -This package provides: -- Learning file parsing (learning_parser) -- Pattern detection across sessions (pattern_detector) -- Rule/skill generation from patterns (rule_generator) -""" - -from .learning_parser import ( - parse_learning_file, - extract_patterns, - extract_what_worked, - extract_what_failed, - extract_key_decisions, - load_all_learnings, -) - -from .pattern_detector import ( - normalize_pattern, - find_similar_patterns, - aggregate_patterns, - detect_recurring_patterns, - categorize_pattern, - generate_pattern_summary, - similarity_score, - DEFAULT_SIMILARITY_THRESHOLD, -) - -from .rule_generator import ( - generate_rule_content, - generate_skill_content, - create_rule_file, - create_skill_file, - generate_proposal, - save_proposal, - load_pending_proposals, - approve_proposal, - reject_proposal, - slugify, - RuleGenerationError, -) - -__all__ = [ - # learning_parser - "parse_learning_file", - "extract_patterns", - "extract_what_worked", - "extract_what_failed", - "extract_key_decisions", - "load_all_learnings", - # pattern_detector - "normalize_pattern", - "find_similar_patterns", - "aggregate_patterns", - "detect_recurring_patterns", - "categorize_pattern", - "generate_pattern_summary", - "similarity_score", - "DEFAULT_SIMILARITY_THRESHOLD", - # rule_generator - "generate_rule_content", - "generate_skill_content", - "create_rule_file", - "create_skill_file", - "generate_proposal", - "save_proposal", - "load_pending_proposals", - "approve_proposal", - "reject_proposal", - "slugify", - "RuleGenerationError", -] diff --git a/default/lib/compound_learnings/learning_parser.py b/default/lib/compound_learnings/learning_parser.py deleted file mode 100644 index 215d2f73..00000000 --- a/default/lib/compound_learnings/learning_parser.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Parse learning markdown files into structured data. - -This module extracts structured information from session learning files -stored in .ring/cache/learnings/. -""" -import re -from pathlib import Path -from typing import Dict, List, Optional - - -def _extract_session_id(filename: str) -> str: - """Extract session ID from filename, expecting YYYY-MM-DD-.md format. - - Args: - filename: File stem without extension (e.g., "2025-12-27-abc123") - - Returns: - Session ID portion, or empty string if no valid session ID found - """ - parts = filename.split("-") - # Validate date prefix: must have at least 4 parts (YYYY-MM-DD-session) - if len(parts) >= 4: - year, month, day = parts[0], parts[1], parts[2] - # Verify first 3 parts look like a date - if len(year) == 4 and len(month) == 2 and len(day) == 2: - if year.isdigit() and month.isdigit() and day.isdigit(): - # Join remaining parts as session ID - return "-".join(parts[3:]) - # No hyphen or no valid date prefix - return filename as-is - if "-" not in filename: - return filename - # Has hyphens but not valid date format - return empty - return "" - - -def parse_learning_file(file_path: Path) -> Dict: - """Parse a learning markdown file into structured data. - - Args: - file_path: Path to the learning markdown file - - Returns: - Dictionary with session_id, date, what_worked, what_failed, - key_decisions, and patterns - """ - try: - content = file_path.read_text(encoding='utf-8') - except (UnicodeDecodeError, IOError, PermissionError): - return { - "session_id": "", - "date": "", - "file_path": str(file_path), - "what_worked": [], - "what_failed": [], - "key_decisions": [], - "patterns": [], - "error": "Failed to read file", - } - - # Extract session ID from filename: YYYY-MM-DD-.md - filename = file_path.stem # e.g., "2025-12-27-abc123" - session_id = _extract_session_id(filename) - - # Extract date from content - date_match = re.search(r'\*\*Date:\*\*\s*(.+)', content) - date = date_match.group(1).strip() if date_match else "" - - return { - "session_id": session_id, - "date": date, - "file_path": str(file_path), - "what_worked": extract_what_worked(content), - "what_failed": extract_what_failed(content), - "key_decisions": extract_key_decisions(content), - "patterns": extract_patterns(content), - } - - -def _extract_section_items(content: str, section_name: str) -> List[str]: - """Extract bullet items from a markdown section. - - Args: - content: Full markdown content - section_name: Name of section (e.g., "What Worked") - - Returns: - List of bullet point items (without the leading "- ") - """ - # Match section header and capture until next ## or end - pattern = rf'##\s*{re.escape(section_name)}\s*\n(.*?)(?=\n##|\Z)' - match = re.search(pattern, content, re.DOTALL | re.IGNORECASE) - - if not match: - return [] - - section_content = match.group(1) - - # Extract bullet points (lines starting with "- ") - items = [] - for line in section_content.split('\n'): - line = line.strip() - if line.startswith('- '): - items.append(line[2:].strip()) - - return items - - -def extract_what_worked(content: str) -> List[str]: - """Extract items from 'What Worked' section.""" - return _extract_section_items(content, "What Worked") - - -def extract_what_failed(content: str) -> List[str]: - """Extract items from 'What Failed' section.""" - return _extract_section_items(content, "What Failed") - - -def extract_key_decisions(content: str) -> List[str]: - """Extract items from 'Key Decisions' section.""" - return _extract_section_items(content, "Key Decisions") - - -def extract_patterns(content: str) -> List[str]: - """Extract items from 'Patterns' section.""" - return _extract_section_items(content, "Patterns") - - -def load_all_learnings(learnings_dir: Path) -> List[Dict]: - """Load all learning files from a directory. - - Args: - learnings_dir: Path to .ring/cache/learnings/ directory - - Returns: - List of parsed learning dictionaries, sorted by date (newest first) - """ - if not learnings_dir.exists(): - return [] - - learnings = [] - for file_path in learnings_dir.glob("*.md"): - try: - learning = parse_learning_file(file_path) - if not learning.get("error"): - learnings.append(learning) - except Exception as e: - # Log but continue processing other files - print(f"Warning: Failed to parse {file_path}: {e}") - - # Sort by date (newest first) - learnings.sort(key=lambda x: x.get("date", ""), reverse=True) - return learnings diff --git a/default/lib/compound_learnings/pattern_detector.py b/default/lib/compound_learnings/pattern_detector.py deleted file mode 100644 index 74480fa3..00000000 --- a/default/lib/compound_learnings/pattern_detector.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Detect recurring patterns across session learnings. - -This module analyzes learning files to find patterns that appear frequently -enough to warrant becoming permanent rules, skills, or hooks. -""" -import re -from typing import Dict, List, Set -from difflib import SequenceMatcher - -# Default similarity threshold for pattern matching -DEFAULT_SIMILARITY_THRESHOLD = 0.6 - - -def normalize_pattern(text: str) -> str: - """Normalize pattern text for comparison. - - Args: - text: Raw pattern text - - Returns: - Lowercase, whitespace-normalized text - """ - # Lowercase and collapse whitespace - return " ".join(text.lower().split()) - - -def similarity_score(text1: str, text2: str) -> float: - """Calculate similarity between two strings. - - Uses SequenceMatcher for fuzzy matching. - - Args: - text1: First string - text2: Second string - - Returns: - Similarity score between 0.0 and 1.0 - """ - return SequenceMatcher( - None, normalize_pattern(text1), normalize_pattern(text2) - ).ratio() - - -def find_similar_patterns( - pattern: str, patterns: List[str], threshold: float = DEFAULT_SIMILARITY_THRESHOLD -) -> List[str]: - """Find patterns similar to the given pattern. - - Args: - pattern: Pattern to match against - patterns: List of patterns to search - threshold: Minimum similarity score (0.0 to 1.0) - - Returns: - List of similar patterns - """ - similar = [] - normalized_pattern = normalize_pattern(pattern) - - for p in patterns: - if similarity_score(normalized_pattern, p) >= threshold: - similar.append(p) - - return similar - - -def aggregate_patterns( - patterns: List[Dict], threshold: float = DEFAULT_SIMILARITY_THRESHOLD -) -> List[Dict]: - """Aggregate similar patterns into groups. - - Args: - patterns: List of {"text": str, "session": str} dictionaries - threshold: Minimum similarity score for grouping (0.0 to 1.0) - - Returns: - List of pattern groups with canonical text and items - """ - if not patterns: - return [] - - groups: List[Dict] = [] - used: Set[int] = set() - - for i, p in enumerate(patterns): - if i in used: - continue - - # Start a new group with this pattern - group = { - "canonical": p["text"], - "items": [p], - "sessions": {p["session"]}, - } - used.add(i) - - # Find similar patterns - for j, other in enumerate(patterns): - if j in used: - continue - - if similarity_score(p["text"], other["text"]) >= threshold: - group["items"].append(other) - group["sessions"].add(other["session"]) - used.add(j) - - groups.append(group) - - # Sort by number of items (most common first) - groups.sort(key=lambda g: len(g["items"]), reverse=True) - return groups - - -def detect_recurring_patterns( - learnings: List[Dict], min_occurrences: int = 3 -) -> List[Dict]: - """Detect patterns that appear in multiple sessions. - - Args: - learnings: List of parsed learning dictionaries - min_occurrences: Minimum number of sessions for a pattern - - Returns: - List of recurring patterns with metadata - """ - # Collect all patterns with their sources - all_patterns: List[Dict] = [] - - for learning in learnings: - session_id = learning.get("session_id", "unknown") - - # Collect from explicit patterns section - for pattern in learning.get("patterns", []): - all_patterns.append({ - "text": pattern, - "session": session_id, - "source": "patterns" - }) - - # Collect from what_worked (success patterns) - for item in learning.get("what_worked", []): - all_patterns.append({ - "text": item, - "session": session_id, - "source": "what_worked" - }) - - # Collect from what_failed (inverted to anti-patterns) - for item in learning.get("what_failed", []): - all_patterns.append({ - "text": f"Avoid: {item}", - "session": session_id, - "source": "what_failed" - }) - - # Aggregate similar patterns - groups = aggregate_patterns(all_patterns) - - # Filter to those appearing in min_occurrences or more sessions - recurring = [] - for group in groups: - if len(group["sessions"]) >= min_occurrences: - recurring.append({ - "canonical": group["canonical"], - "occurrences": len(group["items"]), - "sessions": list(group["sessions"]), - "category": categorize_pattern(group["canonical"]), - "sources": [item["source"] for item in group["items"]], - }) - - return recurring - - -def categorize_pattern(pattern: str) -> str: - """Categorize a pattern as rule, skill, or hook candidate. - - Decision tree: - - Contains sequence/process keywords -> skill - - Contains event trigger keywords -> hook - - Otherwise -> rule (behavioral heuristic) - - Args: - pattern: Pattern text - - Returns: - Category: "rule", "skill", or "hook" - """ - pattern_lower = pattern.lower() - - # Skill indicators: sequences, processes, step-by-step - skill_keywords = [ - "step by step", - "process", - "workflow", - "procedure", - "first", - "then", - "finally", - "sequence", - "follow these", - ] - for keyword in skill_keywords: - if keyword in pattern_lower: - return "skill" - - # Hook indicators: event triggers, automatic actions - # Use regex word boundaries for precise matching - hook_patterns = [ - r"\bon session\b", - r"\bon commit\b", - r"\bon save\b", - r"\bautomatically\b", - r"\btrigger\b", - r"\bevent\b", - r"\bat the end\b", - r"\bat the start\b", - r"\bafter save\b", - r"\bafter commit\b", - r"\bbefore save\b", - r"\bbefore commit\b", - r"\bwhen session\b", - r"\bwhen file\b", - ] - for hook_pattern in hook_patterns: - if re.search(hook_pattern, pattern_lower): - return "hook" - - # Default: behavioral rule - return "rule" - - -def generate_pattern_summary(recurring_patterns: List[Dict]) -> str: - """Generate a human-readable summary of recurring patterns. - - Args: - recurring_patterns: Output from detect_recurring_patterns - - Returns: - Markdown-formatted summary - """ - if not recurring_patterns: - return "No recurring patterns found (patterns need 3+ occurrences)." - - lines = ["# Recurring Patterns Analysis", ""] - - # Group by category - by_category: Dict[str, List[Dict]] = {"rule": [], "skill": [], "hook": []} - for pattern in recurring_patterns: - category = pattern.get("category", "rule") - by_category[category].append(pattern) - - for category, patterns in by_category.items(): - if not patterns: - continue - - lines.append(f"## {category.title()} Candidates ({len(patterns)})") - lines.append("") - - for p in patterns: - canonical = p["canonical"] - truncated = canonical[:60] + "..." if len(canonical) > 60 else canonical - lines.append(f"### {truncated}") - lines.append(f"- **Occurrences:** {p['occurrences']} sessions") - sessions_str = ", ".join(p["sessions"][:5]) - if len(p["sessions"]) > 5: - sessions_str += "..." - lines.append(f"- **Sessions:** {sessions_str}") - lines.append(f"- **Sources:** {', '.join(set(p['sources']))}") - lines.append("") - - return "\n".join(lines) diff --git a/default/lib/compound_learnings/rule_generator.py b/default/lib/compound_learnings/rule_generator.py deleted file mode 100644 index 36973807..00000000 --- a/default/lib/compound_learnings/rule_generator.py +++ /dev/null @@ -1,386 +0,0 @@ -"""Generate rules and skills from detected patterns. - -This module creates the actual rule/skill files that will be added -to the Ring plugin based on user-approved patterns. -""" -import re -import json -from pathlib import Path -from typing import Dict, List, Optional -from datetime import datetime - - -def slugify(text: str, default: str = "unnamed-pattern") -> str: - """Convert text to a URL-friendly slug. - - Args: - text: Input text - default: Fallback value if text produces empty slug - - Returns: - Lowercase, hyphen-separated slug - """ - if not text or not text.strip(): - return default - # Lowercase and replace non-alphanumeric with hyphens - slug = re.sub(r'[^a-z0-9]+', '-', text.lower()) - # Remove leading/trailing hyphens - slug = slug.strip('-') - return slug if slug else default - - -def generate_rule_content(pattern: Dict) -> str: - """Generate markdown content for a rule. - - Args: - pattern: Pattern dictionary with canonical, occurrences, sessions - - Returns: - Markdown content for the rule file - """ - canonical = pattern["canonical"] - occurrences = pattern.get("occurrences", 0) - sessions = pattern.get("sessions", []) - sources = pattern.get("sources", []) - - # Generate DO/DON'T based on pattern text - if canonical.lower().startswith("avoid"): - dont_text = canonical.replace("Avoid: ", "").replace("avoid ", "") - do_text = f"Do the opposite of: {dont_text}" - else: - do_text = canonical - dont_text = f"Skip or ignore: {canonical}" - - content = f"""# {canonical} - -**Context:** This rule emerged from {occurrences} sessions where this pattern appeared. - -## Pattern - -{canonical} - -## DO - -- {do_text} - -## DON'T - -- {dont_text} - -## Source Sessions - -This rule was automatically generated from learnings in these sessions: - -""" - for session in sessions[:10]: # Show first 10 - content += f"- `{session}`\n" - - if len(sessions) > 10: - content += f"- ... and {len(sessions) - 10} more sessions\n" - - content += f""" -## Evidence - -- **Occurrences:** {occurrences} times across {len(sessions)} sessions -- **Sources:** {', '.join(set(sources))} -- **Generated:** {datetime.now().strftime('%Y-%m-%d')} -""" - - return content - - -def generate_skill_content(pattern: Dict) -> str: - """Generate markdown content for a skill. - - Args: - pattern: Pattern dictionary with canonical, occurrences, sessions - - Returns: - Markdown content for SKILL.md file - """ - canonical = pattern["canonical"] - occurrences = pattern.get("occurrences", 0) - sessions = pattern.get("sessions", []) - slug = slugify(canonical) - - # Create description for frontmatter - description = f"Use when {canonical.lower()} - emerged from {occurrences} sessions" - if len(description) > 200: - description = description[:197] + "..." - - content = f"""--- -name: {slug} -description: | - {description} ---- - -# {canonical} - -## Overview - -This skill emerged from patterns observed across {occurrences} sessions. - -{canonical} - -## When to Use - -- When you encounter situations similar to those in the source sessions -- When the pattern would apply to your current task - -## When NOT to Use - -- When the context is significantly different from the source sessions -- When project-specific conventions override this pattern - -## Process - -1. [Step 1 - TO BE FILLED: Review and customize this skill] -2. [Step 2 - TO BE FILLED: Add specific steps for your use case] -3. [Step 3 - TO BE FILLED: Include verification steps] - -## Source Sessions - -This skill was automatically generated from learnings in these sessions: - -""" - for session in sessions[:10]: - content += f"- `{session}`\n" - - content += f""" -## Notes - -- **Generated:** {datetime.now().strftime('%Y-%m-%d')} -- **IMPORTANT:** This skill needs human review and customization before use -- Edit the Process section to add specific, actionable steps -""" - - return content - - -class RuleGenerationError(Exception): - """Error raised when rule/skill generation fails.""" - pass - - -def create_rule_file(pattern: Dict, rules_dir: Path) -> Path: - """Create a rule file on disk. - - Args: - pattern: Pattern dictionary - rules_dir: Path to rules directory - - Returns: - Path to created rule file - - Raises: - RuleGenerationError: If file cannot be written - """ - slug = slugify(pattern["canonical"]) - file_path = rules_dir / f"{slug}.md" - - content = generate_rule_content(pattern) - - try: - file_path.write_text(content, encoding='utf-8') - except (IOError, PermissionError, OSError) as e: - raise RuleGenerationError(f"Failed to write rule file {file_path}: {e}") from e - - return file_path - - -def create_skill_file(pattern: Dict, skills_dir: Path) -> Path: - """Create a skill directory and SKILL.md file. - - Args: - pattern: Pattern dictionary - skills_dir: Path to skills directory - - Returns: - Path to created skill directory - - Raises: - RuleGenerationError: If directory/file cannot be created - """ - slug = slugify(pattern["canonical"]) - skill_dir = skills_dir / slug - - try: - skill_dir.mkdir(exist_ok=True) - skill_file = skill_dir / "SKILL.md" - content = generate_skill_content(pattern) - skill_file.write_text(content, encoding='utf-8') - except (IOError, PermissionError, OSError) as e: - raise RuleGenerationError(f"Failed to create skill {skill_dir}: {e}") from e - - return skill_dir - - -def generate_proposal(patterns: List[Dict]) -> Dict: - """Generate a proposal document for user review. - - Args: - patterns: List of recurring patterns from pattern_detector - - Returns: - Proposal dictionary with proposals list and metadata - """ - proposals = [] - - for i, pattern in enumerate(patterns): - category = pattern.get("category", "rule") - - proposal = { - "id": f"proposal-{i+1}", - "canonical": pattern["canonical"], - "category": category, - "occurrences": pattern.get("occurrences", 0), - "sessions": pattern.get("sessions", []), - "sources": pattern.get("sources", []), - "status": "pending", - "preview": ( - generate_rule_content(pattern) - if category == "rule" - else generate_skill_content(pattern) - ), - } - proposals.append(proposal) - - return { - "generated": datetime.now().isoformat(), - "total_proposals": len(proposals), - "proposals": proposals, - } - - -def save_proposal(proposal: Dict, proposals_dir: Path) -> Path: - """Save proposal to pending.json file. - - Args: - proposal: Proposal dictionary from generate_proposal - proposals_dir: Path to .ring/cache/proposals/ - - Returns: - Path to saved proposals file - """ - proposals_dir.mkdir(parents=True, exist_ok=True) - pending_file = proposals_dir / "pending.json" - - with open(pending_file, 'w', encoding='utf-8') as f: - json.dump(proposal, f, indent=2) - - return pending_file - - -def load_pending_proposals(proposals_dir: Path) -> Optional[Dict]: - """Load pending proposals from file. - - Args: - proposals_dir: Path to proposals directory - - Returns: - Proposal dictionary or None if not found/corrupted - """ - pending_file = proposals_dir / "pending.json" - - if not pending_file.exists(): - return None - - try: - with open(pending_file, encoding='utf-8') as f: - return json.load(f) - except (json.JSONDecodeError, IOError): - return None - - -def _update_proposal_status( - proposal_id: str, - proposals_dir: Path, - status: str, - extra_fields: Optional[Dict] = None -) -> Optional[Dict]: - """Common logic for updating proposal status. - - Args: - proposal_id: ID of proposal to update - proposals_dir: Path to proposals directory - status: New status ("approved" or "rejected") - extra_fields: Additional fields to add to proposal - - Returns: - Updated proposal dictionary, or None if not found - """ - pending_file = proposals_dir / "pending.json" - history_file = proposals_dir / "history.json" - - if not pending_file.exists(): - return None - - try: - with open(pending_file, encoding='utf-8') as f: - data = json.load(f) - except (json.JSONDecodeError, IOError): - return None - - # Find and update proposal - updated = None - for proposal in data.get("proposals", []): - if proposal["id"] == proposal_id: - proposal["status"] = status - proposal[f"{status}_at"] = datetime.now().isoformat() - if extra_fields: - proposal.update(extra_fields) - updated = proposal - break - - if updated: - # Save updated pending file - with open(pending_file, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2) - - # Append to history - history = [] - if history_file.exists(): - try: - with open(history_file, encoding='utf-8') as f: - loaded = json.load(f) - if isinstance(loaded, list): - history = loaded - except (json.JSONDecodeError, IOError): - pass # Start with empty history if corrupt - history.append(updated) - with open(history_file, 'w', encoding='utf-8') as f: - json.dump(history, f, indent=2) - - return updated - - -def approve_proposal(proposal_id: str, proposals_dir: Path) -> Optional[Dict]: - """Mark a proposal as approved. - - Args: - proposal_id: ID of proposal to approve - proposals_dir: Path to proposals directory - - Returns: - Approved proposal dictionary, or None if not found - """ - return _update_proposal_status(proposal_id, proposals_dir, "approved") - - -def reject_proposal( - proposal_id: str, proposals_dir: Path, reason: str = "" -) -> Optional[Dict]: - """Mark a proposal as rejected. - - Args: - proposal_id: ID of proposal to reject - proposals_dir: Path to proposals directory - reason: Optional rejection reason - - Returns: - Rejected proposal dictionary, or None if not found - """ - return _update_proposal_status( - proposal_id, proposals_dir, "rejected", - {"rejection_reason": reason} - ) diff --git a/default/lib/compound_learnings/test_learning_parser.py b/default/lib/compound_learnings/test_learning_parser.py deleted file mode 100644 index 08d27b97..00000000 --- a/default/lib/compound_learnings/test_learning_parser.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for learning_parser.py""" -import unittest -from pathlib import Path -import tempfile -import shutil - -# Will fail until learning_parser.py exists -from learning_parser import ( - parse_learning_file, - extract_patterns, - extract_what_worked, - extract_what_failed, - extract_key_decisions, -) - - -class TestLearningParser(unittest.TestCase): - def setUp(self): - self.test_dir = tempfile.mkdtemp() - self.sample_learning = """# Learnings from Session abc123 - -**Date:** 2025-12-27 10:00:00 - -## What Worked - -- Using TDD approach for the implementation -- Breaking down tasks into small commits -- Running tests after each change - -## What Failed - -- Trying to implement everything at once -- Skipping the planning phase - -## Key Decisions - -- Chose SQLite over PostgreSQL for simplicity -- Used Python instead of TypeScript for portability - -## Patterns - -- Always run tests before committing -- Use explicit file paths in plans -""" - self.learning_path = Path(self.test_dir) / "2025-12-27-abc123.md" - self.learning_path.write_text(self.sample_learning) - - def tearDown(self): - shutil.rmtree(self.test_dir, ignore_errors=True) - - def test_parse_learning_file(self): - """Test parsing a complete learning file""" - result = parse_learning_file(self.learning_path) - self.assertEqual(result["session_id"], "abc123") - self.assertEqual(result["date"], "2025-12-27 10:00:00") - self.assertIn("Using TDD approach", result["what_worked"][0]) - - def test_extract_what_worked(self): - """Test extracting What Worked section""" - items = extract_what_worked(self.sample_learning) - self.assertEqual(len(items), 3) - self.assertIn("TDD", items[0]) - - def test_extract_what_failed(self): - """Test extracting What Failed section""" - items = extract_what_failed(self.sample_learning) - self.assertEqual(len(items), 2) - self.assertIn("everything at once", items[0]) - - def test_extract_key_decisions(self): - """Test extracting Key Decisions section""" - items = extract_key_decisions(self.sample_learning) - self.assertEqual(len(items), 2) - self.assertIn("SQLite", items[0]) - - def test_extract_patterns(self): - """Test extracting Patterns section""" - items = extract_patterns(self.sample_learning) - self.assertEqual(len(items), 2) - self.assertIn("tests before committing", items[0]) - - def test_parse_missing_sections(self): - """Test parsing file with missing sections""" - sparse_content = """# Learnings from Session sparse - -**Date:** 2025-12-27 12:00:00 - -## What Worked - -- One thing worked -""" - sparse_path = Path(self.test_dir) / "2025-12-27-sparse.md" - sparse_path.write_text(sparse_content) - - result = parse_learning_file(sparse_path) - self.assertEqual(result["session_id"], "sparse") - self.assertEqual(len(result["what_worked"]), 1) - self.assertEqual(len(result["what_failed"]), 0) - self.assertEqual(len(result["patterns"]), 0) - - - def test_extract_session_id_with_date_prefix(self): - """Test session ID extraction from filename with date""" - from learning_parser import _extract_session_id - self.assertEqual(_extract_session_id("2025-12-27-abc123"), "abc123") - self.assertEqual(_extract_session_id("2025-12-27-multi-part-id"), "multi-part-id") - - def test_extract_session_id_date_only(self): - """Test session ID extraction from date-only filename""" - from learning_parser import _extract_session_id - # Date-only filenames should return empty string - self.assertEqual(_extract_session_id("2025-12-27"), "") - - def test_extract_session_id_no_hyphens(self): - """Test session ID extraction from filename without hyphens""" - from learning_parser import _extract_session_id - self.assertEqual(_extract_session_id("session"), "session") - - def test_load_all_learnings(self): - """Test loading all learning files from directory""" - # Create second learning file with earlier date - second_learning = Path(self.test_dir) / "2025-12-26-def456.md" - second_learning.write_text("""# Learnings from Session def456 - -**Date:** 2025-12-26 08:00:00 - -## What Worked - -- Something else worked -""") - from learning_parser import load_all_learnings - learnings = load_all_learnings(Path(self.test_dir)) - - self.assertEqual(len(learnings), 2) - # Should be sorted newest first (by date string) - self.assertEqual(learnings[0]["session_id"], "abc123") - - def test_load_all_learnings_empty_dir(self): - """Test loading from empty directory""" - empty_dir = Path(self.test_dir) / "empty" - empty_dir.mkdir() - - from learning_parser import load_all_learnings - learnings = load_all_learnings(empty_dir) - - self.assertEqual(learnings, []) - - def test_load_all_learnings_nonexistent_dir(self): - """Test loading from non-existent directory""" - from learning_parser import load_all_learnings - learnings = load_all_learnings(Path("/nonexistent/path")) - - self.assertEqual(learnings, []) - - -if __name__ == "__main__": - unittest.main() diff --git a/default/lib/compound_learnings/test_pattern_detector.py b/default/lib/compound_learnings/test_pattern_detector.py deleted file mode 100644 index d53ed330..00000000 --- a/default/lib/compound_learnings/test_pattern_detector.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Tests for pattern_detector.py""" -import unittest - -from pattern_detector import ( - normalize_pattern, - find_similar_patterns, - aggregate_patterns, - detect_recurring_patterns, - categorize_pattern, - similarity_score, - generate_pattern_summary, -) - - -class TestPatternDetector(unittest.TestCase): - def test_normalize_pattern(self): - """Test pattern normalization""" - # Should lowercase and remove extra whitespace - self.assertEqual( - normalize_pattern(" Use TDD Approach "), - "use tdd approach" - ) - - def test_find_similar_patterns(self): - """Test finding similar patterns using fuzzy matching""" - patterns = [ - "always run tests before committing", - "run tests before commit", - "test before committing changes", - "use explicit file paths", - ] - similar = find_similar_patterns("run tests before commit", patterns) - # Should find the similar patterns - self.assertGreaterEqual(len(similar), 2) - - def test_aggregate_patterns(self): - """Test aggregating similar patterns into groups""" - patterns = [ - {"text": "run tests before commit", "session": "abc"}, - {"text": "always run tests before committing", "session": "def"}, - {"text": "test before committing changes", "session": "ghi"}, - {"text": "use explicit file paths", "session": "jkl"}, - ] - groups = aggregate_patterns(patterns) - # Should group similar patterns together - self.assertGreaterEqual(len(groups), 1) - # At least one group should have 3 items - has_large_group = any(len(g["items"]) >= 3 for g in groups) - self.assertTrue(has_large_group) - - def test_detect_recurring_patterns(self): - """Test detecting patterns that appear 3+ times""" - learnings = [ - { - "session_id": "abc", - "patterns": ["run tests before commit", "use explicit paths"], - "what_worked": ["TDD approach"], - "what_failed": [], - }, - { - "session_id": "def", - "patterns": ["always run tests before committing"], - "what_worked": ["TDD workflow"], - "what_failed": [], - }, - { - "session_id": "ghi", - "patterns": ["test before committing changes"], - "what_worked": ["test-driven development"], - "what_failed": [], - }, - ] - recurring = detect_recurring_patterns(learnings, min_occurrences=3) - # Should detect "run tests before commit" pattern (appears in 3 sessions) - self.assertGreaterEqual(len(recurring), 1) - - def test_categorize_pattern_rule(self): - """Test categorizing patterns as rules (heuristics)""" - self.assertEqual( - categorize_pattern("always run tests before committing"), - "rule" - ) - self.assertEqual( - categorize_pattern("use explicit file paths"), - "rule" - ) - - def test_categorize_pattern_skill(self): - """Test categorizing patterns as skills (sequences/processes)""" - self.assertEqual( - categorize_pattern("step by step debugging process"), - "skill" - ) - self.assertEqual( - categorize_pattern("follow these steps to deploy"), - "skill" - ) - - def test_categorize_pattern_hook(self): - """Test categorizing patterns as hooks (event triggers)""" - self.assertEqual( - categorize_pattern("on session end, save the state"), - "hook" - ) - self.assertEqual( - categorize_pattern("automatically run lint after save"), - "hook" - ) - self.assertEqual( - categorize_pattern("trigger validation on commit"), - "hook" - ) - - def test_no_recurring_with_insufficient_data(self): - """Test that insufficient data returns no patterns""" - learnings = [ - {"session_id": "abc", "patterns": ["unique pattern"], "what_worked": [], "what_failed": []}, - {"session_id": "def", "patterns": ["another unique"], "what_worked": [], "what_failed": []}, - ] - recurring = detect_recurring_patterns(learnings, min_occurrences=3) - self.assertEqual(len(recurring), 0) - - def test_similarity_score_identical(self): - """Test similarity score for identical strings""" - self.assertEqual(similarity_score("test", "test"), 1.0) - - def test_similarity_score_similar(self): - """Test similarity score for similar strings""" - score = similarity_score("run tests before commit", "run tests before committing") - self.assertGreater(score, 0.8) - - def test_similarity_score_different(self): - """Test similarity score for different strings""" - score = similarity_score("completely different", "no match at all") - self.assertLess(score, 0.5) - - def test_generate_pattern_summary_empty(self): - """Test summary generation with no patterns""" - summary = generate_pattern_summary([]) - self.assertIn("No recurring patterns", summary) - - def test_generate_pattern_summary_with_patterns(self): - """Test summary generation with patterns""" - patterns = [ - { - "canonical": "Test pattern for rules", - "occurrences": 3, - "sessions": ["a", "b", "c"], - "category": "rule", - "sources": ["patterns"] - } - ] - summary = generate_pattern_summary(patterns) - self.assertIn("# Recurring Patterns Analysis", summary) - self.assertIn("Rule Candidates", summary) - self.assertIn("Test pattern", summary) - - -if __name__ == "__main__": - unittest.main() diff --git a/default/lib/compound_learnings/test_rule_generator.py b/default/lib/compound_learnings/test_rule_generator.py deleted file mode 100644 index a7e4e407..00000000 --- a/default/lib/compound_learnings/test_rule_generator.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Tests for rule_generator.py""" -import unittest -from pathlib import Path -import tempfile -import shutil - -from rule_generator import ( - generate_rule_content, - generate_skill_content, - create_rule_file, - create_skill_file, - generate_proposal, - slugify, - save_proposal, - load_pending_proposals, - approve_proposal, - reject_proposal, -) - - -class TestRuleGenerator(unittest.TestCase): - def setUp(self): - self.test_dir = Path(tempfile.mkdtemp()) - self.rules_dir = self.test_dir / "rules" - self.skills_dir = self.test_dir / "skills" - self.rules_dir.mkdir() - self.skills_dir.mkdir() - - def tearDown(self): - shutil.rmtree(self.test_dir, ignore_errors=True) - - def test_slugify(self): - """Test converting text to slug""" - self.assertEqual(slugify("Always Run Tests"), "always-run-tests") - self.assertEqual(slugify("Use TDD!!!"), "use-tdd") - self.assertEqual(slugify("Step-by-step Process"), "step-by-step-process") - - def test_generate_rule_content(self): - """Test generating rule markdown content""" - pattern = { - "canonical": "Always run tests before committing", - "occurrences": 5, - "sessions": ["abc", "def", "ghi", "jkl", "mno"], - "sources": ["patterns", "what_worked"], - } - content = generate_rule_content(pattern) - self.assertIn("# Always run tests before committing", content) - self.assertIn("5 sessions", content) - self.assertIn("## Pattern", content) - self.assertIn("## DO", content) - self.assertIn("## DON'T", content) - - def test_generate_skill_content(self): - """Test generating skill markdown content""" - pattern = { - "canonical": "Step by step debugging process", - "occurrences": 4, - "sessions": ["abc", "def", "ghi", "jkl"], - "sources": ["patterns"], - } - content = generate_skill_content(pattern) - self.assertIn("---", content) # YAML frontmatter - self.assertIn("name:", content) - self.assertIn("description:", content) - self.assertIn("## Overview", content) - self.assertIn("## When to Use", content) - - def test_create_rule_file(self): - """Test creating a rule file on disk""" - pattern = { - "canonical": "Use explicit file paths", - "occurrences": 3, - "sessions": ["abc", "def", "ghi"], - "sources": ["patterns"], - } - file_path = create_rule_file(pattern, self.rules_dir) - self.assertTrue(file_path.exists()) - self.assertIn("use-explicit-file-paths", file_path.name) - content = file_path.read_text() - self.assertIn("Use explicit file paths", content) - - def test_create_skill_file(self): - """Test creating a skill directory and SKILL.md""" - pattern = { - "canonical": "Systematic debugging workflow", - "occurrences": 4, - "sessions": ["abc", "def", "ghi", "jkl"], - "sources": ["patterns"], - } - skill_dir = create_skill_file(pattern, self.skills_dir) - self.assertTrue(skill_dir.exists()) - skill_file = skill_dir / "SKILL.md" - self.assertTrue(skill_file.exists()) - - def test_generate_proposal(self): - """Test generating a proposal document""" - patterns = [ - { - "canonical": "Always run tests", - "occurrences": 3, - "sessions": ["abc", "def", "ghi"], - "category": "rule", - "sources": ["patterns"], - }, - { - "canonical": "Step by step debug", - "occurrences": 4, - "sessions": ["abc", "def", "ghi", "jkl"], - "category": "skill", - "sources": ["what_worked"], - }, - ] - proposal = generate_proposal(patterns) - self.assertIn("proposals", proposal) - self.assertEqual(len(proposal["proposals"]), 2) - self.assertEqual(proposal["proposals"][0]["category"], "rule") - - def test_generate_rule_for_avoid_pattern(self): - """Test generating rule for anti-patterns (Avoid: ...)""" - pattern = { - "canonical": "Avoid: implementing everything at once", - "occurrences": 3, - "sessions": ["abc", "def", "ghi"], - "sources": ["what_failed"], - } - content = generate_rule_content(pattern) - self.assertIn("## DON'T", content) - # Should have inverted DO/DON'T for avoid patterns - self.assertIn("implementing everything at once", content) - - def test_slugify_empty_input(self): - """Test slugify with empty or special-only input""" - self.assertEqual(slugify(""), "unnamed-pattern") - self.assertEqual(slugify("!@#$%"), "unnamed-pattern") - self.assertEqual(slugify(" "), "unnamed-pattern") - - def test_save_and_load_proposal(self): - """Test saving and loading proposal""" - proposals_dir = self.test_dir / "proposals" - - proposal = { - "generated": "2025-12-27T10:00:00", - "total_proposals": 1, - "proposals": [{"id": "proposal-1", "canonical": "Test", "status": "pending"}] - } - path = save_proposal(proposal, proposals_dir) - - self.assertTrue(path.exists()) - loaded = load_pending_proposals(proposals_dir) - self.assertIsNotNone(loaded) - self.assertEqual(loaded["total_proposals"], 1) - - def test_load_pending_proposals_nonexistent(self): - """Test loading from nonexistent directory""" - proposals_dir = self.test_dir / "nonexistent" - loaded = load_pending_proposals(proposals_dir) - self.assertIsNone(loaded) - - def test_approve_proposal(self): - """Test approving a proposal""" - proposals_dir = self.test_dir / "proposals" - - proposal = { - "generated": "2025-12-27T10:00:00", - "total_proposals": 1, - "proposals": [{"id": "proposal-1", "canonical": "Test", "status": "pending"}] - } - save_proposal(proposal, proposals_dir) - - result = approve_proposal("proposal-1", proposals_dir) - - self.assertIsNotNone(result) - self.assertEqual(result["status"], "approved") - self.assertIn("approved_at", result) - - def test_reject_proposal(self): - """Test rejecting a proposal with reason""" - proposals_dir = self.test_dir / "proposals" - - proposal = { - "generated": "2025-12-27T10:00:00", - "total_proposals": 1, - "proposals": [{"id": "proposal-1", "canonical": "Test", "status": "pending"}] - } - save_proposal(proposal, proposals_dir) - - result = reject_proposal("proposal-1", proposals_dir, "Not useful") - - self.assertIsNotNone(result) - self.assertEqual(result["status"], "rejected") - self.assertEqual(result["rejection_reason"], "Not useful") - - def test_approve_nonexistent_proposal(self): - """Test approving a proposal that doesn't exist""" - proposals_dir = self.test_dir / "proposals" - - proposal = { - "generated": "2025-12-27T10:00:00", - "total_proposals": 1, - "proposals": [{"id": "proposal-1", "canonical": "Test", "status": "pending"}] - } - save_proposal(proposal, proposals_dir) - - result = approve_proposal("proposal-999", proposals_dir) - self.assertIsNone(result) - - -if __name__ == "__main__": - unittest.main() diff --git a/default/lib/outcome-inference/outcome_inference.py b/default/lib/outcome-inference/outcome_inference.py deleted file mode 100755 index fa252f86..00000000 --- a/default/lib/outcome-inference/outcome_inference.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -"""Outcome inference module for automatic handoff outcome detection. - -This module analyzes session state to infer task outcomes: -- SUCCEEDED: All todos complete, no errors -- PARTIAL_PLUS: Most todos complete, minor issues -- PARTIAL_MINUS: Some progress, significant blockers -- FAILED: No meaningful progress or abandoned - -Usage: - from outcome_inference import infer_outcome - outcome = infer_outcome(project_root) -""" - -import json -import os -import re -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional, Tuple - - -# Outcome definitions -OUTCOME_SUCCEEDED = "SUCCEEDED" -OUTCOME_PARTIAL_PLUS = "PARTIAL_PLUS" -OUTCOME_PARTIAL_MINUS = "PARTIAL_MINUS" -OUTCOME_FAILED = "FAILED" -OUTCOME_UNKNOWN = "UNKNOWN" - - -def get_project_root() -> Path: - """Get the project root directory.""" - if os.environ.get("CLAUDE_PROJECT_DIR"): - return Path(os.environ["CLAUDE_PROJECT_DIR"]) - return Path.cwd() - - -def sanitize_session_id(session_id: str) -> str: - """Sanitize session ID for safe use in filenames.""" - sanitized = re.sub(r'[^a-zA-Z0-9_-]', '', session_id) - if not sanitized: - return "unknown" - return sanitized[:64] - - -def analyze_todos(todos: List[Dict]) -> Tuple[int, int, int, int]: - """Analyze todo list and return counts. - - Returns: - Tuple of (total, completed, in_progress, pending) - """ - if not todos: - return (0, 0, 0, 0) - - total = len(todos) - completed = sum(1 for t in todos if t.get("status") == "completed") - in_progress = sum(1 for t in todos if t.get("status") == "in_progress") - pending = sum(1 for t in todos if t.get("status") == "pending") - - return (total, completed, in_progress, pending) - - -def infer_outcome_from_todos( - total: int, - completed: int, - in_progress: int, - pending: int -) -> Tuple[str, str]: - """Infer outcome from todo counts. - - Returns: - Tuple of (outcome, reason) - """ - if total == 0: - return (OUTCOME_FAILED, "No todos tracked - unable to measure completion") - - completion_ratio = completed / total - - if completed == total: - return (OUTCOME_SUCCEEDED, f"All {total} tasks completed") - - if completion_ratio >= 0.8: - remaining = total - completed - return (OUTCOME_PARTIAL_PLUS, f"{completed}/{total} tasks done, {remaining} minor items remain") - - if completion_ratio >= 0.5: - return (OUTCOME_PARTIAL_MINUS, f"{completed}/{total} tasks done, significant work remains") - - # <50% completion is FAILED - insufficient progress - # Note: in_progress tasks without completion don't count toward progress - return (OUTCOME_FAILED, f"Insufficient progress: only {completed}/{total} tasks completed ({int(completion_ratio * 100)}%)") - - -def infer_outcome( - todos: Optional[List[Dict]] = None, -) -> Dict: - """Infer session outcome from available state. - - Args: - todos: Optional pre-parsed todos list - - Returns: - Dict with 'outcome', 'reason', and 'confidence' keys - """ - result = { - "outcome": OUTCOME_UNKNOWN, - "reason": "Unable to determine outcome", - "confidence": "low", - "sources": [], - } - - if todos is not None: - total, completed, in_progress, pending = analyze_todos(todos) - # Always infer outcome from todos - even if empty (total=0 -> FAILED) - result["outcome"], result["reason"] = infer_outcome_from_todos( - total, completed, in_progress, pending - ) - result["sources"].append("todos") - # Confidence based on completion clarity - if total == 0: - # Empty todos = high confidence it's a failure - result["confidence"] = "high" - elif completed == total or completed == 0: - result["confidence"] = "high" - elif completed / total >= 0.8 or completed / total <= 0.2: - result["confidence"] = "medium" - else: - result["confidence"] = "low" - - return result - - -def main(): - """CLI entry point for testing.""" - import sys - - # Read optional todos from stdin - todos = None - if not sys.stdin.isatty(): - try: - input_data = json.loads(sys.stdin.read()) - todos = input_data.get("todos") - except json.JSONDecodeError: - pass - - result = infer_outcome(todos=todos) - print(json.dumps(result, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/default/lib/shell/context-check.sh b/default/lib/shell/context-check.sh deleted file mode 100755 index 26586764..00000000 --- a/default/lib/shell/context-check.sh +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# STATEFUL Context Check Utilities -# ============================================================================= -# PURPOSE: State management for context tracking with file persistence -# -# USE THIS WHEN: -# - You need to track turn counts across hook invocations -# - You need to increment/reset context state -# - You're in a hook that modifies session state -# -# DO NOT USE FOR: -# - Simple percentage calculations (use shared/lib/context-check.sh) -# - Stateless warning tier lookups -# -# KEY FUNCTIONS: -# - increment_turn_count() - Atomically increment with flock -# - reset_turn_count() - Reset to 0 with flock -# - update_context_usage() - Persist usage state to file -# - estimate_context_usage() - Calculate usage from turn count -# - get_context_warning() - Get warning message if threshold crossed -# -# DEPENDENCIES: -# - hook-utils.sh (for get_ring_state_dir) -# - flock command (for file locking) -# -# STATE FILES: -# - .ring/state/current-session.json - Turn count and timestamps -# - .ring/state/context-usage-*.json - Per-session usage estimates -# ============================================================================= -# shellcheck disable=SC2034 # Unused variables OK for exported config - -set -euo pipefail - -# Determine script location and source dependencies -CONTEXT_CHECK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Source hook-utils if not already sourced -if ! declare -f get_ring_state_dir &>/dev/null; then - # shellcheck source=hook-utils.sh - source "${CONTEXT_CHECK_DIR}/hook-utils.sh" -fi - -# Approximate context window size (tokens) -# Claude's actual limit is ~200K, we use conservative estimate -readonly CONTEXT_WINDOW_TOKENS=200000 - -# Warning thresholds -readonly THRESHOLD_INFO=50 -readonly THRESHOLD_WARNING=70 -readonly THRESHOLD_CRITICAL=85 - -# Estimate context usage percentage -# Returns: integer 0-100 representing estimated usage percentage -estimate_context_usage() { - local state_dir - state_dir=$(get_ring_state_dir) - local usage_file="${state_dir}/context-usage.json" - - # Check if context-usage.json exists and has recent data - if [[ -f "$usage_file" ]]; then - local estimated - estimated=$(get_json_field "$(cat "$usage_file")" "estimated_percentage" 2>/dev/null || echo "") - if [[ -n "$estimated" ]] && [[ "$estimated" =~ ^[0-9]+$ ]]; then - printf '%s' "$estimated" - return 0 - fi - fi - - # Fallback: estimate from conversation turn count in state file - local session_file="${state_dir}/current-session.json" - if [[ -f "$session_file" ]]; then - local turns - turns=$(get_json_field "$(cat "$session_file")" "turn_count" 2>/dev/null || echo "0") - - # Rough estimate: ~2000 tokens per turn average - # 200K context / 2000 tokens = 100 turns for full context - # So: percentage = turns (since 100 turns = 100%) - if [[ "$turns" =~ ^[0-9]+$ ]]; then - local percentage=$turns # 1 turn = 1%, 100 turns = 100% - if [[ $percentage -gt 100 ]]; then - percentage=100 - fi - printf '%s' "$percentage" - return 0 - fi - fi - - # No data available, return 0 (unknown) - printf '0' -} - -# Update context usage estimate -# Args: $1 - estimated percentage (0-100) -update_context_usage() { - local percentage="${1:-0}" - - # Validate numeric input (security: prevent injection) - if ! [[ "$percentage" =~ ^[0-9]+$ ]]; then - percentage=0 - fi - - local state_dir - state_dir=$(get_ring_state_dir) - local usage_file="${state_dir}/context-usage.json" - local temp_file="${usage_file}.tmp.$$" - - # Get current timestamp - local timestamp - timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - # Atomic write: write to temp file then rename - cat > "$temp_file" </dev/null || echo "0") - if ! [[ "$turn_count" =~ ^[0-9]+$ ]]; then - turn_count=0 - fi - fi - - turn_count=$((turn_count + 1)) - - local timestamp - timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - # Atomic write: write to temp file then rename - cat > "$temp_file" <"$lock_file" -} - -# Reset turn count (call on session start, with atomic write and file locking) -# Uses same locking pattern as increment_turn_count to prevent races -reset_turn_count() { - local state_dir - state_dir=$(get_ring_state_dir) - local session_file="${state_dir}/current-session.json" - local lock_file="${session_file}.lock" - - # Use flock for atomic write (same pattern as increment_turn_count) - ( - # Acquire exclusive lock with timeout to prevent deadlock - flock -w 5 -x 200 || { - echo "Warning: Could not acquire lock for reset_turn_count" >&2 - return 1 - } - - local temp_file="${session_file}.tmp.$$" - local timestamp - timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - # Atomic write: write to temp file then rename - cat > "$temp_file" <"$lock_file" -} - -# Get context warning message for a given percentage -# Args: $1 - percentage (0-100) -# Returns: warning message or empty string -get_context_warning() { - local percentage="${1:-0}" - - if [[ $percentage -ge $THRESHOLD_CRITICAL ]]; then - printf 'CRITICAL: Context at %d%%. MUST run /clear with ledger save NOW or risk losing work.' "$percentage" - elif [[ $percentage -ge $THRESHOLD_WARNING ]]; then - printf 'WARNING: Context at %d%%. Recommend running /clear with ledger save soon.' "$percentage" - elif [[ $percentage -ge $THRESHOLD_INFO ]]; then - printf 'INFO: Context at %d%%. Consider summarizing or preparing ledger.' "$percentage" - else - # No warning needed - printf '' - fi -} - -# Get warning severity level -# Args: $1 - percentage (0-100) -# Returns: "critical", "warning", "info", or "ok" -get_warning_level() { - local percentage="${1:-0}" - - if [[ $percentage -ge $THRESHOLD_CRITICAL ]]; then - printf 'critical' - elif [[ $percentage -ge $THRESHOLD_WARNING ]]; then - printf 'warning' - elif [[ $percentage -ge $THRESHOLD_INFO ]]; then - printf 'info' - else - printf 'ok' - fi -} - -# Export functions for subshells -export -f estimate_context_usage 2>/dev/null || true -export -f update_context_usage 2>/dev/null || true -export -f increment_turn_count 2>/dev/null || true -export -f reset_turn_count 2>/dev/null || true -export -f get_context_warning 2>/dev/null || true -export -f get_warning_level 2>/dev/null || true diff --git a/default/lib/shell/hook-utils.sh b/default/lib/shell/hook-utils.sh index e5f47e39..fe84418e 100755 --- a/default/lib/shell/hook-utils.sh +++ b/default/lib/shell/hook-utils.sh @@ -13,7 +13,6 @@ # - output_hook_context: Output hook response with additionalContext # - get_json_field: Extract field from JSON using jq or grep fallback # - get_project_root: Get project root directory -# - get_ring_state_dir: Get .ring/state directory path set -euo pipefail @@ -101,54 +100,6 @@ get_project_root() { printf '%s' "$project_dir" } -# Get .ring state directory path (creates if needed with secure permissions) -# Returns: path to .ring/state directory -get_ring_state_dir() { - local project_root - project_root=$(get_project_root) - local state_dir="${project_root}/.ring/state" - - # Create if it doesn't exist (owner-only permissions for security) - if [[ ! -d "$state_dir" ]]; then - mkdir -p "$state_dir" - chmod 700 "$state_dir" - fi - - printf '%s' "$state_dir" -} - -# Get .ring cache directory path (creates if needed with secure permissions) -# Returns: path to .ring/cache directory -get_ring_cache_dir() { - local project_root - project_root=$(get_project_root) - local cache_dir="${project_root}/.ring/cache" - - # Create if it doesn't exist (owner-only permissions for security) - if [[ ! -d "$cache_dir" ]]; then - mkdir -p "$cache_dir" - chmod 700 "$cache_dir" - fi - - printf '%s' "$cache_dir" -} - -# Get .ring ledgers directory path (creates if needed with secure permissions) -# Returns: path to .ring/ledgers directory -get_ring_ledgers_dir() { - local project_root - project_root=$(get_project_root) - local ledgers_dir="${project_root}/.ring/ledgers" - - # Create if it doesn't exist (owner-only permissions for security) - if [[ ! -d "$ledgers_dir" ]]; then - mkdir -p "$ledgers_dir" - chmod 700 "$ledgers_dir" - fi - - printf '%s' "$ledgers_dir" -} - # Output a basic hook result # Args: $1 - result ("continue" or "block"), $2 - message (optional) output_hook_result() { @@ -214,9 +165,6 @@ EOF export -f read_hook_stdin 2>/dev/null || true export -f get_json_field 2>/dev/null || true export -f get_project_root 2>/dev/null || true -export -f get_ring_state_dir 2>/dev/null || true -export -f get_ring_cache_dir 2>/dev/null || true -export -f get_ring_ledgers_dir 2>/dev/null || true export -f output_hook_result 2>/dev/null || true export -f output_hook_context 2>/dev/null || true export -f output_hook_empty 2>/dev/null || true diff --git a/default/lib/shell/tests/test_shell_utils.sh b/default/lib/shell/tests/test_shell_utils.sh index ab3439ed..d3672688 100755 --- a/default/lib/shell/tests/test_shell_utils.sh +++ b/default/lib/shell/tests/test_shell_utils.sh @@ -8,7 +8,6 @@ # Tests: # - json-escape.sh: json_escape(), json_string() # - hook-utils.sh: get_json_field(), output_hook_result(), etc. -# - context-check.sh: estimate_context_usage(), increment_turn_count(), etc. set -euo pipefail @@ -133,38 +132,6 @@ assert_exit_code() { fi } -assert_file_exists() { - local filepath="$1" - local test_name="$2" - TESTS_RUN=$((TESTS_RUN + 1)) - if [[ -f "$filepath" ]]; then - echo -e "${GREEN}✓${NC} $test_name" - TESTS_PASSED=$((TESTS_PASSED + 1)) - return 0 - else - echo -e "${RED}✗${NC} $test_name" - echo " File does not exist: $filepath" - TESTS_FAILED=$((TESTS_FAILED + 1)) - return 1 - fi -} - -assert_dir_exists() { - local dirpath="$1" - local test_name="$2" - TESTS_RUN=$((TESTS_RUN + 1)) - if [[ -d "$dirpath" ]]; then - echo -e "${GREEN}✓${NC} $test_name" - TESTS_PASSED=$((TESTS_PASSED + 1)) - return 0 - else - echo -e "${RED}✗${NC} $test_name" - echo " Directory does not exist: $dirpath" - TESTS_FAILED=$((TESTS_FAILED + 1)) - return 1 - fi -} - # ============================================================================= # Source the utilities (after helpers are defined) # ============================================================================= @@ -172,7 +139,6 @@ assert_dir_exists() { # Source in order of dependencies source "${SCRIPT_DIR}/../json-escape.sh" source "${SCRIPT_DIR}/../hook-utils.sh" -source "${SCRIPT_DIR}/../context-check.sh" # ============================================================================= # json_escape() Tests @@ -383,34 +349,6 @@ test_get_project_root_fallback() { assert_equals "$pwd_result" "$result" "get_project_root: falls back to pwd when no env var" } -# ============================================================================= -# get_ring_state_dir() Tests -# ============================================================================= - -test_get_ring_state_dir_creates() { - echo -e "\n${YELLOW}=== get_ring_state_dir() Tests ===${NC}" - setup_test_env - - local result - result=$(get_ring_state_dir) - assert_equals "${TEST_TMP_DIR}/.ring/state" "$result" "get_ring_state_dir: returns correct path" - assert_dir_exists "$result" "get_ring_state_dir: creates directory" - - teardown_test_env -} - -test_get_ring_state_dir_permissions() { - setup_test_env - - local result - result=$(get_ring_state_dir) - local perms - perms=$(stat -f "%Lp" "$result" 2>/dev/null || stat -c "%a" "$result" 2>/dev/null) - assert_equals "700" "$perms" "get_ring_state_dir: has secure permissions (700)" - - teardown_test_env -} - # ============================================================================= # output_hook_result() Tests # ============================================================================= @@ -467,300 +405,6 @@ test_output_hook_empty_with_event() { assert_contains '"hookEventName": "PromptSubmit"' "$result" "output_hook_empty: includes event name" } -# ============================================================================= -# estimate_context_usage() Tests -# ============================================================================= - -test_estimate_context_usage_no_data() { - echo -e "\n${YELLOW}=== estimate_context_usage() Tests ===${NC}" - setup_test_env - - local result - result=$(estimate_context_usage) - assert_equals "0" "$result" "estimate_context_usage: returns 0 when no data" - - teardown_test_env -} - -test_estimate_context_usage_from_turns() { - setup_test_env - - # Create session file with turn count - local state_dir - state_dir=$(get_ring_state_dir) - cat > "${state_dir}/current-session.json" < "${state_dir}/context-usage.json" < "${state_dir}/current-session.json" < "${state_dir}/current-session.json" </dev/null - increment_turn_count >/dev/null - increment_turn_count >/dev/null - - # Now reset - reset_turn_count - - local state_dir - state_dir=$(get_ring_state_dir) - local count - count=$(get_json_field "$(cat "${state_dir}/current-session.json")" "turn_count") - assert_equals "0" "$count" "reset_turn_count: resets to 0" - - teardown_test_env -} - -test_reset_turn_count_creates_file() { - setup_test_env - - local state_dir - state_dir=$(get_ring_state_dir) - local session_file="${state_dir}/current-session.json" - - # Ensure file doesn't exist - rm -f "$session_file" - - # Reset should create it - reset_turn_count - - assert_file_exists "$session_file" "reset_turn_count: creates file if missing" - - teardown_test_env -} - -test_reset_turn_count_includes_timestamps() { - setup_test_env - - reset_turn_count - - local state_dir - state_dir=$(get_ring_state_dir) - local content - content=$(cat "${state_dir}/current-session.json") - - assert_contains "started_at" "$content" "reset_turn_count: includes started_at" - assert_contains "updated_at" "$content" "reset_turn_count: includes updated_at" - - teardown_test_env -} - -# ============================================================================= -# update_context_usage() Tests -# ============================================================================= - -test_update_context_usage() { - echo -e "\n${YELLOW}=== update_context_usage() Tests ===${NC}" - setup_test_env - - update_context_usage 75 - - local state_dir - state_dir=$(get_ring_state_dir) - local content - content=$(cat "${state_dir}/context-usage.json") - - assert_contains '"estimated_percentage": 75' "$content" "update_context_usage: stores percentage" - assert_contains "threshold_info" "$content" "update_context_usage: includes thresholds" - - teardown_test_env -} - -test_update_context_usage_invalid_input() { - setup_test_env - - # Non-numeric input should be treated as 0 - update_context_usage "invalid" - - local state_dir - state_dir=$(get_ring_state_dir) - local content - content=$(cat "${state_dir}/context-usage.json") - - assert_contains '"estimated_percentage": 0' "$content" "update_context_usage: invalid input → 0" - - teardown_test_env -} - -# ============================================================================= -# get_context_warning() Tests -# ============================================================================= - -test_get_context_warning_ok() { - echo -e "\n${YELLOW}=== get_context_warning() Tests ===${NC}" - - local result - result=$(get_context_warning 30) - assert_empty "$result" "get_context_warning: 30% → no warning" -} - -test_get_context_warning_info() { - local result - result=$(get_context_warning 50) - assert_contains "INFO" "$result" "get_context_warning: 50% → INFO" - assert_contains "50%" "$result" "get_context_warning: includes percentage" -} - -test_get_context_warning_warning() { - local result - result=$(get_context_warning 70) - assert_contains "WARNING" "$result" "get_context_warning: 70% → WARNING" -} - -test_get_context_warning_critical() { - local result - result=$(get_context_warning 85) - assert_contains "CRITICAL" "$result" "get_context_warning: 85% → CRITICAL" - assert_contains "/clear" "$result" "get_context_warning: critical includes /clear suggestion" -} - -# ============================================================================= -# get_warning_level() Tests -# ============================================================================= - -test_get_warning_level_ok() { - echo -e "\n${YELLOW}=== get_warning_level() Tests ===${NC}" - - local result - result=$(get_warning_level 30) - assert_equals "ok" "$result" "get_warning_level: 30% → ok" -} - -test_get_warning_level_info() { - local result - result=$(get_warning_level 55) - assert_equals "info" "$result" "get_warning_level: 55% → info" -} - -test_get_warning_level_warning() { - local result - result=$(get_warning_level 75) - assert_equals "warning" "$result" "get_warning_level: 75% → warning" -} - -test_get_warning_level_critical() { - local result - result=$(get_warning_level 90) - assert_equals "critical" "$result" "get_warning_level: 90% → critical" -} - # ============================================================================= # Run All Tests # ============================================================================= @@ -802,10 +446,6 @@ main() { test_get_project_root test_get_project_root_fallback - # get_ring_state_dir tests - test_get_ring_state_dir_creates - test_get_ring_state_dir_permissions - # output_hook_result tests test_output_hook_result_continue test_output_hook_result_block_with_message @@ -818,38 +458,6 @@ main() { test_output_hook_empty test_output_hook_empty_with_event - # estimate_context_usage tests - test_estimate_context_usage_no_data - test_estimate_context_usage_from_turns - test_estimate_context_usage_from_context_file - test_estimate_context_usage_capped_at_100 - - # increment_turn_count tests - test_increment_turn_count_from_zero - test_increment_turn_count_existing - test_increment_turn_count_concurrent - - # reset_turn_count tests - test_reset_turn_count - test_reset_turn_count_creates_file - test_reset_turn_count_includes_timestamps - - # update_context_usage tests - test_update_context_usage - test_update_context_usage_invalid_input - - # get_context_warning tests - test_get_context_warning_ok - test_get_context_warning_info - test_get_context_warning_warning - test_get_context_warning_critical - - # get_warning_level tests - test_get_warning_level_ok - test_get_warning_level_info - test_get_warning_level_warning - test_get_warning_level_critical - # Summary echo "" echo -e "${YELLOW}========================================${NC}" diff --git a/default/lib/tests/test-rag-planning.sh b/default/lib/tests/test-rag-planning.sh deleted file mode 100755 index dc429951..00000000 --- a/default/lib/tests/test-rag-planning.sh +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env bash -# Integration tests for RAG-Enhanced Plan Judging -# Tests the full workflow: query → plan → validate -# Run: bash default/lib/tests/test-rag-planning.sh - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -LIB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -ARTIFACT_INDEX_DIR="${LIB_DIR}/artifact-index" - -# Test counter -TESTS_RUN=0 -TESTS_PASSED=0 - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -NC='\033[0m' # No Color - -# Test helper -run_test() { - local name="$1" - local expected_exit="$2" - shift 2 - local cmd=("$@") - - TESTS_RUN=$((TESTS_RUN + 1)) - - local output - local exit_code=0 - output=$("${cmd[@]}" 2>&1) || exit_code=$? - - if [[ "$exit_code" == "$expected_exit" ]]; then - echo -e "${GREEN}PASS${NC}: $name" - TESTS_PASSED=$((TESTS_PASSED + 1)) - return 0 - else - echo -e "${RED}FAIL${NC}: $name" - echo " Expected exit code: $expected_exit, got: $exit_code" - echo " Output: ${output:0:200}" - return 1 - fi -} - -# Test helper for output matching -run_test_output() { - local name="$1" - local expected_pattern="$2" - shift 2 - local cmd=("$@") - - TESTS_RUN=$((TESTS_RUN + 1)) - - local output - output=$("${cmd[@]}" 2>&1) || true - - if echo "$output" | grep -q "$expected_pattern"; then - echo -e "${GREEN}PASS${NC}: $name" - TESTS_PASSED=$((TESTS_PASSED + 1)) - return 0 - else - echo -e "${RED}FAIL${NC}: $name" - echo " Expected pattern: $expected_pattern" - echo " Output: ${output:0:300}" - return 1 - fi -} - -echo "Running RAG-Enhanced Plan Judging Integration Tests..." -echo "=======================================================" -echo "" - -# ============================================ -# Test 1: artifact_query.py --mode planning -# ============================================ -echo "## Testing artifact_query.py planning mode" - -run_test "Planning mode returns valid JSON" 0 \ - python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "test query" --json - -run_test_output "Planning mode has expected fields" "is_empty_index" \ - python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "test query" --json - -run_test_output "Planning mode includes query_time_ms" "query_time_ms" \ - python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "authentication oauth" --json - -run_test "Planning mode with limit works" 0 \ - python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "test" --limit 3 --json - -echo "" - -# ============================================ -# Test 2: Planning mode performance -# ============================================ -echo "## Testing planning mode performance" - -echo -n "Performance test (<200ms)... " -TESTS_RUN=$((TESTS_RUN + 1)) - -start=$(python3 -c "import time; print(int(time.time() * 1000))") -python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "authentication oauth jwt" --json > /dev/null 2>&1 -end=$(python3 -c "import time; print(int(time.time() * 1000))") -duration=$((end - start)) - -if [[ $duration -lt 200 ]]; then - echo -e "${GREEN}PASS${NC}: Completed in ${duration}ms (<200ms)" - TESTS_PASSED=$((TESTS_PASSED + 1)) -else - echo -e "${YELLOW}WARN${NC}: Completed in ${duration}ms (>200ms target)" - TESTS_PASSED=$((TESTS_PASSED + 1)) # Still pass, just warn -fi - -echo "" - -# ============================================ -# Test 3: validate-plan-precedent.py -# ============================================ -echo "## Testing validate-plan-precedent.py" - -# Create a temporary test plan -TEST_PLAN=$(mktemp /tmp/test-plan-XXXXXX.md) -cat > "$TEST_PLAN" <<'EOF' -# Test Plan - -**Goal:** Implement authentication system - -**Architecture:** Microservices with API gateway - -**Tech Stack:** Python, SQLite, JWT - -### Task 1: Create auth module - -Test content here. -EOF - -run_test "Validation script runs successfully" 0 \ - python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" - -run_test_output "Validation extracts keywords" "Keywords extracted" \ - python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" - -run_test_output "Validation JSON output has passed field" '"passed":' \ - python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" --json - -run_test "Validation with custom threshold works" 0 \ - python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" --threshold 50 - -# Clean up test plan -rm -f "$TEST_PLAN" - -echo "" - -# ============================================ -# Test 4: Edge cases -# ============================================ -echo "## Testing edge cases" - -# Test non-existent file -run_test "Missing file returns error exit code" 2 \ - python3 "${LIB_DIR}/validate-plan-precedent.py" "/nonexistent/file.md" - -# Test empty query (should work, return empty results) -run_test_output "Empty index handled gracefully" "is_empty_index" \ - python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "xyznonexistentquery123" --json - -# Test threshold validation -run_test "Invalid threshold (negative) rejected" 2 \ - python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" --threshold -1 2>/dev/null || echo "expected" - -run_test "Invalid threshold (>100) rejected" 2 \ - python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" --threshold 101 2>/dev/null || echo "expected" - -echo "" - -# ============================================ -# Test 5: Keyword extraction -# ============================================ -echo "## Testing keyword extraction quality" - -# Create plan with Tech Stack -TECH_PLAN=$(mktemp /tmp/tech-plan-XXXXXX.md) -cat > "$TECH_PLAN" <<'EOF' -# Tech Plan - -**Goal:** Build API - -**Architecture:** REST - -**Tech Stack:** Redis, PostgreSQL, Go - -### Task 1: Setup database -EOF - -run_test_output "Tech Stack keywords extracted (redis)" "redis" \ - python3 "${LIB_DIR}/validate-plan-precedent.py" "$TECH_PLAN" --json - -run_test_output "Tech Stack keywords extracted (postgresql)" "postgresql" \ - python3 "${LIB_DIR}/validate-plan-precedent.py" "$TECH_PLAN" --json - -rm -f "$TECH_PLAN" - -echo "" - -# ============================================ -# Summary -# ============================================ -echo "=======================================================" -echo "Tests: $TESTS_PASSED/$TESTS_RUN passed" - -if [[ $TESTS_PASSED -eq $TESTS_RUN ]]; then - echo -e "${GREEN}All tests passed!${NC}" - exit 0 -else - echo -e "${RED}Some tests failed${NC}" - exit 1 -fi diff --git a/default/lib/validate-plan-precedent.py b/default/lib/validate-plan-precedent.py deleted file mode 100755 index b287c1a7..00000000 --- a/default/lib/validate-plan-precedent.py +++ /dev/null @@ -1,348 +0,0 @@ -#!/usr/bin/env python3 -""" -Plan Precedent Validation - -Validates a plan file against known failure patterns from the artifact index. -Warns if the plan's approach overlaps significantly with past failures. - -Usage: - python3 validate-plan-precedent.py [--threshold 30] [--json] - -Exit codes: - 0 - Plan passes validation (or index unavailable) - 1 - Plan has significant overlap with failure patterns (warning) - 2 - Error (invalid arguments, file not found, etc.) -""" - -import argparse -import json -import re -import sqlite3 -import sys -from pathlib import Path -from typing import Any, Dict, List, Optional, Set - - -def get_project_root() -> Path: - """Get project root by looking for .ring or .git.""" - current = Path.cwd() - for parent in [current] + list(current.parents): - if (parent / ".ring").exists() or (parent / ".git").exists(): - return parent - return current - - -def get_db_path() -> Path: - """Get artifact index database path.""" - return get_project_root() / ".ring" / "cache" / "artifact-index" / "context.db" - - -def extract_keywords_from_plan(plan_path: Path) -> Set[str]: - """Extract significant keywords from plan file. - - Focuses on: - - Goal section - - Architecture section - - Tech Stack section - - Task names - - Technical terms - """ - try: - content = plan_path.read_text(encoding='utf-8') - except (UnicodeDecodeError, IOError, PermissionError): - # Return empty set on read error - caller will handle - return set() - - keywords: Set[str] = set() - - # Extract from Goal section - goal_match = re.search(r'\*\*Goal:\*\*\s*(.+?)(?:\n\n|\*\*)', content, re.DOTALL) - if goal_match: - keywords.update(extract_words(goal_match.group(1))) - - # Extract from Architecture section - arch_match = re.search(r'\*\*Architecture:\*\*\s*(.+?)(?:\n\n|\*\*)', content, re.DOTALL) - if arch_match: - keywords.update(extract_words(arch_match.group(1))) - - # Extract from Tech Stack section (key failure indicator) - tech_match = re.search(r'\*\*Tech Stack:\*\*\s*(.+?)(?:\n|$)', content) - if tech_match: - keywords.update(extract_words(tech_match.group(1))) - - # Extract from Task names - task_names = re.findall(r'### Task \d+:\s*(.+)', content) - for name in task_names: - keywords.update(extract_words(name)) - - # Extract from Historical Precedent query if present - query_match = re.search(r'\*\*Query:\*\*\s*"([^"]+)"', content) - if query_match: - keywords.update(extract_words(query_match.group(1))) - - return keywords - - -def extract_words(text: str) -> Set[str]: - """Extract significant words from text (lowercase, >3 chars, alphanumeric).""" - words = re.findall(r'\b[a-zA-Z][a-zA-Z0-9]{2,}\b', text.lower()) - # Filter out common words - stopwords = { - 'the', 'and', 'for', 'this', 'that', 'with', 'from', 'will', 'have', - 'are', 'was', 'been', 'being', 'has', 'had', 'does', 'did', 'doing', - 'would', 'could', 'should', 'must', 'can', 'may', 'might', 'shall', - 'into', 'onto', 'upon', 'about', 'above', 'below', 'between', - 'through', 'during', 'before', 'after', 'under', 'over', - 'then', 'than', 'when', 'where', 'what', 'which', 'who', 'how', 'why', - 'each', 'every', 'all', 'any', 'some', 'most', 'many', 'few', - 'file', 'files', 'code', 'step', 'task', 'test', 'tests', 'run', 'add', - 'create', 'update', 'implement', 'use', 'using', 'used' - } - return {w for w in words if w not in stopwords} - - -def get_failed_handoffs(conn: sqlite3.Connection) -> List[Dict[str, Any]]: - """Get all failed handoffs from the index.""" - sql = """ - SELECT id, session_name, task_number, task_summary, what_failed, outcome - FROM handoffs - WHERE outcome IN ('FAILED', 'PARTIAL_MINUS') - """ - try: - cursor = conn.execute(sql) - columns = [desc[0] for desc in cursor.description] - return [dict(zip(columns, row)) for row in cursor.fetchall()] - except sqlite3.OperationalError: - return [] - - -def extract_keywords_from_handoff(handoff: Dict[str, Any]) -> Set[str]: - """Extract keywords from a handoff record. - - Extracts from task_summary, what_failed, and key_decisions fields - to capture full failure context. - """ - keywords: Set[str] = set() - - task_summary = handoff.get('task_summary', '') or '' - what_failed = handoff.get('what_failed', '') or '' - key_decisions = handoff.get('key_decisions', '') or '' - - keywords.update(extract_words(task_summary)) - keywords.update(extract_words(what_failed)) - keywords.update(extract_words(key_decisions)) - - return keywords - - -def calculate_overlap(plan_keywords: Set[str], handoff_keywords: Set[str]) -> float: - """Calculate keyword overlap using bidirectional maximum. - - Checks overlap in both directions to avoid dilution bias: - - What % of plan keywords match failure? (catches small plans) - - What % of failure keywords appear in plan? (catches large plans) - - Returns: Maximum of both percentages (0-100). - """ - if not plan_keywords or not handoff_keywords: - return 0.0 - - intersection = plan_keywords & handoff_keywords - - # Bidirectional: Check both directions, return higher - # This prevents large plans from escaping detection via keyword dilution - plan_coverage = (len(intersection) / len(plan_keywords)) * 100 - failure_coverage = (len(intersection) / len(handoff_keywords)) * 100 - - return max(plan_coverage, failure_coverage) - - -def validate_plan( - plan_path: Path, - threshold: int = 30 -) -> Dict[str, Any]: - """Validate plan against failure patterns. - - Returns validation result with: - - passed: True if no significant overlap found - - warnings: List of overlapping handoffs - - plan_keywords: Keywords extracted from plan - - threshold: Overlap threshold used - """ - result: Dict[str, Any] = { - "passed": True, - "warnings": [], - "plan_file": str(plan_path), - "plan_keywords": [], - "threshold": threshold, - "index_available": True - } - - # Check plan file exists - if not plan_path.exists(): - result["passed"] = False - result["error"] = f"Plan file not found: {plan_path}" - return result - - # Extract keywords from plan - plan_keywords = extract_keywords_from_plan(plan_path) - result["plan_keywords"] = sorted(plan_keywords) - - if not plan_keywords: - result["warning"] = "No significant keywords extracted from plan" - return result - - # Check database exists - db_path = get_db_path() - if not db_path.exists(): - result["index_available"] = False - result["message"] = "Artifact index not available - skipping validation" - return result - - # Query failed handoffs (use context manager to ensure connection is closed) - try: - with sqlite3.connect(str(db_path), timeout=30.0) as conn: - failed_handoffs = get_failed_handoffs(conn) - except sqlite3.Error as e: - result["index_available"] = False - result["error"] = "Database error: unable to query artifact index" - return result - - if not failed_handoffs: - result["message"] = "No failed handoffs in index - nothing to validate against" - return result - - # Check overlap with each failed handoff - for handoff in failed_handoffs: - handoff_keywords = extract_keywords_from_handoff(handoff) - overlap_pct = calculate_overlap(plan_keywords, handoff_keywords) - - if overlap_pct >= threshold: - result["passed"] = False - overlapping_keywords = sorted(plan_keywords & handoff_keywords) - result["warnings"].append({ - "handoff_id": handoff["id"], - "session": handoff.get("session_name", "unknown"), - "task": handoff.get("task_number", "?"), - "outcome": handoff.get("outcome", "UNKNOWN"), - "overlap_percent": round(overlap_pct, 1), - "overlapping_keywords": overlapping_keywords, - "what_failed": (handoff.get("what_failed", "") or "")[:200] - }) - - return result - - -def format_result(result: Dict[str, Any]) -> str: - """Format validation result for human-readable output.""" - output: List[str] = [] - - output.append("## Plan Precedent Validation") - output.append("") - output.append(f"**Plan:** `{result['plan_file']}`") - output.append(f"**Threshold:** {result['threshold']}% overlap") - output.append("") - - if result.get("error"): - output.append(f"**ERROR:** {result['error']}") - return "\n".join(output) - - if not result.get("index_available"): - output.append(f"**Note:** {result.get('message', 'Index not available')}") - output.append("") - output.append("**Result:** PASS (no validation data)") - return "\n".join(output) - - if result.get("message"): - output.append(f"**Note:** {result['message']}") - output.append("") - - keywords = result.get("plan_keywords", []) - if keywords: - output.append(f"**Keywords extracted:** {len(keywords)}") - output.append(f"```\n{', '.join(keywords[:20])}{'...' if len(keywords) > 20 else ''}\n```") - output.append("") - - if result["passed"]: - output.append("### Result: PASS") - output.append("") - output.append("No significant overlap with known failure patterns.") - else: - output.append("### Result: WARNING - Overlap Detected") - output.append("") - output.append(f"Found {len(result['warnings'])} failure pattern(s) with >={result['threshold']}% keyword overlap:") - output.append("") - - for warning in result["warnings"]: - session = warning.get("session", "unknown") - task = warning.get("task", "?") - overlap = warning.get("overlap_percent", 0) - outcome = warning.get("outcome", "UNKNOWN") - - output.append(f"#### [{session}/task-{task}] ({outcome}) - {overlap}% overlap") - output.append("") - - overlapping = warning.get("overlapping_keywords", []) - if overlapping: - output.append(f"**Overlapping keywords:** `{', '.join(overlapping)}`") - - what_failed = warning.get("what_failed", "") - if what_failed: - output.append(f"**What failed:** {what_failed}") - - output.append("") - - output.append("---") - output.append("**Action Required:** Review the failure patterns above and ensure your plan addresses these issues.") - - return "\n".join(output) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Validate plan against known failure patterns", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__ - ) - parser.add_argument( - "plan_file", - type=str, - help="Path to plan file to validate" - ) - parser.add_argument( - "--threshold", - type=int, - default=30, - help="Overlap percentage threshold for warnings (default: 30)" - ) - parser.add_argument( - "--json", - action="store_true", - help="Output as JSON" - ) - - args = parser.parse_args() - - # Validate threshold - if args.threshold < 0 or args.threshold > 100: - print("Error: Threshold must be between 0 and 100", file=sys.stderr) - return 2 - - plan_path = Path(args.plan_file) - result = validate_plan(plan_path, args.threshold) - - if args.json: - print(json.dumps(result, indent=2)) - else: - print(format_result(result)) - - if result.get("error"): - return 2 - elif not result["passed"]: - return 1 - else: - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/default/skills/artifact-query/SKILL.md b/default/skills/artifact-query/SKILL.md deleted file mode 100644 index 085855a6..00000000 --- a/default/skills/artifact-query/SKILL.md +++ /dev/null @@ -1,170 +0,0 @@ ---- -name: ring:artifact-query -description: | - Search the Artifact Index for relevant historical context using semantic - full-text search. Returns handoffs, plans, and continuity ledgers ranked - by relevance using BM25. - -trigger: | - - Need to find similar past work before starting a task - - Want to learn from previous successes or failures - - Looking for historical context on a topic - - Planning a feature and want precedent - -skip_when: | - - Working on completely novel functionality - - Simple task that doesn't benefit from historical context - - Artifact index not initialized (run artifact_index.py --all first) - -sequence: - before: [ring:writing-plans, ring:executing-plans] - -related: - similar: [ring:exploring-codebase] ---- - -# Artifact Query - -## Overview - -Search Ring's Artifact Index for relevant historical context. Uses SQLite FTS5 full-text search with BM25 ranking to find: - -- **Handoffs** - Completed task records with what worked/failed -- **Plans** - Implementation design documents -- **Continuity** - Session state snapshots with learnings - -**Query response time:** < 100ms for typical searches - -**Announce at start:** "I'm searching the artifact index for relevant precedent." - -## When to Use - -| Scenario | Use This Skill | -|----------|---------------| -| Starting a new feature | Yes - find similar implementations | -| Debugging a recurring issue | Yes - find past resolutions | -| Writing a plan | Yes - learn from past approaches | -| Simple one-liner fix | No - overhead not worth it | -| First time using Ring | No - index is empty | - -## The Process - -### Step 1: Formulate Query - -Choose relevant keywords that describe what you're looking for: -- Feature names: "authentication", "OAuth", "API" -- Problem types: "error handling", "performance", "testing" -- Components: "database", "frontend", "hook" - -### Step 2: Run Query - -```bash -python3 default/lib/artifact-index/artifact_query.py "" [options] -``` - -**Options:** -- `--mode search|planning` - Query mode (planning for structured precedent) -- `--type handoffs|plans|continuity|all` - Filter by artifact type -- `--outcome SUCCEEDED|FAILED|...` - Filter handoffs by outcome -- `--limit N` - Maximum results (1-100, default: 5) -- `--json` - Output as JSON for programmatic use -- `--stats` - Show index statistics -- `--no-save` - Disable automatic query saving (saving enabled by default) - -### Planning Mode (Recommended for ring:write-plan) - -For structured precedent when creating implementation plans: - -```bash -python3 default/lib/artifact-index/artifact_query.py --mode planning "feature topic" --json -``` - -Returns: -- **successful_handoffs**: Past implementations that worked (reference these) -- **failed_handoffs**: Past implementations that failed (avoid these patterns) -- **relevant_plans**: Similar past plans for reference -- **query_time_ms**: Performance metric (target <200ms) -- **is_empty_index**: True if no historical data available - -Empty index returns: -```json -{ - "is_empty_index": true, - "message": "No artifact index found. This is normal for new projects." -} -``` -This is NOT an error - proceed with standard planning. - -### Step 3: Interpret Results - -Results are ranked by relevance (BM25 score). For each result: - -1. **Check outcome** - Learn from successes, avoid failures -2. **Read what_worked** - Reuse successful approaches -3. **Read what_failed** - Don't repeat mistakes -4. **Note file paths** - Can read full artifact if needed - -### Step 4: Apply Learnings - -Use historical context to inform current work: -- Reference successful patterns in your implementation -- Avoid approaches that failed previously -- Cite the precedent in your plan or handoff - -## Examples - -### Find Authentication Implementations - -```bash -python3 default/lib/artifact-index/artifact_query.py "authentication OAuth JWT" --type handoffs -``` - -### Find Successful API Designs - -```bash -python3 default/lib/artifact-index/artifact_query.py "API design REST" --outcome SUCCEEDED -``` - -### Get Index Statistics - -```bash -python3 default/lib/artifact-index/artifact_query.py --stats -``` - -### Search Plans Only - -```bash -python3 default/lib/artifact-index/artifact_query.py "context management" --type plans --json -``` - -## Integration with Planning - -When creating plans (ring:writing-plans skill), query the artifact index first: - -1. Search for similar past implementations -2. Note which approaches succeeded vs failed -3. Include historical context in your plan -4. Reference specific handoffs that inform decisions - -This enables RAG-enhanced planning where new plans learn from past experience. - -## Initialization - -If the index is empty, initialize it: - -```bash -python3 default/lib/artifact-index/artifact_index.py --all -``` - -This indexes: -- `docs/handoffs/**/*.md` - Handoff documents -- `docs/plans/*.md` - Plan documents -- `.ring/ledgers/*.md` and `CONTINUITY*.md` - Continuity ledgers - -## Remember - -- Query before starting significant work -- Learn from both successes AND failures -- Cite historical precedent in your work -- Keep the index updated (hooks do this automatically) -- Response time target: < 100ms diff --git a/default/skills/compound-learnings/SKILL.md b/default/skills/compound-learnings/SKILL.md deleted file mode 100644 index 5c7e6364..00000000 --- a/default/skills/compound-learnings/SKILL.md +++ /dev/null @@ -1,265 +0,0 @@ ---- -name: ring:compound-learnings -description: | - Use when asked to "analyze learnings", "find patterns", "what should become rules", - "compound my learnings", or after accumulating 5+ session learnings. Analyzes - session learnings to detect recurring patterns (3+ occurrences), categorizes them - as rule/skill/hook candidates, and generates proposals for user approval. - -trigger: | - - "What patterns should become permanent?" - - "Analyze my learnings" - - "Compound learnings" - - "Turn learnings into rules" - - After completing a feature with multiple sessions - -skip_when: | - - Fewer than 3 learning files exist - - Just completed first session (no patterns to detect yet) - - Looking for specific past learning (use artifact search instead) - -related: - complementary: [ring:handoff-tracking, artifact-query] ---- - -# Compound Learnings - -Transform ephemeral session learnings into permanent, compounding capabilities. - -## Overview - -This skill analyzes accumulated learnings from past sessions, detects recurring patterns (appearing in 3+ sessions), and generates proposals for new rules, skills, or hooks. User approval is REQUIRED before creating any permanent artifacts. - -**Core Principle:** The system improves itself over time by learning from successes and failures. - -## When to Use - -- "What should I learn from recent sessions?" -- "Improve my setup based on recent work" -- "Turn learnings into skills/rules" -- "What patterns should become permanent?" -- "Compound my learnings" -- After completing a multi-session feature - -## When NOT to Use - -- Fewer than 3 learning files exist (insufficient data) -- Looking for a specific past learning (use artifact search) -- First session on a new project (no history yet) - -## Process - -### Step 1: Gather Learnings - -```bash -# List learnings (most recent first) -ls -t $PROJECT_ROOT/.ring/cache/learnings/*.md 2>/dev/null | head -20 - -# Count total learnings -ls $PROJECT_ROOT/.ring/cache/learnings/*.md 2>/dev/null | wc -l -``` - -**If fewer than 3 files:** STOP. Insufficient data for pattern detection. - -Read the most recent 5-10 files for analysis. - -### Step 2: Extract and Consolidate Patterns - -For each learnings file, extract entries from: - -| Section Header | What to Extract | -|----------------|-----------------| -| `## What Worked` | Success patterns | -| `## What Failed` | Anti-patterns (invert to rules) | -| `## Key Decisions` | Design principles | -| `## Patterns` | Direct pattern candidates | - -**Consolidate similar patterns before counting:** - -| Raw Patterns | Consolidated To | -|--------------|-----------------| -| "Check artifacts before editing", "Verify outputs first", "Look before editing" | "Observe outputs before editing code" | -| "TDD approach", "Test-driven development", "Write tests first" | "Use TDD workflow" | - -Use the most general formulation. - -### Step 3: Detect Recurring Patterns - -**Signal thresholds:** - -| Occurrences | Action | -|-------------|--------| -| 1 | Skip (insufficient signal) | -| 2 | Consider - present to user for information only | -| 3+ | Strong signal - recommend creating permanent artifact | -| 4+ | Definitely create | - -**Only patterns appearing in 3+ DIFFERENT sessions qualify for proposals.** - -### Step 4: Categorize Patterns - -For each qualifying pattern, determine artifact type: - -``` -Is it a sequence of commands/steps? - → YES → SKILL (executable process) - → NO ↓ - -Should it run automatically on an event? - → YES → HOOK (SessionEnd, PostToolUse, etc.) - → NO ↓ - -Is it "when X, do Y" or "never do X"? - → YES → RULE (behavioral heuristic) - → NO → Skip (not worth capturing) -``` - -**Examples:** - -| Pattern | Type | Why | -|---------|------|-----| -| "Run tests before committing" | Hook (PreCommit) | Automatic gate | -| "Step-by-step debugging process" | Skill | Manual sequence | -| "Always use explicit file paths" | Rule | Behavioral heuristic | - -### Step 5: Generate Proposals - -Present each proposal in this format: - -```markdown ---- -## Pattern: [Generalized Name] - -**Signal:** [N] sessions ([list session IDs]) - -**Category:** [rule / skill / hook] - -**Rationale:** [Why this artifact type, why worth creating] - -**Draft Content:** -[Preview of what would be created] - -**Target Location:** `.ring/generated/rules/[name].md` or `.ring/generated/skills/[name]/SKILL.md` - -**Your Action:** [APPROVE] / [REJECT] / [MODIFY] - ---- -``` - -### Step 6: WAIT FOR USER APPROVAL - -**CRITICAL: DO NOT create any files without explicit user approval.** - -User must explicitly say: -- "Approve" or "Create it" → Proceed to create -- "Reject" or "Skip" → Mark as rejected, do not create -- "Modify" → User provides changes, then re-propose - -**NEVER assume approval. NEVER create without explicit consent.** - -### Step 7: Create Approved Artifacts - -#### Before Creating Any Artifact: -Ensure the target directories exist: -```bash -mkdir -p $PROJECT_ROOT/.ring/generated/rules -mkdir -p $PROJECT_ROOT/.ring/generated/skills -mkdir -p $PROJECT_ROOT/.ring/generated/hooks -``` - -#### For Rules: - -Create file at `.ring/generated/rules/.md` with: -- Rule name and context -- Pattern description -- DO / DON'T sections -- Source sessions - -#### For Skills: - -Create directory `.ring/generated/skills//` with `SKILL.md` containing: -- YAML frontmatter (name, description) -- Overview (what it does) -- When to Use (triggers) -- When NOT to Use -- Step-by-step process -- Source sessions - -**Note:** Generated skills are project-local. To make them discoverable, add to your project's `CLAUDE.md`: -```markdown -## Project-Specific Skills -See `.ring/generated/skills/` for learnings-based skills. -``` - -#### For Hooks: - -Create in `.ring/generated/hooks/.sh` following Ring's hook patterns. -Register in project's `.claude/hooks.json` (not plugin's hooks.json). - -### Step 8: Archive Processed Learnings - -After creating artifacts, move processed learnings: - -```bash -# Create archive directory -mkdir -p $PROJECT_ROOT/.ring/cache/learnings/archived - -# Move processed files (preserving them for reference) -mv $PROJECT_ROOT/.ring/cache/learnings/2025-*.md $PROJECT_ROOT/.ring/cache/learnings/archived/ -``` - -### Step 9: Summary Report - -```markdown -## Compounding Complete - -**Learnings Analyzed:** [N] sessions -**Patterns Found:** [M] -**Artifacts Created:** [K] - -### Created: -- Rule: `explicit-paths.md` - Always use explicit file paths -- Skill: `ring:systematic-debugging` - Step-by-step debugging workflow - -### Skipped (insufficient signal): -- "Pattern X" (2 occurrences - below threshold) - -### Rejected by User: -- "Pattern Y" - User chose not to create - -**Your setup is now permanently improved.** -``` - -## Quality Checks - -Before creating any artifact: - -1. **Is it general enough?** Would it apply in other projects? -2. **Is it specific enough?** Does it give concrete guidance? -3. **Does it already exist?** Check `.ring/generated/rules/` and `.ring/generated/skills/` first -4. **Is it the right type?** Sequences → skills, heuristics → rules - -## Common Mistakes - -| Mistake | Why It's Wrong | Correct Approach | -|---------|----------------|------------------| -| Creating without approval | Violates user consent | ALWAYS wait for explicit approval | -| Low-signal patterns | 1-2 occurrences is noise | Require 3+ session occurrences | -| Too specific | Won't apply elsewhere | Generalize to broader principle | -| Too vague | No actionable guidance | Include concrete DO/DON'T | -| Duplicate artifacts | Wastes space, confuses | Check existing rules/skills first | - -## Files Reference - -**Project-local data (per-project):** -- Learnings input: `.ring/cache/learnings/*.md` -- Proposals: `.ring/cache/proposals/pending.json` -- History: `.ring/cache/proposals/history.json` -- Generated rules: `.ring/generated/rules/.md` -- Generated skills: `.ring/generated/skills//SKILL.md` -- Generated hooks: `.ring/generated/hooks/.sh` - -**Plugin code (shared, read-only):** -- Library: `default/lib/compound_learnings/` -- Skill: `default/skills/compound-learnings/SKILL.md` -- Command: `default/commands/compound-learnings.md` diff --git a/default/skills/continuity-ledger/SKILL.md b/default/skills/continuity-ledger/SKILL.md deleted file mode 100644 index b0d09726..00000000 --- a/default/skills/continuity-ledger/SKILL.md +++ /dev/null @@ -1,247 +0,0 @@ ---- -name: ring:continuity-ledger -description: | - Create or update continuity ledger for state preservation across /clear operations. - Ledgers maintain session state externally, surviving context resets with full fidelity. - -trigger: | - - Before running /clear - - Context usage approaching 70%+ - - Multi-phase implementations (3+ phases) - - Complex refactors spanning multiple sessions - - Any session expected to hit 85%+ context - -skip_when: | - - Quick tasks (< 30 min estimated) - - Simple single-file bug fixes - - Already using handoffs for cross-session transfer - - No multi-phase work in progress ---- - -# Continuity Ledger - -Maintain a ledger file that survives `/clear` for long-running sessions. Unlike handoffs (cross-session), ledgers preserve state within a session. - -**Why clear instead of compact?** Each compaction is lossy compression - after several compactions, you're working with degraded context. Clearing + loading the ledger gives you fresh context with full signal. - -## When to Use - -- Before running `/clear` -- Context usage approaching 70%+ -- Multi-day implementations -- Complex refactors you pick up/put down -- Any session expected to hit 85%+ context - -## When NOT to Use - -- Quick tasks (< 30 min) -- Simple bug fixes -- Single-file changes -- Already using handoffs for cross-session transfer - -## Ledger Location - -Ledgers are stored in: `$PROJECT_ROOT/.ring/ledgers/` -Format: `CONTINUITY-.md` - -**Use kebab-case for session name** (e.g., `auth-refactor`, `api-migration`) - -## Process - -### 1. Determine Ledger File - -Check if a ledger already exists: -```bash -ls "$PROJECT_ROOT/.ring/ledgers/CONTINUITY-"*.md 2>/dev/null -``` - -- **If exists**: Update the existing ledger -- **If not**: Create new file with the template below - -### 2. Create/Update Ledger - -**REQUIRED SECTIONS (all must be present):** - -```markdown -# Session: -Updated: - -## Goal - - -## Constraints - - -## Key Decisions - -- Decision 1: Chose X over Y because... -- Decision 2: ... - -## State -- Done: - - [x] Phase 1: - - [x] Phase 2: -- Now: [->] Phase 3: -- Next: - - [ ] Phase 4: - - [ ] Phase 5: - -## Open Questions -- UNCONFIRMED: -- UNCONFIRMED: - -## Working Set - -- Branch: `feature/xyz` -- Key files: `src/auth/`, `tests/auth/` -- Test cmd: `npm test -- --grep auth` -- Build cmd: `npm run build` -``` - -### 3. Checkbox States - -| Symbol | Meaning | -|--------|---------| -| `[x]` | Completed | -| `[->]` | In progress (current) | -| `[ ]` | Pending | - -**Why checkboxes in files:** TodoWrite survives compaction, but the *understanding* around those todos degrades each time context is compressed. File-based checkboxes are never compressed - full fidelity preserved. - -### 4. Real-Time Update Rule (MANDATORY) - -**⛔ HARD GATE: Update ledger IMMEDIATELY after completing ANY phase.** - -You (the AI) are the one doing the work. You know exactly when: -- A phase is complete -- A decision is made -- An open question is resolved - -**There is NO excuse to wait for the user to ask.** This is the same discipline as `TodoWrite` - update in real-time. - -| After This Event | MUST Do This | Before | -|------------------|--------------|--------| -| Complete a phase | Mark `[x]`, move `[->]` to next | Proceeding to next phase | -| All phases done | Add `Status: COMPLETED` | Telling user "done" | -| Make key decision | Add to Key Decisions section | Moving on | -| Resolve open question | Change UNCONFIRMED → CONFIRMED | Proceeding | - -**Anti-Rationalization:** - -| Rationalization | Why It's WRONG | Required Action | -|-----------------|----------------|-----------------| -| "I'll update after I finish" | You'll forget. State drifts. User asks why ledger is stale. | **Update NOW** | -| "It's just one phase" | One phase becomes three. Ledger shows Phase 2 when you're on Phase 5. | **Update NOW** | -| "User will ask me to update" | User shouldn't have to. You're the AI doing the work. You know. | **Update NOW** | -| "I'm in the flow, don't want to stop" | 10 seconds to update vs. explaining why ledger is wrong later. | **Update NOW** | - -### 5. Update Guidelines - -**When to update the ledger:** -- **IMMEDIATELY after completing a phase** (MANDATORY - see above) -- Session start: Read and refresh -- After major decisions -- Before `/clear` -- At natural breakpoints -- When context usage >70% - -**What to update:** -- Move completed items from "Now" to "Done" (change `[->]` to `[x]`) -- Update "Now" with current focus -- Add new decisions as they're made -- Mark items as UNCONFIRMED if uncertain -- Add `Status: COMPLETED` when all phases done - -### 6. After Clear Recovery - -When resuming after `/clear`: - -1. **Ledger loads automatically** (SessionStart hook) -2. **Find `[->]` marker** to see current phase -3. **Review UNCONFIRMED items** -4. **Ask 1-3 targeted questions** to validate assumptions -5. **Update ledger** with clarifications -6. **Continue work** with fresh context - -## Template Response - -After creating/updating the ledger, respond: - -``` -Continuity ledger updated: .ring/ledgers/CONTINUITY-.md - -Current state: -- Done: -- Now: -- Next: - -Ready for /clear - ledger will reload on resume. -``` - -## UNCONFIRMED Prefix - -Mark uncertain items explicitly: - -```markdown -## Open Questions -- UNCONFIRMED: Does the auth middleware need updating? -- UNCONFIRMED: Are we using v2 or v3 of the API? -``` - -After `/clear`, these prompt you to verify before proceeding. - -## Comparison with Other Tools - -| Tool | Scope | Fidelity | -|------|-------|----------| -| CLAUDE.md | Project | Always fresh, stable patterns | -| TodoWrite | Turn | Survives compaction, but understanding degrades | -| CONTINUITY-*.md | Session | External file - never compressed, full fidelity | -| Handoffs | Cross-session | External file - detailed context for new session | - -## Example - -```markdown -# Session: auth-refactor -Updated: 2025-01-15T14:30:00Z - -## Goal -Replace JWT auth with session-based auth. Done when all tests pass and no JWT imports remain. - -## Constraints -- Must maintain backward compat for 2 weeks (migration period) -- Use existing Redis for session storage -- No new dependencies - -## Key Decisions -- Session tokens: UUID v4 (simpler than signed tokens for our use case) -- Storage: Redis with 24h TTL (matches current JWT expiry) -- Migration: Dual-auth period, feature flag controlled - -## State -- Done: - - [x] Phase 1: Session model - - [x] Phase 2: Redis integration - - [x] Phase 3: Login endpoint -- Now: [->] Phase 4: Logout endpoint and session invalidation -- Next: - - [ ] Phase 5: Middleware swap - - [ ] Phase 6: Remove JWT - - [ ] Phase 7: Update tests - -## Open Questions -- UNCONFIRMED: Does rate limiter need session awareness? - -## Working Set -- Branch: `feature/session-auth` -- Key files: `src/auth/session.ts`, `src/middleware/auth.ts` -- Test cmd: `npm test -- --grep session` -``` - -## Additional Notes - -- **Keep it concise** - Brevity matters for context -- **One "Now" item** - Forces focus, prevents sprawl -- **UNCONFIRMED prefix** - Signals what to verify after clear -- **Update frequently** - Stale ledgers lose value quickly -- **Clear > compact** - Fresh context beats degraded context diff --git a/default/skills/handoff-tracking/SKILL.md b/default/skills/handoff-tracking/SKILL.md deleted file mode 100644 index e667b94e..00000000 --- a/default/skills/handoff-tracking/SKILL.md +++ /dev/null @@ -1,207 +0,0 @@ ---- -name: ring:handoff-tracking -description: | - Create detailed handoff documents for session transitions. Captures task status, - learnings, decisions, and next steps in a structured format that gets indexed - for future retrieval. - -trigger: | - - Session ending or transitioning - - User runs /ring:create-handoff command - - Context pressure requiring /clear - - Completing a major milestone - -skip_when: | - - Quick Q&A session with no implementation - - No meaningful work to document - - Session was exploratory with no decisions - -related: - before: [ring:executing-plans, ring:subagent-driven-development] - after: [artifact-query] ---- - -# Handoff Tracking - -## Overview - -Create structured handoff documents that preserve session context for future sessions. Handoffs capture what was done, what worked, what failed, key decisions, and next steps. - -**Core principle:** Handoffs are indexed immediately on creation, making them searchable before the session ends. - -**Announce at start:** "I'm creating a handoff document to preserve this session's context." - -## When to Create Handoffs - -| Situation | Action | -|-----------|--------| -| Session ending | ALWAYS create handoff | -| Running /clear | Create handoff BEFORE clear | -| Major milestone complete | Create handoff to checkpoint progress | -| Context at 70%+ | Create handoff, then /clear | -| Blocked and need help | Create handoff with blockers documented | - -## Handoff File Location - -**Path:** `docs/handoffs/{session-name}/YYYY-MM-DD_HH-MM-SS_{description}.md` - -Where: -- `{session-name}` - From active work context (e.g., `context-management`, `auth-feature`) -- `YYYY-MM-DD_HH-MM-SS` - Current timestamp in 24-hour format -- `{description}` - Brief kebab-case description of work done - -**Example:** `docs/handoffs/context-management/2025-12-27_14-30-00_handoff-tracking-skill.md` - -If no clear session context, use `general/` as the folder name. - -## Handoff Document Template - -Use this exact structure for all handoff documents: - -~~~markdown ---- -date: {ISO timestamp with timezone} -session_name: {session-name} -git_commit: {current commit hash} -branch: {current branch} -repository: {repository name} -topic: "{Feature/Task} Implementation" -tags: [implementation, {relevant-tags}] -status: {complete|in_progress|blocked} -outcome: UNKNOWN -root_span_id: {trace ID if available, empty otherwise} -turn_span_id: {turn span ID if available, empty otherwise} ---- - -# Handoff: {concise description} - -## Task Summary -{Description of task(s) worked on and their status: completed, in_progress, blocked. -If following a plan, reference the plan document and current phase.} - -## Critical References -{2-3 most important file paths that must be read to continue this work. -Leave blank if none.} -- `path/to/critical/file.md` - -## Recent Changes -{Files modified in this session with line references} -- `src/path/to/file.py:45-67` - Added validation logic -- `tests/path/to/test.py:10-30` - New test cases - -## Learnings - -### What Worked -{Specific approaches that succeeded - these get indexed for future sessions} -- Approach: {description} - worked because {reason} -- Pattern: {pattern name} was effective for {use case} - -### What Failed -{Attempted approaches that didn't work - helps future sessions avoid same mistakes} -- Tried: {approach} -> Failed because: {reason} -- Error: {error type} when {action} -> Fixed by: {solution} - -### Key Decisions -{Important choices made and WHY - future sessions reference these} -- Decision: {choice made} - - Alternatives: {other options considered} - - Reason: {why this choice} - -## Files Modified -{Exhaustive list of files created or modified} -- `path/to/new/file.py` - NEW: Description -- `path/to/existing/file.py:100-150` - MODIFIED: Description - -## Action Items & Next Steps -{Prioritized list for the next session} -1. {Most important next action} -2. {Second priority} -3. {Additional items} - -## Other Notes -{Anything else relevant: codebase locations, useful commands, gotchas} -~~~ - -## The Process - -### Step 1: Gather Session Metadata - -```bash -# Get current git state -git rev-parse HEAD # Commit hash -git branch --show-current # Branch name -git remote get-url origin # Repository - -# Get timestamp -date -u +"%Y-%m-%dT%H:%M:%SZ" -``` - -### Step 2: Determine Session Name - -Check for active work context: -1. Recent plan files in `docs/plans/` - extract feature name -2. Recent branch name - use as session context -3. If unclear, use `general` - -### Step 3: Write Handoff Document - -1. Create handoff directory if needed: `mkdir -p docs/handoffs/{session-name}/` -2. Write handoff file with template structure -3. Fill in all sections with session details -4. Be thorough in learnings - these feed compound learning - -### Step 4: Verify Indexing - -After writing the handoff, verify it was indexed: - -```bash -# Check artifact index updated (if database exists) -sqlite3 .ring/cache/artifact-index/context.db \ - "SELECT id, session_name FROM handoffs ORDER BY indexed_at DESC LIMIT 1" -``` - -The PostToolUse hook automatically indexes handoffs on Write. - -## Integration with Ring - -### Execution Reports -When working within dev-team cycles, the handoff's "Recent Changes" and "Files Modified" sections should mirror the execution report format: - -| Metric | Include | -|--------|---------| -| Duration | Time spent on session | -| Tasks Completed | X/Y from plan | -| Files Created | N | -| Files Modified | N | -| Tests Added | N | - -### Session Traces -If session tracing is enabled (Braintrust, etc.), include: -- `root_span_id` - Main trace ID -- `turn_span_id` - Current turn span - -These enable correlation between handoffs and detailed session logs. - -## Outcome Tracking - -Outcomes are marked AFTER the handoff is created, either: -1. User responds to Stop hook prompt -2. User runs outcome marking command later - -**Valid outcomes:** -| Outcome | Meaning | -|---------|---------| -| SUCCEEDED | Task completed successfully | -| PARTIAL_PLUS | Mostly done, minor issues remain | -| PARTIAL_MINUS | Some progress, major issues remain | -| FAILED | Task abandoned or blocked | - -Handoffs start with `outcome: UNKNOWN` and get updated when marked. - -## Remember - -- **Be thorough in Learnings** - These feed the compound learning system -- **Include file:line references** - Makes resumption faster -- **Document WHY not just WHAT** - Decisions without rationale are useless -- **Index happens automatically** - PostToolUse hook handles it -- **Outcome is separate** - Don't try to guess outcome, leave as UNKNOWN diff --git a/default/skills/using-ring/SKILL.md b/default/skills/using-ring/SKILL.md index f3e1bb78..77969312 100644 --- a/default/skills/using-ring/SKILL.md +++ b/default/skills/using-ring/SKILL.md @@ -282,52 +282,6 @@ Your human partner's specific instructions describe WHAT to do, not HOW. **Why:** Specific instructions mean clear requirements, which is when workflows matter MOST. Skipping process on "simple" tasks is how simple tasks become complex problems. -## Context Management & Self-Improvement - -Ring includes skills for managing context and enabling self-improvement: - -| Skill | Use When | Trigger | -|-------|----------|---------| -| `continuity-ledger` | Save session state for future resumption | At 70%+ context OR task completion | -| `ring:create-handoff` | Full handoff document with all context | At 85%+ context OR session end | -| `artifact-query` | Search past handoffs, plans, or outcomes by keywords | Need historical context | -| `ring:handoff-tracking` | Mark task completion with what_worked/what_failed/key_decisions | Task complete | -| `compound-learnings` | Analyze learnings from multiple sessions, detect patterns | After 3+ sessions | - -**Compound Learnings workflow:** Session ends → Hook extracts learnings → After 3+ sessions, patterns emerge → User approves → Permanent rules/skills created. - -### MANDATORY Context Preservation (NON-NEGOTIABLE) - -**Context warnings are BEHAVIORAL TRIGGERS, not informational messages.** - -| Context Level | Warning Type | MANDATORY Action | -|---------------|--------------|------------------| -| 50-69% (info) | `` | Acknowledge, plan for handoff | -| 70-84% (warning) | `` | **CREATE ledger NOW** - No exceptions | -| 85%+ (critical) | `` | **STOP + handoff + /clear** - Immediate | - -**When you receive a MANDATORY-USER-MESSAGE about context:** -1. Display the message verbatim at start of response (per MANDATORY-USER-MESSAGE contract) -2. Execute the required action BEFORE continuing other work -3. Do NOT rationalize delaying action - -**Anti-Rationalization for Context Management:** - -| Rationalization | Why It's WRONG | Required Action | -|-----------------|----------------|-----------------| -| "I'll create handoff after this task" | Context may truncate mid-task, losing everything | **Create handoff NOW** | -| "70% is just a warning, not urgent" | 70% = MANDATORY action, not suggestion | **Create ledger immediately** | -| "The message is informational" | MANDATORY-USER-MESSAGE = behavioral trigger | **Execute required action** | -| "User didn't ask for handoff" | System requires it for context safety | **Create handoff anyway** | -| "I'm almost done, can finish first" | "Almost done" at 85% = high truncation risk | **STOP and handoff NOW** | -| "Small task, won't use much more context" | Every response adds ~2500 tokens | **Follow threshold rules** | - -**Verification Checklist:** -- [ ] At 70%+: Did I create a continuity-ledger? -- [ ] At 85%+: Did I STOP, create handoff, and recommend /clear? -- [ ] Did I display MANDATORY-USER-MESSAGE verbatim? -- [ ] Did I execute required action BEFORE other work? - ## Summary **Starting any task:** diff --git a/default/skills/writing-plans/SKILL.md b/default/skills/writing-plans/SKILL.md index dce43d8a..410125a7 100644 --- a/default/skills/writing-plans/SKILL.md +++ b/default/skills/writing-plans/SKILL.md @@ -34,40 +34,16 @@ This skill dispatches a specialized agent to write comprehensive implementation ## The Process -**Step 1: Query Historical Precedent (MANDATORY)** - -Before planning, query the artifact index for historical context: - -```bash -# Keywords MUST be alphanumeric with spaces only (sanitize before use) -python3 default/lib/artifact-index/artifact_query.py --mode planning "keywords" --json -``` - -**Keyword Extraction (MANDATORY):** -1. Extract topic keywords from the planning request -2. Sanitize keywords: Keep only alphanumeric characters, hyphens, and spaces -3. Example: "Implement OAuth2 authentication!" → keywords: "oauth2 authentication" - -**Safety:** Never include shell metacharacters (`;`, `$`, `` ` ``, `|`, `&`) in keywords. - -Results inform the plan: -- `successful_handoffs` → Reference patterns that worked -- `failed_handoffs` → WARN in plan, design to avoid -- `relevant_plans` → Review for approach ideas - -If `is_empty_index: true` → Proceed without precedent (normal for new projects). - -**Step 2: Dispatch Write-Plan Agent** +**Step 1: Dispatch Write-Plan Agent** Dispatch via `Task(subagent_type: "ring:write-plan", model: "opus")` with: - Instructions to create bite-sized tasks (2-5 min each) - Include exact file paths, complete code, verification steps -- **Include Historical Precedent section** in plan with query results - Save to `docs/plans/YYYY-MM-DD-.md` -**Step 3: Validate Plan Against Failure Patterns** +**Step 2: Validate Plan** -After the plan is saved, validate it against known failures: +After the plan is saved, validate it: ```bash python3 default/lib/validate-plan-precedent.py docs/plans/YYYY-MM-DD-.md @@ -75,14 +51,12 @@ python3 default/lib/validate-plan-precedent.py docs/plans/YYYY-MM-DD-.m **Interpretation:** - `PASS` → Plan is safe to execute -- `WARNING` → Plan has >30% keyword overlap with past failures +- `WARNING` → Plan has issues to address - Review the warnings in the output - - Update plan to address the failure patterns + - Update plan to address the issues - Re-run validation until PASS -**Note:** If artifact index is unavailable, validation passes automatically (nothing to check against). - -**Step 4: Ask User About Execution** +**Step 3: Ask User About Execution** Ask via `AskUserQuestion`: "Execute now?" Options: 1. Execute now → `ring:subagent-driven-development` @@ -95,11 +69,11 @@ Ask via `AskUserQuestion`: "Execute now?" Options: ## What the Agent Does -**Query precedent** → Explore codebase → identify files → break into bite-sized tasks (2-5 min) → write complete code → include exact commands → add review checkpoints → **include Historical Precedent section** → verify Zero-Context Test → save to `docs/plans/YYYY-MM-DD-.md` → report back +Explore codebase → identify files → break into bite-sized tasks (2-5 min) → write complete code → include exact commands → add review checkpoints → verify Zero-Context Test → save to `docs/plans/YYYY-MM-DD-.md` → report back ## Requirements for Plans -Every plan: **Historical Precedent section** | Header (goal, architecture, tech stack) | Verification commands with expected output | Exact file paths (never "somewhere in src") | Complete code (never "add validation here") | Bite-sized steps with verification | Failure recovery | Review checkpoints | Zero-Context Test | **Recommended agents per task** | **Avoids known failure patterns** +Every plan: Header (goal, architecture, tech stack) | Verification commands with expected output | Exact file paths (never "somewhere in src") | Complete code (never "add validation here") | Bite-sized steps with verification | Failure recovery | Review checkpoints | Zero-Context Test | **Recommended agents per task** ## Agent Selection diff --git a/default/templates/CONTINUITY-example.md b/default/templates/CONTINUITY-example.md deleted file mode 100644 index 8776e6b3..00000000 --- a/default/templates/CONTINUITY-example.md +++ /dev/null @@ -1,48 +0,0 @@ -# Session: context-management-system -Updated: 2025-12-27T14:30:00Z - -## Goal -Implement context management system for Ring plugin. Done when: -1. Continuity ledger skill exists and is functional -2. SessionStart hook loads ledgers automatically -3. PreCompact/Stop hooks save ledgers automatically -4. All tests pass - -## Constraints -- Must integrate with existing Ring hook infrastructure -- Must use bash for hooks (matching existing patterns) -- Must not break existing session-start.sh functionality -- Ledgers stored in .ring/ledgers/ (not default/ plugin directory) - -## Key Decisions -- Ledger location: .ring/ledgers/ (project-level, not plugin-level) - - Rationale: Ledgers are project-specific state, not plugin artifacts -- Checkpoint marker: [->] for current phase - - Rationale: Visually distinct from [x] and [ ], grep-friendly -- Hook trigger: PreCompact AND Stop - - Rationale: Catch both automatic compaction and manual stop - -## State -- Done: - - [x] Phase 1: Create continuity-ledger skill directory - - [x] Phase 2: Write SKILL.md with full documentation - - [x] Phase 3: Add ledger detection to session-start.sh - - [x] Phase 4: Create ledger-save.sh hook - - [x] Phase 5: Register hooks in hooks.json -- Now: [->] Phase 6: Create templates and documentation -- Next: - - [ ] Phase 7: Integration testing - - [ ] Phase 8: Clean up test artifacts - -## Open Questions -- UNCONFIRMED: Does Claude Code support PreCompact hook? (need to verify) -- UNCONFIRMED: Should ledger-save.sh also index to artifact database? - -## Working Set -- Branch: `main` -- Key files: - - `default/skills/continuity-ledger/SKILL.md` - - `default/hooks/session-start.sh` - - `default/hooks/ledger-save.sh` -- Test cmd: `echo '{}' | default/hooks/ledger-save.sh | jq .` -- Build cmd: N/A (bash scripts) diff --git a/default/templates/CONTINUITY-template.md b/default/templates/CONTINUITY-template.md deleted file mode 100644 index b7aead8d..00000000 --- a/default/templates/CONTINUITY-template.md +++ /dev/null @@ -1,58 +0,0 @@ -# Session: -Updated: - -## Goal - - -## Constraints - -- Constraint 1: ... -- Constraint 2: ... - -## Key Decisions - -- Decision 1: Chose X over Y because... -- Decision 2: ... - -## State -- Done: - - [x] Phase 1: -- Now: [->] Phase 2: -- Next: - - [ ] Phase 3: - - [ ] Phase 4: - -## Open Questions -- UNCONFIRMED: -- UNCONFIRMED: - -## Working Set -- Branch: `` -- Key files: `` -- Test cmd: `` -- Build cmd: `` - ---- - -## Ledger Usage Notes - -**Checkbox States:** -- `[x]` = Completed -- `[->]` = In progress (current) -- `[ ]` = Pending - -**When to Update:** -- After completing a phase (change `[->]` to `[x]`, move `[->]` to next) -- Before running `/clear` -- When context usage > 70% - -**After Clear:** -1. Ledger loads automatically -2. Find `[->]` for current phase -3. Verify UNCONFIRMED items -4. Continue work - -**To create a new ledger:** -```bash -cp .ring/templates/CONTINUITY-template.md .ring/ledgers/CONTINUITY-.md -``` diff --git a/docs/handoffs/codereview-integration/2026-01-14_21-24-35_security-hardening-complete.md b/docs/handoffs/codereview-integration/2026-01-14_21-24-35_security-hardening-complete.md deleted file mode 100644 index 0c430b1f..00000000 --- a/docs/handoffs/codereview-integration/2026-01-14_21-24-35_security-hardening-complete.md +++ /dev/null @@ -1,333 +0,0 @@ ---- -date: 2026-01-14T21:24:35Z -session_name: codereview-integration -git_commit: 186a5a2ddd914c32d2afe385c7b40aa4d10823de -branch: main -repository: ring -topic: "Codereview Pipeline: Unknown Language Handling + Security Hardening" -tags: [security, codereview, pipeline, checksums, TOCTOU, graceful-degradation] -status: complete -outcome: SUCCESS -root_span_id: -turn_span_id: ---- - -# Handoff: Codereview Pipeline Security Hardening Complete - -## Task Summary - -**Objective:** Fix pre-analysis pipeline to handle unknown language gracefully + implement hybrid security model with checksum verification and build-from-source fallback. - -**Status:** Complete - -**What was accomplished:** -1. Fixed pipeline to skip ast/callgraph/dataflow phases gracefully when language is "unknown" (markdown-only commits) -2. Implemented hybrid security model: checksum generation + verification + build-from-source fallback -3. Addressed all critical/high/medium/low security issues found in code review -4. Fixed TOCTOU race condition with atomic verify-and-execute pattern -5. Added macOS compatibility (shasum fallback) -6. Made unverified mode fail-closed by default - -**Architecture implemented:** -``` -Binary Found? ──Yes──> Verify Checksum ──Pass──> Execute - │ │ - No Fail - │ │ - └────> Build from Source <────┘ - │ - ┌─────┴─────┐ - Success Fail - │ │ - Execute Degraded Mode -``` - -## Critical References - -Must read to continue work: -- `scripts/codereview/cmd/run-all/main.go:65-76` - shouldSkipForUnknownLanguage helper -- `scripts/codereview/cmd/run-all/main.go:213` - Skip field added to Phase struct -- `scripts/codereview/cmd/run-all/main.go:581-589` - Skip execution logic -- `scripts/codereview/build-release.sh:347-407` - Checksum generation with macOS support -- `.github/workflows/build-codereview.yml:112-145` - CI checksum verification -- `default/skills/requesting-code-review/SKILL.md:288-359` - secure_execute_binary function - -## Recent Changes - -**Files created:** -- `default/lib/codereview/bin/CHECKSUMS.sha256` - NEW: Root checksums for all platforms -- `default/lib/codereview/bin/darwin_amd64/CHECKSUMS.sha256` - NEW: Platform checksums -- `default/lib/codereview/bin/darwin_arm64/CHECKSUMS.sha256` - NEW: Platform checksums -- `default/lib/codereview/bin/linux_amd64/CHECKSUMS.sha256` - NEW: Platform checksums -- `default/lib/codereview/bin/linux_arm64/CHECKSUMS.sha256` - NEW: Platform checksums - -**Files modified:** -- `scripts/codereview/cmd/run-all/main.go:65-76` - ADDED: shouldSkipForUnknownLanguage helper -- `scripts/codereview/cmd/run-all/main.go:213` - ADDED: Skip field to Phase struct -- `scripts/codereview/cmd/run-all/main.go:234` - ADDED: SkipReason field to PhaseResult -- `scripts/codereview/cmd/run-all/main.go:192-197` - ADDED: unknown-ast.json to detectASTOutputFile -- `scripts/codereview/cmd/run-all/main.go:342,365,387` - ADDED: Skip conditions to phases -- `scripts/codereview/cmd/run-all/main.go:581-589` - ADDED: Skip execution in executePhase -- `scripts/codereview/build-release.sh:347-407` - ADDED: Checksum generation with macOS support -- `.github/workflows/build-codereview.yml:71-145` - MODIFIED: Added checksum validation -- `default/skills/requesting-code-review/SKILL.md:288-400` - REPLACED: verify_binary with secure_execute_binary -- `default/lib/codereview/bin/*` - REBUILT: 28 binaries with all fixes - -## Learnings - -### What Worked - -**1. Graceful degradation for unknown language** -- Approach: Check scope.Language in Skip function, skip ast/callgraph/dataflow for "unknown" -- Why it worked: Allows pipeline to pass on markdown-only commits without failing -- Pattern: Phase-level skip conditions with clear reasons -- Before: FAIL with "exit status 1", 1.85s -- After: SUCCESS with "3 skipped", 156ms - -**2. Atomic verify-and-execute pattern (TOCTOU fix)** -- Approach: Copy binary to secure temp, verify copy, execute copy immediately -- Why it worked: Prevents attacker from swapping binary between verification and execution -- Pattern: mktemp + trap for cleanup -```bash -secure_copy=$(mktemp) -trap "rm -f '$secure_copy'" EXIT -cp "$binary" "$secure_copy" && chmod 700 "$secure_copy" -# Verify $secure_copy -"$secure_copy" "${args[@]}" -``` - -**3. Exact match with awk instead of grep** -- Approach: `awk -v name="$binary_name" '$2 == name {print $1}'` -- Why it worked: Prevents partial string matches (run-all vs run-all-malicious) -- Before: `grep "$binary_name"` matched any substring -- After: Only exact field match - -**4. macOS compatibility with command detection** -- Approach: Check for sha256sum first, fallback to shasum -a 256 -- Why it worked: macOS doesn't have sha256sum by default -- Pattern: -```bash -if command -v sha256sum &> /dev/null; then - CHECKSUM_CMD="sha256sum" -elif command -v shasum &> /dev/null; then - CHECKSUM_CMD="shasum -a 256" -fi -``` - -**5. Fail-closed unverified mode** -- Approach: Default to requiring checksums, only bypass with explicit RING_ALLOW_UNVERIFIED=true -- Why it worked: Security by default, opt-in for development -- Before: Missing checksum file → warning + continue -- After: Missing checksum file → error + fail (unless explicitly bypassed) - -**6. CI checksum integrity verification** -- Approach: Added `sha256sum --check CHECKSUMS.sha256` step in CI -- Why it worked: Validates checksums are correct, not just that files exist -- Before: CI only counted files (28 binaries + 5 checksums) -- After: CI verifies every checksum matches its binary - -### What Failed - -**1. Initial checksum generation included self-reference** -- Tried: `sha256sum * > CHECKSUMS.sha256` without deleting existing file -- Failed because: Existing CHECKSUMS.sha256 was included in the glob -- Fixed by: `rm -f CHECKSUMS.sha256` before regenerating - -**2. Silent error suppression hid checksum generation failures** -- Tried: `sha256sum * > CHECKSUMS.sha256 2>/dev/null` -- Failed because: Errors were hidden, builds succeeded with incomplete checksums -- Fixed by: Removed `2>/dev/null`, added explicit error checking with `if !` - -**3. grep partial match vulnerability** -- Tried: `grep "$binary_name" "$checksum_file"` -- Failed because: Matched partial strings (run-all matched run-all-debug) -- Fixed by: Switched to awk exact match - -### Key Decisions - -**Decision 1: Skip phases instead of failing for unknown language** -- Alternatives: - - Fail pipeline (original behavior) - - Try to force language detection - - Skip entire pre-analysis -- Reason: Allows documentation PRs to pass; context files still generated (empty but valid) - -**Decision 2: Hybrid security model (checksums + build-from-source)** -- Alternatives: - - Checksums only - - Build on every invocation - - GitHub Releases for binaries -- Reason: Balance of security, convenience, and offline capability - -**Decision 3: Atomic verify-and-execute (copy-based approach)** -- Alternatives: - - File locking with flock - - Verify original then execute (TOCTOU vulnerable) - - Always build from source -- Reason: Works on all platforms (Linux + macOS), prevents TOCTOU without complex locking - -**Decision 4: Fail-closed for missing checksums** -- Alternatives: - - Warn and continue (original) - - Always require checksums - - Different behavior for plugin vs dev repo -- Reason: Security by default with explicit opt-out for development scenarios - -**Decision 5: Fix all issues immediately (critical through low)** -- Alternatives: - - Fix only critical - - Fix critical + high, defer medium/low - - Merge as-is, track as tech debt -- Reason: User requested comprehensive fix; low issues were low-effort with high security value - -## Files Modified - -### Created -- `docs/handoffs/codereview-integration/2026-01-14_21-24-35_security-hardening-complete.md` - NEW: This handoff (you are here) -- `default/lib/codereview/bin/CHECKSUMS.sha256` - NEW: Root checksums (28 entries) -- `default/lib/codereview/bin/darwin_amd64/CHECKSUMS.sha256` - NEW: 7 entries -- `default/lib/codereview/bin/darwin_arm64/CHECKSUMS.sha256` - NEW: 7 entries -- `default/lib/codereview/bin/linux_amd64/CHECKSUMS.sha256` - NEW: 7 entries -- `default/lib/codereview/bin/linux_arm64/CHECKSUMS.sha256` - NEW: 7 entries - -### Modified -- `scripts/codereview/cmd/run-all/main.go` - MODIFIED: Skip conditions for unknown language (51 insertions, 13 deletions) -- `scripts/codereview/build-release.sh` - MODIFIED: Checksum generation with macOS support (35 insertions) -- `.github/workflows/build-codereview.yml` - MODIFIED: Checksum validation (60 insertions) -- `default/skills/requesting-code-review/SKILL.md` - MODIFIED: secure_execute_binary (141 insertions) -- `default/lib/codereview/bin/darwin_amd64/*` - REBUILT: 7 binaries -- `default/lib/codereview/bin/darwin_arm64/*` - REBUILT: 7 binaries -- `default/lib/codereview/bin/linux_amd64/*` - REBUILT: 7 binaries -- `default/lib/codereview/bin/linux_arm64/*` - REBUILT: 7 binaries - -## Action Items & Next Steps - -1. **Test end-to-end security flow** - - Test with valid checksums (should pass) - - Test with tampered binary (should fail and fallback to build) - - Test with missing checksums + RING_ALLOW_UNVERIFIED=true (should warn and run) - - Test with missing checksums without flag (should fail) - - Test on macOS to verify shasum fallback works - -2. **Commit security hardening changes** - - Commit 1: run-all.go unknown language handling - - Commit 2: build-release.sh checksum generation - - Commit 3: CI workflow checksum validation - - Commit 4: SKILL.md secure_execute_binary - - Commit 5: Rebuilt binaries with checksums - -3. **Update documentation** - - Add section to README.md about checksum verification - - Document RING_ALLOW_UNVERIFIED flag in MANUAL.md - - Add security model explanation to default/lib/codereview/README.md - -4. **Monitor CI workflow execution** - - Watch first run of updated build-codereview.yml - - Verify checksum validation step passes - - Verify binaries are correctly verified - -5. **Consider future enhancements** - - Add GPG/Sigstore signatures for authenticity (checksums only verify integrity) - - Add SLSA provenance attestation - - Consider separate checksum storage (e.g., GitHub Releases) - - Add binary size sanity checks before verification - -## Other Notes - -### Security Model Summary - -**Before (Original):** -- No checksums -- No verification -- Direct execution of pre-built binaries -- **Risk:** Supply chain attack via compromised binaries - -**After (Commit 352e702 - First Attempt):** -- Checksums generated -- No verification (planned but not implemented) -- **Risk:** Still vulnerable to supply chain attack - -**After (This Session - Complete):** -- Checksums generated with macOS support -- Verification with atomic execute (prevents TOCTOU) -- Build-from-source fallback -- Fail-closed by default -- **Residual Risk:** Checksums verify integrity not authenticity (future: add signatures) - -### Code Review Findings (Addressed) - -| Severity | Issue | Status | -|----------|-------|--------| -| CRITICAL | TOCTOU race condition | ✅ Fixed with secure_execute_binary | -| CRITICAL | Partial string match bypass | ✅ Fixed with awk exact match | -| HIGH | Self-inclusion in checksum | ✅ Fixed with rm -f before generate | -| HIGH | Silent error suppression | ✅ Fixed with explicit error checking | -| HIGH | CI doesn't verify checksums | ✅ Fixed with sha256sum --check | -| HIGH | Unverified mode too permissive | ✅ Fixed with fail-closed default | -| HIGH | macOS incompatibility | ✅ Fixed with shasum fallback | -| MEDIUM | Variable expansion inconsistency | ✅ Fixed with ${VAR:-} pattern | -| LOW | All low issues | ✅ Fixed | - -### Binary Sizes (per platform, with checksums) - -- darwin_amd64: 23.5M (7 binaries + 1 checksum file) -- darwin_arm64: 22.6M (7 binaries + 1 checksum file) -- linux_amd64: 23.5M (7 binaries + 1 checksum file) -- linux_arm64: 23.0M (7 binaries + 1 checksum file) -- **Total: 92.6M** (28 binaries + 5 checksum files) - -### Pipeline Phases (Behavior After Fixes) - -For **markdown-only commits** (language: unknown): -1. Phase 0: scope-detector → PASS (detects 40 markdown files, language: unknown) -2. Phase 1: static-analysis → PASS (0 linters for unknown language) -3. Phase 2: ast → **SKIP** (No supported code files detected) -4. Phase 3: callgraph → **SKIP** (No supported code files detected) -5. Phase 4: dataflow → **SKIP** (No supported code files detected) -6. Phase 5: context → PASS (generates empty context files) - -For **Go/TypeScript commits** (language: go/typescript): -1. All phases run normally -2. Full static analysis, AST, call graph, data flow -3. Rich context files generated - -### Environment Variables Used - -- `${CLAUDE_PLUGIN_ROOT}` - Path to installed plugin (e.g., `~/.claude/plugins/cache/ring/ring-default/0.35.0`) -- `${RING_ALLOW_UNVERIFIED}` - NEW: Set to `true` to bypass checksum verification (not recommended) - -### Useful Commands - -```bash -# Rebuild binaries with checksums (locally) -cd scripts/codereview && ./build-release.sh --clean - -# Test pipeline on markdown commit (should skip gracefully) -./default/lib/codereview/bin/darwin_arm64/run-all \ - --base=352e702^ --head=352e702 --output=.ring/codereview --verbose - -# Verify checksums manually -cd default/lib/codereview/bin/darwin_arm64 -sha256sum --check CHECKSUMS.sha256 # Linux -shasum -a 256 --check CHECKSUMS.sha256 # macOS - -# Test with tampered binary (should fail and fallback) -# 1. Modify a binary: echo "malicious" >> run-all -# 2. Run pipeline → should detect mismatch → build from source - -# Test unverified mode (development) -RING_ALLOW_UNVERIFIED=true - -# Check CI workflow status -gh run list --workflow=build-codereview.yml -``` - -### Next Session Resume - -To resume work on this integration: -1. Read this handoff document -2. Test the security model end-to-end -3. Commit all changes if tests pass -4. Update documentation (README.md, MANUAL.md) -5. Monitor first CI run after push -6. Consider GPG/Sigstore signatures for future enhancement diff --git a/docs/plans/2026-01-13-codereview-phase0-scope-detector.md b/docs/plans/2026-01-13-codereview-phase0-scope-detector.md deleted file mode 100644 index c97616b7..00000000 --- a/docs/plans/2026-01-13-codereview-phase0-scope-detector.md +++ /dev/null @@ -1,2761 +0,0 @@ -# Codereview Phase 0: Scope Detector Implementation Plan - -> **For Agents:** REQUIRED SUB-SKILL: Use ring:executing-plans to implement this plan task-by-task. - -**Goal:** Build the `scope-detector` Go binary that analyzes git diffs to detect changed files, identify project language (Go/TypeScript/Python), and output structured scope information for downstream code review phases. - -**Architecture:** Single Go binary in `scripts/ring:codereview/cmd/scope-detector/` that uses `exec.Command` to run git operations, parses output to categorize files by language/extension, and produces JSON output. Internal packages under `scripts/ring:codereview/internal/` provide reusable git operations, scope detection logic, and output formatting. - -**Tech Stack:** -- Go 1.22+ (stdlib only - no external dependencies) -- Git CLI (via `exec.Command`) -- JSON output format - -**Global Prerequisites:** -- Environment: macOS/Linux with Go 1.22+, Git 2.x+ -- Tools: Go compiler, Git CLI -- Access: None required (local git operations only) -- State: Clean working tree on feature branch - -**Verification before starting:** -```bash -# Run ALL these commands and verify output: -go version # Expected: go version go1.22+ (any 1.22.x or higher) -git --version # Expected: git version 2.x.x -ls -la scripts/ # Expected: directory does not exist (we'll create it) -``` - -## Historical Precedent - -**Query:** "ring:codereview scope detection Go CLI git diff" -**Index Status:** Populated (no relevant matches) - -### Successful Patterns to Reference -No directly relevant handoffs found. This is a new feature area. - -### Failure Patterns to AVOID -No failure patterns recorded for this domain. - -### Related Past Plans -- `ring:codereview-enhancement-macro-plan.md` - Parent macro plan defining overall architecture - ---- - -## File Structure Overview - -``` -scripts/ -└── ring:codereview/ - ├── cmd/ - │ └── scope-detector/ - │ └── main.go # CLI binary entry point - ├── internal/ - │ ├── git/ - │ │ └── git.go # Git operations wrapper - │ │ └── git_test.go # Git unit tests - │ ├── scope/ - │ │ └── scope.go # Scope detection logic - │ │ └── scope_test.go # Scope unit tests - │ └── output/ - │ └── json.go # JSON output formatter - │ └── json_test.go # JSON formatter tests - ├── go.mod # Go module definition - ├── go.sum # (empty initially - no deps) - └── Makefile # Build targets -``` - ---- - -## Task 1: Create Go Module and Directory Structure - -**Files:** -- Create: `scripts/ring:codereview/go.mod` -- Create: `scripts/ring:codereview/Makefile` - -**Prerequisites:** -- Tools: Go 1.22+ -- Current directory: Repository root `/Users/fredamaral/repos/lerianstudio/ring` - -**Step 1: Create directory structure** - -```bash -mkdir -p scripts/ring:codereview/cmd/scope-detector -mkdir -p scripts/ring:codereview/internal/git -mkdir -p scripts/ring:codereview/internal/scope -mkdir -p scripts/ring:codereview/internal/output -mkdir -p scripts/ring:codereview/bin -``` - -**Step 2: Create go.mod** - -Create file `scripts/ring:codereview/go.mod`: - -```go -module github.com/lerianstudio/ring/scripts/ring:codereview - -go 1.22 -``` - -**Step 3: Create Makefile** - -Create file `scripts/ring:codereview/Makefile`: - -```makefile -.PHONY: all build test clean install - -# Binary output directory -BIN_DIR := bin - -# All binaries to build -BINARIES := scope-detector - -all: build - -build: $(BINARIES) - -scope-detector: - @echo "Building scope-detector..." - @go build -o $(BIN_DIR)/scope-detector ./cmd/scope-detector - -test: - @echo "Running tests..." - @go test -v -race ./... - -test-coverage: - @echo "Running tests with coverage..." - @go test -v -race -coverprofile=coverage.out ./... - @go tool cover -html=coverage.out -o coverage.html - @echo "Coverage report: coverage.html" - -clean: - @echo "Cleaning..." - @rm -rf $(BIN_DIR) - @rm -f coverage.out coverage.html - -install: build - @echo "Installing binaries to $(BIN_DIR)..." - @chmod +x $(BIN_DIR)/* - -# Development helpers -fmt: - @go fmt ./... - -vet: - @go vet ./... - -lint: fmt vet -``` - -**Step 4: Verify structure** - -Run: `ls -la scripts/ring:codereview/` - -**Expected output:** -``` -total 16 -drwxr-xr-x 7 user staff 224 Jan 13 XX:XX . -drwxr-xr-x 3 user staff 96 Jan 13 XX:XX .. --rw-r--r-- 1 user staff XX Jan 13 XX:XX Makefile -drwxr-xr-x 2 user staff 64 Jan 13 XX:XX bin -drwxr-xr-x 3 user staff 96 Jan 13 XX:XX cmd --rw-r--r-- 1 user staff XX Jan 13 XX:XX go.mod -drwxr-xr-x 5 user staff 160 Jan 13 XX:XX internal -``` - -**Step 5: Verify go.mod is valid** - -Run: `cd scripts/ring:codereview && go mod verify && cd ../..` - -**Expected output:** -``` -all modules verified -``` - -**If Task Fails:** - -1. **Directory creation fails:** - - Check: `ls -la scripts/` (parent exists?) - - Fix: Create parent first: `mkdir -p scripts` - - Rollback: `rm -rf scripts/ring:codereview` - -2. **go mod verify fails:** - - Check: `cat scripts/ring:codereview/go.mod` (syntax correct?) - - Fix: Ensure go directive matches installed Go version - - Rollback: `rm scripts/ring:codereview/go.mod` - ---- - -## Task 2: Implement Git Operations Package - Types and Interface - -**Files:** -- Create: `scripts/ring:codereview/internal/git/git.go` - -**Prerequisites:** -- Task 1 completed (directory structure exists) -- Tools: Go 1.22+ - -**Step 1: Write the failing test** - -Create file `scripts/ring:codereview/internal/git/git_test.go`: - -```go -package git - -import ( - "testing" -) - -func TestFileStatusString(t *testing.T) { - tests := []struct { - name string - status FileStatus - expected string - }{ - {"Added", StatusAdded, "A"}, - {"Modified", StatusModified, "M"}, - {"Deleted", StatusDeleted, "D"}, - {"Renamed", StatusRenamed, "R"}, - {"Copied", StatusCopied, "C"}, - {"Unknown", StatusUnknown, "?"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.status.String(); got != tt.expected { - t.Errorf("FileStatus.String() = %q, want %q", got, tt.expected) - } - }) - } -} - -func TestParseFileStatus(t *testing.T) { - tests := []struct { - name string - input string - expected FileStatus - }{ - {"Added", "A", StatusAdded}, - {"Modified", "M", StatusModified}, - {"Deleted", "D", StatusDeleted}, - {"Renamed", "R100", StatusRenamed}, - {"Renamed partial", "R075", StatusRenamed}, - {"Copied", "C", StatusCopied}, - {"Unknown", "X", StatusUnknown}, - {"Empty", "", StatusUnknown}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ParseFileStatus(tt.input); got != tt.expected { - t.Errorf("ParseFileStatus(%q) = %v, want %v", tt.input, got, tt.expected) - } - }) - } -} - -func TestChangedFileValidation(t *testing.T) { - // Test that ChangedFile struct can be created with all fields - cf := ChangedFile{ - Path: "internal/handler/user.go", - Status: StatusModified, - OldPath: "", - Additions: 10, - Deletions: 5, - } - - if cf.Path != "internal/handler/user.go" { - t.Errorf("ChangedFile.Path = %q, want %q", cf.Path, "internal/handler/user.go") - } - if cf.Status != StatusModified { - t.Errorf("ChangedFile.Status = %v, want %v", cf.Status, StatusModified) - } -} - -func TestDiffStatsValidation(t *testing.T) { - // Test that DiffStats struct can be created with all fields - stats := DiffStats{ - TotalFiles: 3, - TotalAdditions: 100, - TotalDeletions: 25, - } - - if stats.TotalFiles != 3 { - t.Errorf("DiffStats.TotalFiles = %d, want %d", stats.TotalFiles, 3) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd scripts/ring:codereview && go test -v ./internal/git/... 2>&1 | head -20 && cd ../..` - -**Expected output:** -``` -# github.com/lerianstudio/ring/scripts/ring:codereview/internal/git [github.com/lerianstudio/ring/scripts/ring:codereview/internal/git.test] -./git_test.go:XX:XX: undefined: FileStatus -./git_test.go:XX:XX: undefined: StatusAdded -... -FAIL github.com/lerianstudio/ring/scripts/ring:codereview/internal/git [build failed] -``` - -**If you see different error:** Check that git_test.go was created in the correct location - -**Step 3: Write minimal implementation** - -Create file `scripts/ring:codereview/internal/git/git.go`: - -```go -// Package git provides utilities for interacting with git repositories. -// It wraps git CLI commands using exec.Command (no external dependencies). -package git - -import ( - "bufio" - "bytes" - "fmt" - "os/exec" - "strconv" - "strings" -) - -// FileStatus represents the status of a file in git diff output. -type FileStatus int - -const ( - StatusUnknown FileStatus = iota - StatusAdded - StatusModified - StatusDeleted - StatusRenamed - StatusCopied -) - -// String returns the single-character git status code. -func (s FileStatus) String() string { - switch s { - case StatusAdded: - return "A" - case StatusModified: - return "M" - case StatusDeleted: - return "D" - case StatusRenamed: - return "R" - case StatusCopied: - return "C" - default: - return "?" - } -} - -// ParseFileStatus converts a git status string to FileStatus. -// Handles both single-char ("M") and similarity-prefixed ("R100") formats. -func ParseFileStatus(s string) FileStatus { - if len(s) == 0 { - return StatusUnknown - } - switch s[0] { - case 'A': - return StatusAdded - case 'M': - return StatusModified - case 'D': - return StatusDeleted - case 'R': - return StatusRenamed - case 'C': - return StatusCopied - default: - return StatusUnknown - } -} - -// ChangedFile represents a single file change in a git diff. -type ChangedFile struct { - Path string // Current path of the file - Status FileStatus // Type of change (A/M/D/R/C) - OldPath string // Previous path (for renames/copies) - Additions int // Lines added - Deletions int // Lines deleted -} - -// DiffStats contains aggregate statistics for a diff. -type DiffStats struct { - TotalFiles int - TotalAdditions int - TotalDeletions int -} - -// DiffResult contains the complete result of a git diff operation. -type DiffResult struct { - BaseRef string // Base reference (e.g., "main", commit SHA) - HeadRef string // Head reference (e.g., "HEAD", commit SHA) - Files []ChangedFile // List of changed files - Stats DiffStats // Aggregate statistics -} - -// Client provides methods for interacting with git. -type Client struct { - workDir string // Working directory for git commands -} - -// NewClient creates a new git client for the specified directory. -// If workDir is empty, commands run in the current directory. -func NewClient(workDir string) *Client { - return &Client{workDir: workDir} -} - -// runGit executes a git command and returns stdout. -func (c *Client) runGit(args ...string) ([]byte, error) { - cmd := exec.Command("git", args...) - if c.workDir != "" { - cmd.Dir = c.workDir - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("git %s failed: %w\nstderr: %s", - strings.Join(args, " "), err, stderr.String()) - } - - return stdout.Bytes(), nil -} - -// GetDiff returns the diff between two refs, or working tree changes if refs are empty. -// baseRef: starting point (e.g., "main", "abc123"). Empty = use index. -// headRef: ending point (e.g., "HEAD", "def456"). Empty = working tree. -func (c *Client) GetDiff(baseRef, headRef string) (*DiffResult, error) { - result := &DiffResult{ - BaseRef: baseRef, - HeadRef: headRef, - Files: make([]ChangedFile, 0), - } - - // Build git diff command based on refs provided - args := []string{"diff", "--name-status"} - - switch { - case baseRef == "" && headRef == "": - // Staged + unstaged changes (compare index to working tree) - args = append(args, "HEAD") - case baseRef != "" && headRef == "": - // Compare base to working tree - args = append(args, baseRef) - case baseRef == "" && headRef != "": - // Compare HEAD to specific ref - args = append(args, "HEAD", headRef) - default: - // Compare two specific refs - args = append(args, baseRef, headRef) - } - - // Get file status list - output, err := c.runGit(args...) - if err != nil { - return nil, fmt.Errorf("failed to get diff name-status: %w", err) - } - - // Parse name-status output - scanner := bufio.NewScanner(bytes.NewReader(output)) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - - cf, err := parseNameStatusLine(line) - if err != nil { - continue // Skip unparseable lines - } - result.Files = append(result.Files, cf) - } - - // Get diff statistics - statsArgs := []string{"diff", "--numstat"} - if baseRef == "" && headRef == "" { - statsArgs = append(statsArgs, "HEAD") - } else if baseRef != "" && headRef == "" { - statsArgs = append(statsArgs, baseRef) - } else if baseRef == "" && headRef != "" { - statsArgs = append(statsArgs, "HEAD", headRef) - } else { - statsArgs = append(statsArgs, baseRef, headRef) - } - - statsOutput, err := c.runGit(statsArgs...) - if err != nil { - // Non-fatal: we can continue without stats - result.Stats.TotalFiles = len(result.Files) - return result, nil - } - - // Parse numstat output and update file stats - statsMap := parseNumstat(statsOutput) - for i, f := range result.Files { - if stats, ok := statsMap[f.Path]; ok { - result.Files[i].Additions = stats.additions - result.Files[i].Deletions = stats.deletions - result.Stats.TotalAdditions += stats.additions - result.Stats.TotalDeletions += stats.deletions - } - } - result.Stats.TotalFiles = len(result.Files) - - return result, nil -} - -// GetStagedDiff returns only staged changes (index vs HEAD). -func (c *Client) GetStagedDiff() (*DiffResult, error) { - result := &DiffResult{ - BaseRef: "HEAD", - HeadRef: "staged", - Files: make([]ChangedFile, 0), - } - - // Get staged file status - output, err := c.runGit("diff", "--name-status", "--cached") - if err != nil { - return nil, fmt.Errorf("failed to get staged diff: %w", err) - } - - scanner := bufio.NewScanner(bytes.NewReader(output)) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - cf, err := parseNameStatusLine(line) - if err != nil { - continue - } - result.Files = append(result.Files, cf) - } - - // Get staged stats - statsOutput, err := c.runGit("diff", "--numstat", "--cached") - if err == nil { - statsMap := parseNumstat(statsOutput) - for i, f := range result.Files { - if stats, ok := statsMap[f.Path]; ok { - result.Files[i].Additions = stats.additions - result.Files[i].Deletions = stats.deletions - result.Stats.TotalAdditions += stats.additions - result.Stats.TotalDeletions += stats.deletions - } - } - } - result.Stats.TotalFiles = len(result.Files) - - return result, nil -} - -// GetWorkingTreeDiff returns only unstaged changes (working tree vs index). -func (c *Client) GetWorkingTreeDiff() (*DiffResult, error) { - result := &DiffResult{ - BaseRef: "index", - HeadRef: "working-tree", - Files: make([]ChangedFile, 0), - } - - // Get unstaged file status (no --cached flag) - output, err := c.runGit("diff", "--name-status") - if err != nil { - return nil, fmt.Errorf("failed to get working tree diff: %w", err) - } - - scanner := bufio.NewScanner(bytes.NewReader(output)) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - cf, err := parseNameStatusLine(line) - if err != nil { - continue - } - result.Files = append(result.Files, cf) - } - - // Get unstaged stats - statsOutput, err := c.runGit("diff", "--numstat") - if err == nil { - statsMap := parseNumstat(statsOutput) - for i, f := range result.Files { - if stats, ok := statsMap[f.Path]; ok { - result.Files[i].Additions = stats.additions - result.Files[i].Deletions = stats.deletions - result.Stats.TotalAdditions += stats.additions - result.Stats.TotalDeletions += stats.deletions - } - } - } - result.Stats.TotalFiles = len(result.Files) - - return result, nil -} - -// GetAllChangesDiff returns both staged and unstaged changes combined. -func (c *Client) GetAllChangesDiff() (*DiffResult, error) { - staged, err := c.GetStagedDiff() - if err != nil { - return nil, fmt.Errorf("failed to get staged changes: %w", err) - } - - unstaged, err := c.GetWorkingTreeDiff() - if err != nil { - return nil, fmt.Errorf("failed to get unstaged changes: %w", err) - } - - // Merge results, deduplicating by path (staged takes precedence) - result := &DiffResult{ - BaseRef: "HEAD", - HeadRef: "working-tree", - Files: make([]ChangedFile, 0), - } - - seenPaths := make(map[string]bool) - - // Add staged files first - for _, f := range staged.Files { - result.Files = append(result.Files, f) - seenPaths[f.Path] = true - result.Stats.TotalAdditions += f.Additions - result.Stats.TotalDeletions += f.Deletions - } - - // Add unstaged files not already in staged - for _, f := range unstaged.Files { - if !seenPaths[f.Path] { - result.Files = append(result.Files, f) - seenPaths[f.Path] = true - result.Stats.TotalAdditions += f.Additions - result.Stats.TotalDeletions += f.Deletions - } - } - - result.Stats.TotalFiles = len(result.Files) - return result, nil -} - -// parseNameStatusLine parses a single line of git diff --name-status output. -// Format: "M\tpath/to/file" or "R100\told/path\tnew/path" -func parseNameStatusLine(line string) (ChangedFile, error) { - parts := strings.Split(line, "\t") - if len(parts) < 2 { - return ChangedFile{}, fmt.Errorf("invalid name-status line: %s", line) - } - - status := ParseFileStatus(parts[0]) - cf := ChangedFile{ - Status: status, - } - - // Handle renames/copies (have old and new paths) - if status == StatusRenamed || status == StatusCopied { - if len(parts) < 3 { - return ChangedFile{}, fmt.Errorf("invalid rename/copy line: %s", line) - } - cf.OldPath = parts[1] - cf.Path = parts[2] - } else { - cf.Path = parts[1] - } - - return cf, nil -} - -// fileStats holds parsed addition/deletion counts for a file. -type fileStats struct { - additions int - deletions int -} - -// parseNumstat parses git diff --numstat output. -// Format: "10\t5\tpath/to/file" (additions, deletions, path) -// Binary files show as "-\t-\tpath" -func parseNumstat(output []byte) map[string]fileStats { - result := make(map[string]fileStats) - scanner := bufio.NewScanner(bytes.NewReader(output)) - - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - - parts := strings.Split(line, "\t") - if len(parts) < 3 { - continue - } - - // Skip binary files (marked with "-") - if parts[0] == "-" || parts[1] == "-" { - continue - } - - additions, err := strconv.Atoi(parts[0]) - if err != nil { - continue - } - deletions, err := strconv.Atoi(parts[1]) - if err != nil { - continue - } - - // Handle paths with spaces (rejoin remaining parts) - path := strings.Join(parts[2:], "\t") - result[path] = fileStats{ - additions: additions, - deletions: deletions, - } - } - - return result -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd scripts/ring:codereview && go test -v ./internal/git/... && cd ../..` - -**Expected output:** -``` -=== RUN TestFileStatusString -=== RUN TestFileStatusString/Added -=== RUN TestFileStatusString/Modified -=== RUN TestFileStatusString/Deleted -=== RUN TestFileStatusString/Renamed -=== RUN TestFileStatusString/Copied -=== RUN TestFileStatusString/Unknown ---- PASS: TestFileStatusString (0.00s) - --- PASS: TestFileStatusString/Added (0.00s) - --- PASS: TestFileStatusString/Modified (0.00s) - --- PASS: TestFileStatusString/Deleted (0.00s) - --- PASS: TestFileStatusString/Renamed (0.00s) - --- PASS: TestFileStatusString/Copied (0.00s) - --- PASS: TestFileStatusString/Unknown (0.00s) -=== RUN TestParseFileStatus -... -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/git -``` - -**Step 5: Commit** - -```bash -git add scripts/ring:codereview/ -git commit -m "feat(ring:codereview): add git operations package with types and diff parsing - -Phase 0 of ring:codereview enhancement - foundational git wrapper. -Includes FileStatus enum, ChangedFile struct, and Client with -GetDiff, GetStagedDiff, GetWorkingTreeDiff, GetAllChangesDiff methods." -``` - -**If Task Fails:** - -1. **Test still fails after implementation:** - - Check: `go build ./internal/git/` (syntax errors?) - - Fix: Review error messages and fix type definitions - - Rollback: `git checkout -- scripts/ring:codereview/internal/git/` - -2. **Import errors:** - - Check: Package name matches directory - - Fix: Ensure `package git` at top of both files - ---- - -## Task 3: Add Integration Tests for Git Package - -**Files:** -- Modify: `scripts/ring:codereview/internal/git/git_test.go` - -**Prerequisites:** -- Task 2 completed (git package exists) -- Must be in a git repository (ring repo itself) - -**Step 1: Add integration tests to existing test file** - -Append to `scripts/ring:codereview/internal/git/git_test.go`: - -```go -// Integration tests - these run against the actual git repository - -func TestClientGetDiff_Integration(t *testing.T) { - // Skip if not in a git repo - if _, err := exec.Command("git", "rev-parse", "--git-dir").Output(); err != nil { - t.Skip("Not in a git repository") - } - - client := NewClient("") - - // Test getting diff between two known commits - // This tests against the ring repo itself - result, err := client.GetDiff("HEAD~1", "HEAD") - if err != nil { - // This might fail if HEAD~1 doesn't exist (fresh repo) - t.Skipf("Could not get diff HEAD~1..HEAD: %v", err) - } - - // Basic validation - if result.BaseRef != "HEAD~1" { - t.Errorf("BaseRef = %q, want %q", result.BaseRef, "HEAD~1") - } - if result.HeadRef != "HEAD" { - t.Errorf("HeadRef = %q, want %q", result.HeadRef, "HEAD") - } -} - -func TestClientGetStagedDiff_Integration(t *testing.T) { - if _, err := exec.Command("git", "rev-parse", "--git-dir").Output(); err != nil { - t.Skip("Not in a git repository") - } - - client := NewClient("") - result, err := client.GetStagedDiff() - if err != nil { - t.Fatalf("GetStagedDiff() error = %v", err) - } - - // Should return a valid result (even if empty) - if result.BaseRef != "HEAD" { - t.Errorf("BaseRef = %q, want %q", result.BaseRef, "HEAD") - } - if result.HeadRef != "staged" { - t.Errorf("HeadRef = %q, want %q", result.HeadRef, "staged") - } - if result.Files == nil { - t.Error("Files should not be nil") - } -} - -func TestClientGetWorkingTreeDiff_Integration(t *testing.T) { - if _, err := exec.Command("git", "rev-parse", "--git-dir").Output(); err != nil { - t.Skip("Not in a git repository") - } - - client := NewClient("") - result, err := client.GetWorkingTreeDiff() - if err != nil { - t.Fatalf("GetWorkingTreeDiff() error = %v", err) - } - - // Should return a valid result (even if empty) - if result.BaseRef != "index" { - t.Errorf("BaseRef = %q, want %q", result.BaseRef, "index") - } - if result.HeadRef != "working-tree" { - t.Errorf("HeadRef = %q, want %q", result.HeadRef, "working-tree") - } -} - -func TestClientGetAllChangesDiff_Integration(t *testing.T) { - if _, err := exec.Command("git", "rev-parse", "--git-dir").Output(); err != nil { - t.Skip("Not in a git repository") - } - - client := NewClient("") - result, err := client.GetAllChangesDiff() - if err != nil { - t.Fatalf("GetAllChangesDiff() error = %v", err) - } - - // Should return a valid result - if result.BaseRef != "HEAD" { - t.Errorf("BaseRef = %q, want %q", result.BaseRef, "HEAD") - } - if result.HeadRef != "working-tree" { - t.Errorf("HeadRef = %q, want %q", result.HeadRef, "working-tree") - } - if result.Stats.TotalFiles < 0 { - t.Error("TotalFiles should not be negative") - } -} - -func TestParseNameStatusLine(t *testing.T) { - tests := []struct { - name string - line string - expected ChangedFile - wantErr bool - }{ - { - name: "Modified file", - line: "M\tinternal/handler/user.go", - expected: ChangedFile{ - Path: "internal/handler/user.go", - Status: StatusModified, - }, - }, - { - name: "Added file", - line: "A\tnew/file.go", - expected: ChangedFile{ - Path: "new/file.go", - Status: StatusAdded, - }, - }, - { - name: "Deleted file", - line: "D\told/file.go", - expected: ChangedFile{ - Path: "old/file.go", - Status: StatusDeleted, - }, - }, - { - name: "Renamed file", - line: "R100\told/path.go\tnew/path.go", - expected: ChangedFile{ - Path: "new/path.go", - OldPath: "old/path.go", - Status: StatusRenamed, - }, - }, - { - name: "Copied file", - line: "C100\toriginal.go\tcopy.go", - expected: ChangedFile{ - Path: "copy.go", - OldPath: "original.go", - Status: StatusCopied, - }, - }, - { - name: "Invalid line - no tab", - line: "M internal/handler/user.go", - wantErr: true, - }, - { - name: "Invalid line - empty", - line: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseNameStatusLine(tt.line) - if (err != nil) != tt.wantErr { - t.Errorf("parseNameStatusLine() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr { - return - } - if got.Path != tt.expected.Path { - t.Errorf("Path = %q, want %q", got.Path, tt.expected.Path) - } - if got.OldPath != tt.expected.OldPath { - t.Errorf("OldPath = %q, want %q", got.OldPath, tt.expected.OldPath) - } - if got.Status != tt.expected.Status { - t.Errorf("Status = %v, want %v", got.Status, tt.expected.Status) - } - }) - } -} - -func TestParseNumstat(t *testing.T) { - tests := []struct { - name string - input string - expected map[string]fileStats - }{ - { - name: "Single file", - input: "10\t5\tpath/to/file.go", - expected: map[string]fileStats{ - "path/to/file.go": {additions: 10, deletions: 5}, - }, - }, - { - name: "Multiple files", - input: "10\t5\tfile1.go\n20\t3\tfile2.go", - expected: map[string]fileStats{ - "file1.go": {additions: 10, deletions: 5}, - "file2.go": {additions: 20, deletions: 3}, - }, - }, - { - name: "Binary file (skip)", - input: "-\t-\timage.png", - expected: map[string]fileStats{}, - }, - { - name: "Empty input", - input: "", - expected: map[string]fileStats{}, - }, - { - name: "File with spaces", - input: "10\t5\tpath/to/file with spaces.go", - expected: map[string]fileStats{ - "path/to/file with spaces.go": {additions: 10, deletions: 5}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := parseNumstat([]byte(tt.input)) - if len(got) != len(tt.expected) { - t.Errorf("parseNumstat() returned %d entries, want %d", len(got), len(tt.expected)) - } - for path, expectedStats := range tt.expected { - if gotStats, ok := got[path]; !ok { - t.Errorf("parseNumstat() missing path %q", path) - } else if gotStats != expectedStats { - t.Errorf("parseNumstat()[%q] = %+v, want %+v", path, gotStats, expectedStats) - } - } - }) - } -} -``` - -**Step 2: Run all tests** - -Run: `cd scripts/ring:codereview && go test -v ./internal/git/... && cd ../..` - -**Expected output:** -``` -=== RUN TestFileStatusString ---- PASS: TestFileStatusString (0.00s) -... -=== RUN TestClientGetDiff_Integration ---- PASS: TestClientGetDiff_Integration (0.XX s) -=== RUN TestClientGetStagedDiff_Integration ---- PASS: TestClientGetStagedDiff_Integration (0.XX s) -... -=== RUN TestParseNameStatusLine ---- PASS: TestParseNameStatusLine (0.00s) -=== RUN TestParseNumstat ---- PASS: TestParseNumstat (0.00s) -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/git -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/git/git_test.go -git commit -m "test(ring:codereview): add integration and unit tests for git package - -Covers parseNameStatusLine, parseNumstat, and integration tests -for Client.GetDiff, GetStagedDiff, GetWorkingTreeDiff, GetAllChangesDiff." -``` - -**If Task Fails:** - -1. **Integration tests fail:** - - Check: Are you in the ring repository? - - Fix: Tests should skip gracefully with `t.Skip()` if not in git repo - - Rollback: Remove integration test functions - ---- - -## Task 4: Implement Scope Detection Package - Language Detection - -**Files:** -- Create: `scripts/ring:codereview/internal/scope/scope.go` -- Create: `scripts/ring:codereview/internal/scope/scope_test.go` - -**Prerequisites:** -- Task 2 completed (git package exists) -- Tools: Go 1.22+ - -**Step 1: Write the failing test** - -Create file `scripts/ring:codereview/internal/scope/scope_test.go`: - -```go -package scope - -import ( - "testing" - - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/git" -) - -func TestDetectLanguage(t *testing.T) { - tests := []struct { - name string - files []string - expected Language - wantErr bool - }{ - { - name: "Go only", - files: []string{"main.go", "internal/handler.go", "pkg/utils.go"}, - expected: LanguageGo, - }, - { - name: "TypeScript only", - files: []string{"src/index.ts", "src/App.tsx", "components/Button.tsx"}, - expected: LanguageTypeScript, - }, - { - name: "Python only", - files: []string{"main.py", "app/handlers.py", "tests/test_main.py"}, - expected: LanguagePython, - }, - { - name: "Mixed languages - error", - files: []string{"main.go", "app.ts"}, - expected: LanguageUnknown, - wantErr: true, - }, - { - name: "No recognized files", - files: []string{"README.md", "config.yaml", ".gitignore"}, - expected: LanguageUnknown, - }, - { - name: "Go with non-code files", - files: []string{"main.go", "README.md", "go.mod"}, - expected: LanguageGo, - }, - { - name: "Empty file list", - files: []string{}, - expected: LanguageUnknown, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := DetectLanguage(tt.files) - if (err != nil) != tt.wantErr { - t.Errorf("DetectLanguage() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.expected { - t.Errorf("DetectLanguage() = %v, want %v", got, tt.expected) - } - }) - } -} - -func TestLanguageString(t *testing.T) { - tests := []struct { - lang Language - expected string - }{ - {LanguageGo, "go"}, - {LanguageTypeScript, "typescript"}, - {LanguagePython, "python"}, - {LanguageUnknown, "unknown"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - if got := tt.lang.String(); got != tt.expected { - t.Errorf("Language.String() = %q, want %q", got, tt.expected) - } - }) - } -} - -func TestGetFileExtension(t *testing.T) { - tests := []struct { - path string - expected string - }{ - {"main.go", ".go"}, - {"src/App.tsx", ".tsx"}, - {"internal/handler/user.go", ".go"}, - {"noextension", ""}, - {".gitignore", ".gitignore"}, - {"file.test.ts", ".ts"}, - } - - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - if got := getFileExtension(tt.path); got != tt.expected { - t.Errorf("getFileExtension(%q) = %q, want %q", tt.path, got, tt.expected) - } - }) - } -} - -func TestCategorizeFilesByStatus(t *testing.T) { - files := []git.ChangedFile{ - {Path: "handler.go", Status: git.StatusModified}, - {Path: "new_file.go", Status: git.StatusAdded}, - {Path: "old_file.go", Status: git.StatusDeleted}, - {Path: "renamed.go", Status: git.StatusRenamed, OldPath: "old_name.go"}, - } - - modified, added, deleted := CategorizeFilesByStatus(files) - - if len(modified) != 1 || modified[0] != "handler.go" { - t.Errorf("Modified files = %v, want [handler.go]", modified) - } - if len(added) != 1 || added[0] != "new_file.go" { - t.Errorf("Added files = %v, want [new_file.go]", added) - } - if len(deleted) != 1 || deleted[0] != "old_file.go" { - t.Errorf("Deleted files = %v, want [old_file.go]", deleted) - } -} - -func TestExtractPackages(t *testing.T) { - tests := []struct { - name string - lang Language - files []string - expected []string - }{ - { - name: "Go packages", - lang: LanguageGo, - files: []string{"internal/handler/user.go", "internal/handler/admin.go", "pkg/utils/string.go"}, - expected: []string{"internal/handler", "pkg/utils"}, - }, - { - name: "TypeScript directories", - lang: LanguageTypeScript, - files: []string{"src/components/Button.tsx", "src/components/Form.tsx", "src/utils/helpers.ts"}, - expected: []string{"src/components", "src/utils"}, - }, - { - name: "Python modules", - lang: LanguagePython, - files: []string{"app/handlers/user.py", "app/handlers/admin.py", "app/services/auth.py"}, - expected: []string{"app/handlers", "app/services"}, - }, - { - name: "Root level files", - lang: LanguageGo, - files: []string{"main.go", "config.go"}, - expected: []string{"."}, - }, - { - name: "Empty file list", - lang: LanguageGo, - files: []string{}, - expected: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ExtractPackages(tt.lang, tt.files) - if len(got) != len(tt.expected) { - t.Errorf("ExtractPackages() = %v, want %v", got, tt.expected) - return - } - // Check that all expected packages are present - gotMap := make(map[string]bool) - for _, p := range got { - gotMap[p] = true - } - for _, exp := range tt.expected { - if !gotMap[exp] { - t.Errorf("ExtractPackages() missing expected package %q, got %v", exp, got) - } - } - }) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd scripts/ring:codereview && go test -v ./internal/scope/... 2>&1 | head -20 && cd ../..` - -**Expected output:** -``` -# github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope [github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope.test] -./scope_test.go:XX:XX: undefined: Language -./scope_test.go:XX:XX: undefined: LanguageGo -... -FAIL github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope [build failed] -``` - -**Step 3: Write minimal implementation** - -Create file `scripts/ring:codereview/internal/scope/scope.go`: - -```go -// Package scope provides scope detection for code review analysis. -// It identifies changed files, detects project language, and extracts -// package/module information from git diffs. -package scope - -import ( - "errors" - "path/filepath" - "sort" - "strings" - - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/git" -) - -// Language represents a supported programming language. -type Language int - -const ( - LanguageUnknown Language = iota - LanguageGo - LanguageTypeScript - LanguagePython -) - -// String returns the string representation of the language. -func (l Language) String() string { - switch l { - case LanguageGo: - return "go" - case LanguageTypeScript: - return "typescript" - case LanguagePython: - return "python" - default: - return "unknown" - } -} - -// languageExtensions maps file extensions to languages. -var languageExtensions = map[string]Language{ - ".go": LanguageGo, - ".ts": LanguageTypeScript, - ".tsx": LanguageTypeScript, - ".py": LanguagePython, -} - -// ErrMixedLanguages is returned when multiple languages are detected. -var ErrMixedLanguages = errors.New("mixed languages detected: project must be single-language (Go, TypeScript, or Python)") - -// DetectLanguage determines the primary language from a list of file paths. -// Returns ErrMixedLanguages if multiple code languages are found. -func DetectLanguage(files []string) (Language, error) { - if len(files) == 0 { - return LanguageUnknown, nil - } - - languagesSeen := make(map[Language]bool) - - for _, file := range files { - ext := getFileExtension(file) - if lang, ok := languageExtensions[ext]; ok { - languagesSeen[lang] = true - } - } - - // No recognized code files - if len(languagesSeen) == 0 { - return LanguageUnknown, nil - } - - // Multiple languages detected - if len(languagesSeen) > 1 { - return LanguageUnknown, ErrMixedLanguages - } - - // Return the single detected language - for lang := range languagesSeen { - return lang, nil - } - - return LanguageUnknown, nil -} - -// getFileExtension returns the file extension including the dot. -// For files like "file.test.ts", returns ".ts" (last extension). -func getFileExtension(path string) string { - base := filepath.Base(path) - ext := filepath.Ext(base) - return ext -} - -// CategorizeFilesByStatus separates files into modified, added, and deleted lists. -// Renamed files are treated as additions of the new path. -func CategorizeFilesByStatus(files []git.ChangedFile) (modified, added, deleted []string) { - for _, f := range files { - switch f.Status { - case git.StatusModified: - modified = append(modified, f.Path) - case git.StatusAdded, git.StatusRenamed, git.StatusCopied: - added = append(added, f.Path) - case git.StatusDeleted: - deleted = append(deleted, f.Path) - } - } - return -} - -// ExtractPackages extracts unique package/directory paths from file paths. -// For Go, this is the directory containing the file. -// For TypeScript/Python, this is also the parent directory. -func ExtractPackages(lang Language, files []string) []string { - if len(files) == 0 { - return []string{} - } - - packageSet := make(map[string]bool) - - for _, file := range files { - dir := filepath.Dir(file) - // Normalize empty dir to "." for root-level files - if dir == "" { - dir = "." - } - packageSet[dir] = true - } - - // Convert set to sorted slice - packages := make([]string, 0, len(packageSet)) - for pkg := range packageSet { - packages = append(packages, pkg) - } - sort.Strings(packages) - - return packages -} - -// ScopeResult contains the complete scope detection result. -type ScopeResult struct { - BaseRef string `json:"base_ref"` - HeadRef string `json:"head_ref"` - Language string `json:"language"` - ModifiedFiles []string `json:"modified"` - AddedFiles []string `json:"added"` - DeletedFiles []string `json:"deleted"` - TotalFiles int `json:"total_files"` - TotalAdditions int `json:"total_additions"` - TotalDeletions int `json:"total_deletions"` - PackagesAffected []string `json:"packages_affected"` -} - -// Detector performs scope detection on git diffs. -type Detector struct { - gitClient *git.Client -} - -// NewDetector creates a new scope detector for the given working directory. -func NewDetector(workDir string) *Detector { - return &Detector{ - gitClient: git.NewClient(workDir), - } -} - -// DetectFromRefs detects scope from a diff between two git refs. -// If baseRef is empty, uses HEAD. If headRef is empty, uses working tree. -func (d *Detector) DetectFromRefs(baseRef, headRef string) (*ScopeResult, error) { - // Get the diff - diff, err := d.gitClient.GetDiff(baseRef, headRef) - if err != nil { - return nil, err - } - - return d.buildResult(diff) -} - -// DetectAllChanges detects scope from all staged and unstaged changes. -func (d *Detector) DetectAllChanges() (*ScopeResult, error) { - diff, err := d.gitClient.GetAllChangesDiff() - if err != nil { - return nil, err - } - - return d.buildResult(diff) -} - -// buildResult constructs a ScopeResult from a DiffResult. -func (d *Detector) buildResult(diff *git.DiffResult) (*ScopeResult, error) { - // Extract file paths for language detection - filePaths := make([]string, len(diff.Files)) - for i, f := range diff.Files { - filePaths[i] = f.Path - } - - // Detect language - lang, err := DetectLanguage(filePaths) - if err != nil { - return nil, err - } - - // Categorize files by status - modified, added, deleted := CategorizeFilesByStatus(diff.Files) - - // Extract affected packages - allFiles := append(append(modified, added...), deleted...) - packages := ExtractPackages(lang, allFiles) - - return &ScopeResult{ - BaseRef: diff.BaseRef, - HeadRef: diff.HeadRef, - Language: lang.String(), - ModifiedFiles: modified, - AddedFiles: added, - DeletedFiles: deleted, - TotalFiles: diff.Stats.TotalFiles, - TotalAdditions: diff.Stats.TotalAdditions, - TotalDeletions: diff.Stats.TotalDeletions, - PackagesAffected: packages, - }, nil -} - -// FilterByLanguage filters files to only those matching the detected language. -func FilterByLanguage(files []string, lang Language) []string { - if lang == LanguageUnknown { - return files - } - - var filtered []string - for _, file := range files { - ext := getFileExtension(file) - if fileLang, ok := languageExtensions[ext]; ok && fileLang == lang { - filtered = append(filtered, file) - } - } - return filtered -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd scripts/ring:codereview && go test -v ./internal/scope/... && cd ../..` - -**Expected output:** -``` -=== RUN TestDetectLanguage -=== RUN TestDetectLanguage/Go_only -=== RUN TestDetectLanguage/TypeScript_only -=== RUN TestDetectLanguage/Python_only -=== RUN TestDetectLanguage/Mixed_languages_-_error -=== RUN TestDetectLanguage/No_recognized_files -=== RUN TestDetectLanguage/Go_with_non-code_files -=== RUN TestDetectLanguage/Empty_file_list ---- PASS: TestDetectLanguage (0.00s) -... -=== RUN TestLanguageString ---- PASS: TestLanguageString (0.00s) -=== RUN TestGetFileExtension ---- PASS: TestGetFileExtension (0.00s) -=== RUN TestCategorizeFilesByStatus ---- PASS: TestCategorizeFilesByStatus (0.00s) -=== RUN TestExtractPackages ---- PASS: TestExtractPackages (0.00s) -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope -``` - -**Step 5: Commit** - -```bash -git add scripts/ring:codereview/internal/scope/ -git commit -m "feat(ring:codereview): add scope detection package with language detection - -Implements Language enum, DetectLanguage function (errors on mixed languages), -CategorizeFilesByStatus, ExtractPackages, and Detector struct for building -complete scope results from git diffs." -``` - -**If Task Fails:** - -1. **Import error for git package:** - - Check: `go mod tidy` in scripts/ring:codereview directory - - Fix: Ensure module path matches in imports - ---- - -## Task 5: Add Integration Tests for Scope Package - -**Files:** -- Modify: `scripts/ring:codereview/internal/scope/scope_test.go` - -**Prerequisites:** -- Task 4 completed (scope package exists) - -**Step 1: Add integration tests** - -Append to `scripts/ring:codereview/internal/scope/scope_test.go`: - -```go -import ( - "os/exec" -) - -func TestDetector_Integration(t *testing.T) { - // Skip if not in a git repo - if _, err := exec.Command("git", "rev-parse", "--git-dir").Output(); err != nil { - t.Skip("Not in a git repository") - } - - detector := NewDetector("") - - // Test detecting from refs - result, err := detector.DetectFromRefs("HEAD~1", "HEAD") - if err != nil { - // Might fail if HEAD~1 doesn't exist or mixed languages - if errors.Is(err, ErrMixedLanguages) { - t.Skip("Repository has mixed languages") - } - t.Skipf("Could not detect from refs: %v", err) - } - - // Basic validation - if result.BaseRef != "HEAD~1" { - t.Errorf("BaseRef = %q, want %q", result.BaseRef, "HEAD~1") - } - if result.HeadRef != "HEAD" { - t.Errorf("HeadRef = %q, want %q", result.HeadRef, "HEAD") - } - if result.Language == "" { - t.Error("Language should not be empty") - } -} - -func TestDetector_DetectAllChanges_Integration(t *testing.T) { - if _, err := exec.Command("git", "rev-parse", "--git-dir").Output(); err != nil { - t.Skip("Not in a git repository") - } - - detector := NewDetector("") - result, err := detector.DetectAllChanges() - if err != nil { - if errors.Is(err, ErrMixedLanguages) { - t.Skip("Repository has mixed languages") - } - t.Fatalf("DetectAllChanges() error = %v", err) - } - - // Should return valid result (even if no changes) - if result.BaseRef != "HEAD" { - t.Errorf("BaseRef = %q, want %q", result.BaseRef, "HEAD") - } -} - -func TestFilterByLanguage(t *testing.T) { - tests := []struct { - name string - files []string - lang Language - expected []string - }{ - { - name: "Filter Go files", - files: []string{"main.go", "README.md", "internal/handler.go", "config.yaml"}, - lang: LanguageGo, - expected: []string{"main.go", "internal/handler.go"}, - }, - { - name: "Filter TypeScript files", - files: []string{"index.ts", "App.tsx", "styles.css", "package.json"}, - lang: LanguageTypeScript, - expected: []string{"index.ts", "App.tsx"}, - }, - { - name: "Filter Python files", - files: []string{"main.py", "requirements.txt", "app/handler.py"}, - lang: LanguagePython, - expected: []string{"main.py", "app/handler.py"}, - }, - { - name: "Unknown language returns all", - files: []string{"main.go", "app.ts", "script.py"}, - lang: LanguageUnknown, - expected: []string{"main.go", "app.ts", "script.py"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := FilterByLanguage(tt.files, tt.lang) - if len(got) != len(tt.expected) { - t.Errorf("FilterByLanguage() = %v, want %v", got, tt.expected) - return - } - for i, exp := range tt.expected { - if got[i] != exp { - t.Errorf("FilterByLanguage()[%d] = %q, want %q", i, got[i], exp) - } - } - }) - } -} -``` - -**Step 2: Update imports at top of file** - -Add `"errors"` and `"os/exec"` to the imports in `scope_test.go`: - -```go -import ( - "errors" - "os/exec" - "testing" - - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/git" -) -``` - -**Step 3: Run all tests** - -Run: `cd scripts/ring:codereview && go test -v ./internal/scope/... && cd ../..` - -**Expected output:** -``` -=== RUN TestDetectLanguage ---- PASS: TestDetectLanguage (0.00s) -... -=== RUN TestDetector_Integration ---- PASS: TestDetector_Integration (0.XX s) -=== RUN TestDetector_DetectAllChanges_Integration ---- PASS: TestDetector_DetectAllChanges_Integration (0.XX s) -=== RUN TestFilterByLanguage ---- PASS: TestFilterByLanguage (0.00s) -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/internal/scope/scope_test.go -git commit -m "test(ring:codereview): add integration tests and FilterByLanguage tests - -Adds Detector integration tests and comprehensive FilterByLanguage tests." -``` - ---- - -## Task 6: Implement JSON Output Package - -**Files:** -- Create: `scripts/ring:codereview/internal/output/json.go` -- Create: `scripts/ring:codereview/internal/output/json_test.go` - -**Prerequisites:** -- Task 4 completed (scope package exists) - -**Step 1: Write the failing test** - -Create file `scripts/ring:codereview/internal/output/json_test.go`: - -```go -package output - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope" -) - -func TestScopeOutput_ToJSON(t *testing.T) { - result := &scope.ScopeResult{ - BaseRef: "main", - HeadRef: "HEAD", - Language: "go", - ModifiedFiles: []string{"internal/handler/user.go"}, - AddedFiles: []string{"internal/service/notification.go"}, - DeletedFiles: []string{}, - TotalFiles: 2, - TotalAdditions: 100, - TotalDeletions: 10, - PackagesAffected: []string{"internal/handler", "internal/service"}, - } - - output := NewScopeOutput(result) - jsonBytes, err := output.ToJSON() - if err != nil { - t.Fatalf("ToJSON() error = %v", err) - } - - // Verify it's valid JSON - var decoded map[string]interface{} - if err := json.Unmarshal(jsonBytes, &decoded); err != nil { - t.Fatalf("Output is not valid JSON: %v", err) - } - - // Check key fields - if decoded["base_ref"] != "main" { - t.Errorf("base_ref = %v, want %q", decoded["base_ref"], "main") - } - if decoded["language"] != "go" { - t.Errorf("language = %v, want %q", decoded["language"], "go") - } -} - -func TestScopeOutput_ToPrettyJSON(t *testing.T) { - result := &scope.ScopeResult{ - BaseRef: "main", - HeadRef: "HEAD", - Language: "go", - } - - output := NewScopeOutput(result) - jsonBytes, err := output.ToPrettyJSON() - if err != nil { - t.Fatalf("ToPrettyJSON() error = %v", err) - } - - // Pretty JSON should contain newlines - if !containsNewlines(jsonBytes) { - t.Error("ToPrettyJSON() should contain newlines for formatting") - } -} - -func TestScopeOutput_WriteToFile(t *testing.T) { - result := &scope.ScopeResult{ - BaseRef: "main", - HeadRef: "HEAD", - Language: "go", - } - - // Create temp directory - tmpDir := t.TempDir() - outputPath := filepath.Join(tmpDir, "scope.json") - - output := NewScopeOutput(result) - if err := output.WriteToFile(outputPath); err != nil { - t.Fatalf("WriteToFile() error = %v", err) - } - - // Verify file exists and contains valid JSON - data, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("Failed to read output file: %v", err) - } - - var decoded map[string]interface{} - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("Output file is not valid JSON: %v", err) - } - - if decoded["base_ref"] != "main" { - t.Errorf("base_ref = %v, want %q", decoded["base_ref"], "main") - } -} - -func TestScopeOutput_WriteToFile_CreatesDirectory(t *testing.T) { - result := &scope.ScopeResult{ - BaseRef: "main", - HeadRef: "HEAD", - Language: "go", - } - - // Create temp directory with nested path - tmpDir := t.TempDir() - outputPath := filepath.Join(tmpDir, "nested", "dir", "scope.json") - - output := NewScopeOutput(result) - if err := output.WriteToFile(outputPath); err != nil { - t.Fatalf("WriteToFile() error = %v", err) - } - - // Verify file exists - if _, err := os.Stat(outputPath); os.IsNotExist(err) { - t.Errorf("Output file was not created at %s", outputPath) - } -} - -func containsNewlines(data []byte) bool { - for _, b := range data { - if b == '\n' { - return true - } - } - return false -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd scripts/ring:codereview && go test -v ./internal/output/... 2>&1 | head -20 && cd ../..` - -**Expected output:** -``` -# github.com/lerianstudio/ring/scripts/ring:codereview/internal/output [github.com/lerianstudio/ring/scripts/ring:codereview/internal/output.test] -./json_test.go:XX:XX: undefined: NewScopeOutput -FAIL github.com/lerianstudio/ring/scripts/ring:codereview/internal/output [build failed] -``` - -**Step 3: Write minimal implementation** - -Create file `scripts/ring:codereview/internal/output/json.go`: - -```go -// Package output provides formatters for writing analysis results. -package output - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope" -) - -// ScopeOutput wraps a ScopeResult for output formatting. -type ScopeOutput struct { - result *scope.ScopeResult -} - -// NewScopeOutput creates a new ScopeOutput from a ScopeResult. -func NewScopeOutput(result *scope.ScopeResult) *ScopeOutput { - return &ScopeOutput{result: result} -} - -// scopeJSON is the JSON-serializable representation of scope results. -// This matches the output format specified in the macro plan. -type scopeJSON struct { - BaseRef string `json:"base_ref"` - HeadRef string `json:"head_ref"` - Language string `json:"language"` - Files filesJSON `json:"files"` - Stats statsJSON `json:"stats"` - Packages []string `json:"packages_affected"` -} - -type filesJSON struct { - Modified []string `json:"modified"` - Added []string `json:"added"` - Deleted []string `json:"deleted"` -} - -type statsJSON struct { - TotalFiles int `json:"total_files"` - TotalAdditions int `json:"total_additions"` - TotalDeletions int `json:"total_deletions"` -} - -// toScopeJSON converts the internal result to the JSON output format. -func (o *ScopeOutput) toScopeJSON() scopeJSON { - // Ensure slices are never nil (for consistent JSON output) - modified := o.result.ModifiedFiles - if modified == nil { - modified = []string{} - } - added := o.result.AddedFiles - if added == nil { - added = []string{} - } - deleted := o.result.DeletedFiles - if deleted == nil { - deleted = []string{} - } - packages := o.result.PackagesAffected - if packages == nil { - packages = []string{} - } - - return scopeJSON{ - BaseRef: o.result.BaseRef, - HeadRef: o.result.HeadRef, - Language: o.result.Language, - Files: filesJSON{ - Modified: modified, - Added: added, - Deleted: deleted, - }, - Stats: statsJSON{ - TotalFiles: o.result.TotalFiles, - TotalAdditions: o.result.TotalAdditions, - TotalDeletions: o.result.TotalDeletions, - }, - Packages: packages, - } -} - -// ToJSON returns the scope result as compact JSON bytes. -func (o *ScopeOutput) ToJSON() ([]byte, error) { - data := o.toScopeJSON() - return json.Marshal(data) -} - -// ToPrettyJSON returns the scope result as formatted JSON bytes. -func (o *ScopeOutput) ToPrettyJSON() ([]byte, error) { - data := o.toScopeJSON() - return json.MarshalIndent(data, "", " ") -} - -// WriteToFile writes the scope result as formatted JSON to the specified path. -// Creates parent directories if they don't exist. -func (o *ScopeOutput) WriteToFile(path string) error { - // Ensure parent directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - - // Generate pretty JSON - jsonBytes, err := o.ToPrettyJSON() - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - // Write to file - if err := os.WriteFile(path, jsonBytes, 0644); err != nil { - return fmt.Errorf("failed to write file %s: %w", path, err) - } - - return nil -} - -// WriteToStdout writes the scope result as formatted JSON to stdout. -func (o *ScopeOutput) WriteToStdout() error { - jsonBytes, err := o.ToPrettyJSON() - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - _, err = os.Stdout.Write(jsonBytes) - if err != nil { - return fmt.Errorf("failed to write to stdout: %w", err) - } - - // Add trailing newline - fmt.Println() - return nil -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd scripts/ring:codereview && go test -v ./internal/output/... && cd ../..` - -**Expected output:** -``` -=== RUN TestScopeOutput_ToJSON ---- PASS: TestScopeOutput_ToJSON (0.00s) -=== RUN TestScopeOutput_ToPrettyJSON ---- PASS: TestScopeOutput_ToPrettyJSON (0.00s) -=== RUN TestScopeOutput_WriteToFile ---- PASS: TestScopeOutput_WriteToFile (0.00s) -=== RUN TestScopeOutput_WriteToFile_CreatesDirectory ---- PASS: TestScopeOutput_WriteToFile_CreatesDirectory (0.00s) -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/output -``` - -**Step 5: Commit** - -```bash -git add scripts/ring:codereview/internal/output/ -git commit -m "feat(ring:codereview): add JSON output package for scope results - -Implements ScopeOutput with ToJSON, ToPrettyJSON, WriteToFile, WriteToStdout -methods. Output format matches macro plan specification with nested files -and stats structures." -``` - ---- - -## Task 7: Implement CLI Binary - Main Entry Point - -**Files:** -- Create: `scripts/ring:codereview/cmd/scope-detector/main.go` - -**Prerequisites:** -- Tasks 2, 4, 6 completed (git, scope, output packages exist) - -**Step 1: Create the CLI binary** - -Create file `scripts/ring:codereview/cmd/scope-detector/main.go`: - -```go -// scope-detector analyzes git diffs to detect changed files and project language. -// -// Usage: -// scope-detector # Analyze staged + unstaged changes -// scope-detector --base=main --head=HEAD # Compare specific refs -// scope-detector --output=scope.json # Write to file instead of stdout -// -// Output: JSON containing language, files (modified/added/deleted), stats, and packages. -package main - -import ( - "flag" - "fmt" - "os" - - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/output" - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope" -) - -// Version information (set via ldflags during build) -var ( - version = "dev" -) - -func main() { - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func run() error { - // Define flags - baseRef := flag.String("base", "", "Base reference (commit/branch). Empty = use HEAD for comparison") - headRef := flag.String("head", "", "Head reference (commit/branch). Empty = use working tree") - outputPath := flag.String("output", "", "Output file path. Empty = write to stdout") - workDir := flag.String("workdir", "", "Working directory. Empty = current directory") - showVersion := flag.Bool("version", false, "Show version and exit") - - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: scope-detector [options]\n\n") - fmt.Fprintf(os.Stderr, "Analyzes git diff to detect changed files and project language.\n\n") - fmt.Fprintf(os.Stderr, "Options:\n") - flag.PrintDefaults() - fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " scope-detector # All uncommitted changes\n") - fmt.Fprintf(os.Stderr, " scope-detector --base=main --head=HEAD # Compare branches\n") - fmt.Fprintf(os.Stderr, " scope-detector --output=.ring/ring:codereview/scope.json\n") - } - - flag.Parse() - - // Handle version flag - if *showVersion { - fmt.Printf("scope-detector version %s\n", version) - return nil - } - - // Create detector - detector := scope.NewDetector(*workDir) - - // Detect scope based on provided refs - var result *scope.ScopeResult - var err error - - if *baseRef == "" && *headRef == "" { - // No refs specified: detect all uncommitted changes - result, err = detector.DetectAllChanges() - } else { - // Compare specific refs - result, err = detector.DetectFromRefs(*baseRef, *headRef) - } - - if err != nil { - return fmt.Errorf("scope detection failed: %w", err) - } - - // Handle empty result (no changes) - if result.TotalFiles == 0 { - fmt.Fprintln(os.Stderr, "No changes detected") - } - - // Create output formatter - out := output.NewScopeOutput(result) - - // Write output - if *outputPath != "" { - if err := out.WriteToFile(*outputPath); err != nil { - return fmt.Errorf("failed to write output: %w", err) - } - fmt.Fprintf(os.Stderr, "Scope written to %s\n", *outputPath) - } else { - if err := out.WriteToStdout(); err != nil { - return fmt.Errorf("failed to write to stdout: %w", err) - } - } - - return nil -} -``` - -**Step 2: Build the binary** - -Run: `cd scripts/ring:codereview && make build && cd ../..` - -**Expected output:** -``` -Building scope-detector... -``` - -**Step 3: Verify binary exists** - -Run: `ls -la scripts/ring:codereview/bin/` - -**Expected output:** -``` -total XX -drwxr-xr-x 3 user staff 96 Jan 13 XX:XX . -drwxr-xr-x 8 user staff 256 Jan 13 XX:XX .. --rwxr-xr-x 1 user staff XXX Jan 13 XX:XX scope-detector -``` - -**Step 4: Test binary with --help** - -Run: `./scripts/ring:codereview/bin/scope-detector --help` - -**Expected output:** -``` -Usage: scope-detector [options] - -Analyzes git diff to detect changed files and project language. - -Options: - -base string - Base reference (commit/branch). Empty = use HEAD for comparison - -head string - Head reference (commit/branch). Empty = use working tree - -output string - Output file path. Empty = write to stdout - -version - Show version and exit - -workdir string - Working directory. Empty = current directory - -Examples: - scope-detector # All uncommitted changes - scope-detector --base=main --head=HEAD # Compare branches - scope-detector --output=.ring/ring:codereview/scope.json -``` - -**Step 5: Test binary with actual diff** - -Run: `./scripts/ring:codereview/bin/scope-detector --base=HEAD~1 --head=HEAD` - -**Expected output:** (varies based on actual changes) -```json -{ - "base_ref": "HEAD~1", - "head_ref": "HEAD", - "language": "...", - "files": { - "modified": [...], - "added": [...], - "deleted": [] - }, - "stats": { - "total_files": N, - "total_additions": N, - "total_deletions": N - }, - "packages_affected": [...] -} -``` - -**Step 6: Commit** - -```bash -git add scripts/ring:codereview/cmd/scope-detector/ -git commit -m "feat(ring:codereview): implement scope-detector CLI binary - -Entry point for Phase 0 scope detection. Supports: -- Default: all uncommitted changes (staged + unstaged) -- --base/--head: compare specific refs -- --output: write to file instead of stdout -- --workdir: run in different directory" -``` - -**If Task Fails:** - -1. **Build fails:** - - Check: `go build ./cmd/scope-detector/` for detailed errors - - Fix: Ensure all imports are correct - - Rollback: `rm -rf scripts/ring:codereview/bin/` - -2. **Binary runs but errors:** - - Check: Are you in a git repository? - - Fix: Run from ring repository root - ---- - -## Task 8: Run Full Test Suite and Code Review Checkpoint - -**Files:** -- None (verification only) - -**Prerequisites:** -- Tasks 1-7 completed - -**Step 1: Run all tests with coverage** - -Run: `cd scripts/ring:codereview && make test-coverage && cd ../..` - -**Expected output:** -``` -Running tests with coverage... -=== RUN TestFileStatusString ---- PASS: TestFileStatusString (0.00s) -... -PASS -coverage: XX.X% of statements -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/git -... -PASS -coverage: XX.X% of statements -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope -... -PASS -coverage: XX.X% of statements -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/output -Coverage report: coverage.html -``` - -**Step 2: Run linters** - -Run: `cd scripts/ring:codereview && make lint && cd ../..` - -**Expected output:** -``` -(no output means no errors) -``` - -**Step 3: Test binary end-to-end** - -Run: `./scripts/ring:codereview/bin/scope-detector --output=.ring/ring:codereview/scope.json --base=HEAD~5 --head=HEAD` - -**Expected output:** -``` -Scope written to .ring/ring:codereview/scope.json -``` - -**Step 4: Verify output file** - -Run: `cat .ring/ring:codereview/scope.json` - -**Expected output:** Valid JSON with scope information - -**Step 5: Clean up test output** - -Run: `rm -f .ring/ring:codereview/scope.json` - -### Code Review Checkpoint - -**CRITICAL: Dispatch all 5 reviewers in parallel before proceeding.** - -1. **Dispatch all 5 reviewers in parallel:** - - REQUIRED SUB-SKILL: Use ring:requesting-code-review - - All reviewers run simultaneously (ring:code-reviewer, ring:business-logic-reviewer, ring:security-reviewer, ring:test-reviewer, ring:nil-safety-reviewer) - - Wait for all to complete - -2. **Handle findings by severity (MANDATORY):** - -**Critical/High/Medium Issues:** -- Fix immediately (do NOT add TODO comments for these severities) -- Re-run all 5 reviewers in parallel after fixes -- Repeat until zero Critical/High/Medium issues remain - -**Low Issues:** -- Add `TODO(review):` comments in code at the relevant location -- Format: `TODO(review): [Issue description] (reported by [reviewer] on [date], severity: Low)` - -**Cosmetic/Nitpick Issues:** -- Add `FIXME(nitpick):` comments in code at the relevant location -- Format: `FIXME(nitpick): [Issue description] (reported by [reviewer] on [date], severity: Cosmetic)` - -3. **Proceed only when:** - - Zero Critical/High/Medium issues remain - - All Low issues have TODO(review): comments added - - All Cosmetic issues have FIXME(nitpick): comments added - ---- - -## Task 9: Add CLI Tests - -**Files:** -- Create: `scripts/ring:codereview/cmd/scope-detector/main_test.go` - -**Prerequisites:** -- Task 7 completed (CLI binary exists) - -**Step 1: Create CLI test file** - -Create file `scripts/ring:codereview/cmd/scope-detector/main_test.go`: - -```go -package main - -import ( - "bytes" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "testing" -) - -func TestMain_Version(t *testing.T) { - // Build the binary first - buildCmd := exec.Command("go", "build", "-o", "scope-detector-test", ".") - buildCmd.Dir = "." - if err := buildCmd.Run(); err != nil { - t.Fatalf("Failed to build binary: %v", err) - } - defer os.Remove("scope-detector-test") - - // Run with --version - cmd := exec.Command("./scope-detector-test", "--version") - var stdout bytes.Buffer - cmd.Stdout = &stdout - - if err := cmd.Run(); err != nil { - t.Fatalf("Command failed: %v", err) - } - - output := stdout.String() - if !bytes.Contains([]byte(output), []byte("scope-detector version")) { - t.Errorf("Version output = %q, want to contain 'scope-detector version'", output) - } -} - -func TestMain_Help(t *testing.T) { - // Build the binary - buildCmd := exec.Command("go", "build", "-o", "scope-detector-test", ".") - if err := buildCmd.Run(); err != nil { - t.Fatalf("Failed to build binary: %v", err) - } - defer os.Remove("scope-detector-test") - - // Run with --help (exits with 0) - cmd := exec.Command("./scope-detector-test", "--help") - var stderr bytes.Buffer - cmd.Stderr = &stderr - - // --help should exit 0 - if err := cmd.Run(); err != nil { - // help might exit non-zero on some systems - t.Logf("Help command exited with: %v", err) - } - - output := stderr.String() - if !bytes.Contains([]byte(output), []byte("Usage:")) { - t.Errorf("Help output = %q, want to contain 'Usage:'", output) - } -} - -func TestMain_OutputToFile(t *testing.T) { - // Skip if not in a git repo - if _, err := exec.Command("git", "rev-parse", "--git-dir").Output(); err != nil { - t.Skip("Not in a git repository") - } - - // Build the binary - buildCmd := exec.Command("go", "build", "-o", "scope-detector-test", ".") - if err := buildCmd.Run(); err != nil { - t.Fatalf("Failed to build binary: %v", err) - } - defer os.Remove("scope-detector-test") - - // Create temp output path - tmpDir := t.TempDir() - outputPath := filepath.Join(tmpDir, "scope.json") - - // Run with output flag - cmd := exec.Command("./scope-detector-test", - "--base=HEAD~1", - "--head=HEAD", - "--output="+outputPath) - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - // Might fail if HEAD~1 doesn't exist - t.Skipf("Command failed (may be expected): %v, stderr: %s", err, stderr.String()) - } - - // Verify output file exists - if _, err := os.Stat(outputPath); os.IsNotExist(err) { - t.Errorf("Output file was not created at %s", outputPath) - } - - // Verify it's valid JSON - data, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("Failed to read output file: %v", err) - } - - var decoded map[string]interface{} - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("Output is not valid JSON: %v", err) - } - - // Check required fields exist - if _, ok := decoded["base_ref"]; !ok { - t.Error("Output missing 'base_ref' field") - } - if _, ok := decoded["language"]; !ok { - t.Error("Output missing 'language' field") - } - if _, ok := decoded["files"]; !ok { - t.Error("Output missing 'files' field") - } -} - -func TestMain_JSONStructure(t *testing.T) { - // Skip if not in a git repo - if _, err := exec.Command("git", "rev-parse", "--git-dir").Output(); err != nil { - t.Skip("Not in a git repository") - } - - // Build the binary - buildCmd := exec.Command("go", "build", "-o", "scope-detector-test", ".") - if err := buildCmd.Run(); err != nil { - t.Fatalf("Failed to build binary: %v", err) - } - defer os.Remove("scope-detector-test") - - // Run and capture stdout - cmd := exec.Command("./scope-detector-test", - "--base=HEAD~1", - "--head=HEAD") - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - t.Skipf("Command failed (may be expected): %v, stderr: %s", err, stderr.String()) - } - - // Parse output - var output struct { - BaseRef string `json:"base_ref"` - HeadRef string `json:"head_ref"` - Language string `json:"language"` - Files struct { - Modified []string `json:"modified"` - Added []string `json:"added"` - Deleted []string `json:"deleted"` - } `json:"files"` - Stats struct { - TotalFiles int `json:"total_files"` - TotalAdditions int `json:"total_additions"` - TotalDeletions int `json:"total_deletions"` - } `json:"stats"` - Packages []string `json:"packages_affected"` - } - - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("Failed to parse output JSON: %v\nOutput was: %s", err, stdout.String()) - } - - // Validate structure - if output.BaseRef != "HEAD~1" { - t.Errorf("base_ref = %q, want %q", output.BaseRef, "HEAD~1") - } - if output.HeadRef != "HEAD" { - t.Errorf("head_ref = %q, want %q", output.HeadRef, "HEAD") - } - // Files and stats should exist (even if empty) - if output.Files.Modified == nil { - t.Error("files.modified should not be nil") - } -} -``` - -**Step 2: Run CLI tests** - -Run: `cd scripts/ring:codereview && go test -v ./cmd/scope-detector/... && cd ../..` - -**Expected output:** -``` -=== RUN TestMain_Version ---- PASS: TestMain_Version (X.XX s) -=== RUN TestMain_Help ---- PASS: TestMain_Help (X.XX s) -=== RUN TestMain_OutputToFile ---- PASS: TestMain_OutputToFile (X.XX s) -=== RUN TestMain_JSONStructure ---- PASS: TestMain_JSONStructure (X.XX s) -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/cmd/scope-detector -``` - -**Step 3: Run full test suite** - -Run: `cd scripts/ring:codereview && go test -v ./... && cd ../..` - -**Expected output:** All tests pass - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/cmd/scope-detector/main_test.go -git commit -m "test(ring:codereview): add CLI integration tests for scope-detector - -Tests version flag, help output, file output, and JSON structure validation." -``` - ---- - -## Task 10: Update .gitignore and Documentation - -**Files:** -- Modify: `.gitignore` - -**Prerequisites:** -- Tasks 1-9 completed - -**Step 1: Verify .gitignore already includes .ring/** - -Check if `.ring/` is already in `.gitignore`: - -Run: `grep -n "\.ring" .gitignore` - -**Expected output:** -``` -26:.ring/ -``` - -If `.ring/` is already present, skip to Step 3. Otherwise: - -**Step 2: Add .ring/ to .gitignore (if needed)** - -This step is likely NOT needed based on earlier verification. The `.ring/` directory is already gitignored. - -**Step 3: Add bin directory to gitignore** - -We should ensure the built binaries are not committed. Add to `.gitignore`: - -```bash -echo "" >> .gitignore -echo "# Code review binaries" >> .gitignore -echo "scripts/ring:codereview/bin/" >> .gitignore -echo "scripts/ring:codereview/coverage.*" >> .gitignore -``` - -**Step 4: Verify additions** - -Run: `tail -5 .gitignore` - -**Expected output:** -``` -.ring/ - -# Code review binaries -scripts/ring:codereview/bin/ -scripts/ring:codereview/coverage.* -``` - -**Step 5: Commit** - -```bash -git add .gitignore -git commit -m "chore: gitignore ring:codereview binaries and coverage files" -``` - ---- - -## Task 11: Final Integration Test - -**Files:** -- None (verification only) - -**Prerequisites:** -- All previous tasks completed - -**Step 1: Clean build** - -Run: `cd scripts/ring:codereview && make clean && make build && cd ../..` - -**Expected output:** -``` -Cleaning... -Building scope-detector... -``` - -**Step 2: Run complete test suite** - -Run: `cd scripts/ring:codereview && make test && cd ../..` - -**Expected output:** -``` -Running tests... -... -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/git -... -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/scope -... -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/output -... -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/cmd/scope-detector -``` - -**Step 3: End-to-end test with output** - -Run: `./scripts/ring:codereview/bin/scope-detector --base=HEAD~3 --head=HEAD --output=.ring/ring:codereview/scope.json && cat .ring/ring:codereview/scope.json` - -**Expected output:** Valid JSON scope file with detected language and files - -**Step 4: Verify JSON matches spec** - -The output should match this structure from the macro plan: - -```json -{ - "base_ref": "...", - "head_ref": "...", - "language": "go|typescript|python|unknown", - "files": { - "modified": [...], - "added": [...], - "deleted": [] - }, - "stats": { - "total_files": N, - "total_additions": N, - "total_deletions": N - }, - "packages_affected": [...] -} -``` - -**Step 5: Clean up** - -Run: `rm -f .ring/ring:codereview/scope.json` - -**Step 6: Final commit (if any uncommitted changes)** - -```bash -git status -# If clean: done -# If changes: commit appropriately -``` - ---- - -## Plan Checklist - -Before saving the plan, verify: - -- [x] **Historical precedent queried** (artifact-query --mode planning) -- [x] Historical Precedent section included in plan -- [x] Header with goal, architecture, tech stack, prerequisites -- [x] Verification commands with expected output -- [x] Tasks broken into bite-sized steps (2-5 min each) -- [x] Exact file paths for all files -- [x] Complete code (no placeholders) -- [x] Exact commands with expected output -- [x] Failure recovery steps for each task -- [x] Code review checkpoints after batches -- [x] Severity-based issue handling documented -- [x] Passes Zero-Context Test -- [x] **Plan avoids known failure patterns** (none found in precedent) - ---- - -## Summary - -This plan implements Phase 0 (Scope Detection) of the ring:codereview enhancement with: - -1. **Go module structure** - `scripts/ring:codereview/` with proper layout -2. **Git package** - Wrapper for git CLI operations (diff, name-status, numstat) -3. **Scope package** - Language detection, file categorization, package extraction -4. **Output package** - JSON formatter with file/stdout output -5. **CLI binary** - `scope-detector` with flags for refs and output path -6. **Comprehensive tests** - Unit tests, integration tests, CLI tests - -**Total Tasks:** 11 -**Estimated Time:** 60-90 minutes - -**Next Phase:** Phase 1 (Static Analysis) will consume `scope.json` and run language-specific linters. diff --git a/docs/plans/2026-01-13-codereview-phase1-static-analysis.md b/docs/plans/2026-01-13-codereview-phase1-static-analysis.md deleted file mode 100644 index 63c87f12..00000000 --- a/docs/plans/2026-01-13-codereview-phase1-static-analysis.md +++ /dev/null @@ -1,3690 +0,0 @@ -# Phase 1: Static Analysis Implementation Plan - -> **For Agents:** REQUIRED SUB-SKILL: Use ring:executing-plans to implement this plan task-by-task. - -**Goal:** Implement the static analysis binary that runs language-specific linters (Go, TypeScript, Python), normalizes their output, and produces `static-analysis.json` for downstream phases. - -**Architecture:** Go binary orchestrator that reads `scope.json` (from Phase 0), detects project language, dispatches appropriate linters via subprocess execution, parses their native output formats, filters findings to changed files only, normalizes to a common schema, deduplicates, and outputs aggregate results. - -**Tech Stack:** -- Go 1.22+ (binary implementation) -- External tools: golangci-lint, staticcheck, gosec (Go); tsc, eslint (TypeScript); ruff, mypy, pylint, bandit (Python) -- JSON output format - -**Global Prerequisites:** -- Environment: macOS or Linux, Go 1.22+ -- Tools: git (for repo operations) -- Access: None required (all tools are local) -- State: Clean working tree on `main` branch - -**Verification before starting:** -```bash -# Run ALL these commands and verify output: -go version # Expected: go version go1.22+ or higher -git status # Expected: clean working tree -ls docs/plans/codereview-enhancement-macro-plan.md # Expected: file exists -``` - -## Historical Precedent - -**Query:** "static analysis linter ring:codereview Go TypeScript Python" -**Index Status:** Populated (no relevant precedent found) - -### Successful Patterns to Reference -- None found - this is a new feature area - -### Failure Patterns to AVOID -- None found - -### Related Past Plans -- `ring:codereview-enhancement-macro-plan.md`: Parent macro plan defining overall architecture - ---- - -## Task Overview - -| # | Task | Description | Time | -|---|------|-------------|------| -| 1 | Initialize Go module | Create `scripts/ring:codereview/go.mod` and directory structure | 3 min | -| 2 | Define common types | Create `internal/ring:lint/types.go` with Finding, Result schemas | 4 min | -| 3 | Create linter runner interface | Define `internal/ring:lint/runner.go` with Linter interface | 4 min | -| 4 | Implement tool executor | Create `internal/ring:lint/executor.go` for subprocess execution | 5 min | -| 5 | Implement Go: golangci-lint | Create `internal/ring:lint/golangci.go` wrapper | 5 min | -| 6 | Implement Go: staticcheck | Create `internal/ring:lint/staticcheck.go` wrapper | 4 min | -| 7 | Implement Go: gosec | Create `internal/ring:lint/gosec.go` wrapper | 4 min | -| 8 | Implement TypeScript: tsc | Create `internal/ring:lint/tsc.go` wrapper | 5 min | -| 9 | Implement TypeScript: eslint | Create `internal/ring:lint/eslint.go` wrapper | 5 min | -| 10 | Implement Python: ruff | Create `internal/ring:lint/ruff.go` wrapper | 4 min | -| 11 | Implement Python: mypy | Create `internal/ring:lint/mypy.go` wrapper | 5 min | -| 12 | Implement Python: pylint | Create `internal/ring:lint/pylint.go` wrapper | 5 min | -| 13 | Implement Python: bandit | Create `internal/ring:lint/bandit.go` wrapper | 4 min | -| 14 | Create scope reader | Create `internal/scope/reader.go` to parse scope.json | 4 min | -| 15 | Create output writer | Create `internal/output/json.go` for JSON output | 3 min | -| 16 | Implement orchestrator | Create `cmd/static-analysis/main.go` | 5 min | -| 17 | Add unit tests: types | Create `internal/ring:lint/types_test.go` | 4 min | -| 18 | Add unit tests: golangci parser | Create `internal/ring:lint/golangci_test.go` | 4 min | -| 19 | Add unit tests: eslint parser | Create `internal/ring:lint/eslint_test.go` | 4 min | -| 20 | Add unit tests: ruff parser | Create `internal/ring:lint/ruff_test.go` | 4 min | -| 21 | Integration test | End-to-end test with sample scope.json | 5 min | -| 22 | Code Review | Run code review checkpoint | 5 min | -| 23 | Build and verify | Build binary and verify with real project | 5 min | - ---- - -## Task 1: Initialize Go Module - -**Files:** -- Create: `scripts/ring:codereview/go.mod` -- Create: `scripts/ring:codereview/cmd/static-analysis/.gitkeep` -- Create: `scripts/ring:codereview/internal/ring:lint/.gitkeep` -- Create: `scripts/ring:codereview/internal/scope/.gitkeep` -- Create: `scripts/ring:codereview/internal/output/.gitkeep` - -**Prerequisites:** -- Tools: Go 1.22+ -- Directory `scripts/` does not exist yet - -**Step 1: Create directory structure** - -```bash -mkdir -p scripts/ring:codereview/cmd/static-analysis -mkdir -p scripts/ring:codereview/internal/ring:lint -mkdir -p scripts/ring:codereview/internal/scope -mkdir -p scripts/ring:codereview/internal/output -mkdir -p scripts/ring:codereview/bin -``` - -**Step 2: Create go.mod** - -Create file `scripts/ring:codereview/go.mod`: - -```go -module github.com/LerianStudio/ring/scripts/ring:codereview - -go 1.22 - -require ( - github.com/stretchr/testify v1.9.0 -) -``` - -**Step 3: Initialize go.sum** - -Run: `cd scripts/ring:codereview && go mod tidy` - -**Expected output:** -``` -go: downloading github.com/stretchr/testify v1.9.0 -go: downloading github.com/davecgh/go-spew v1.1.1 -go: downloading github.com/pmezard/go-difflib v1.0.0 -go: downloading github.com/stretchr/objx v0.5.2 -go: downloading gopkg.in/yaml.v3 v3.0.1 -``` - -**Step 4: Verify structure** - -Run: `ls -la scripts/ring:codereview/` - -**Expected output:** -``` -total XX -drwxr-xr-x ... . -drwxr-xr-x ... .. -drwxr-xr-x ... bin -drwxr-xr-x ... cmd --rw-r--r-- ... go.mod --rw-r--r-- ... go.sum -drwxr-xr-x ... internal -``` - -**Step 5: Commit** - -```bash -git add scripts/ring:codereview/ -git commit -m "feat(ring:codereview): initialize Go module for static analysis scripts" -``` - -**If Task Fails:** - -1. **Directory creation fails:** - - Check: `ls scripts/` (parent may not exist) - - Fix: Create parent directories first - - Rollback: `rm -rf scripts/ring:codereview` - -2. **go mod tidy fails:** - - Check: `go version` (needs 1.22+) - - Fix: Update Go version - - Rollback: `rm scripts/ring:codereview/go.sum` - ---- - -## Task 2: Define Common Types - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/types.go` - -**Prerequisites:** -- Task 1 completed (go.mod exists) - -**Step 1: Write the types file** - -Create file `scripts/ring:codereview/internal/ring:lint/types.go`: - -```go -// Package lint provides linter integrations for static analysis. -package lint - -// Severity represents the severity level of a finding. -type Severity string - -const ( - SeverityCritical Severity = "critical" - SeverityHigh Severity = "high" - SeverityWarning Severity = "warning" - SeverityInfo Severity = "info" -) - -// Category represents the category of a finding. -type Category string - -const ( - CategorySecurity Category = "security" - CategoryBug Category = "bug" - CategoryStyle Category = "style" - CategoryPerformance Category = "performance" - CategoryDeprecation Category = "deprecation" - CategoryComplexity Category = "complexity" - CategoryType Category = "type" - CategoryUnused Category = "unused" - CategoryOther Category = "other" -) - -// Finding represents a single lint finding. -type Finding struct { - Tool string `json:"tool"` - Rule string `json:"rule"` - Severity Severity `json:"severity"` - File string `json:"file"` - Line int `json:"line"` - Column int `json:"column"` - Message string `json:"message"` - Suggestion string `json:"suggestion,omitempty"` - Category Category `json:"category"` -} - -// ToolVersions holds version information for all tools used. -type ToolVersions map[string]string - -// Summary holds aggregated finding counts by severity. -type Summary struct { - Critical int `json:"critical"` - High int `json:"high"` - Warning int `json:"warning"` - Info int `json:"info"` -} - -// Result is the aggregate output of static analysis. -type Result struct { - ToolVersions ToolVersions `json:"tool_versions"` - Findings []Finding `json:"findings"` - Summary Summary `json:"summary"` - Errors []string `json:"errors,omitempty"` -} - -// NewResult creates a new Result with initialized fields. -func NewResult() *Result { - return &Result{ - ToolVersions: make(ToolVersions), - Findings: make([]Finding, 0), - Summary: Summary{}, - Errors: make([]string, 0), - } -} - -// AddFinding adds a finding and updates the summary. -func (r *Result) AddFinding(f Finding) { - r.Findings = append(r.Findings, f) - switch f.Severity { - case SeverityCritical: - r.Summary.Critical++ - case SeverityHigh: - r.Summary.High++ - case SeverityWarning: - r.Summary.Warning++ - case SeverityInfo: - r.Summary.Info++ - } -} - -// Merge combines another Result into this one. -func (r *Result) Merge(other *Result) { - for k, v := range other.ToolVersions { - r.ToolVersions[k] = v - } - for _, f := range other.Findings { - r.AddFinding(f) - } - r.Errors = append(r.Errors, other.Errors...) -} - -// FilterByFiles returns findings only for the specified files. -func (r *Result) FilterByFiles(files map[string]bool) *Result { - filtered := NewResult() - filtered.ToolVersions = r.ToolVersions - filtered.Errors = r.Errors - - for _, f := range r.Findings { - if files[f.File] { - filtered.AddFinding(f) - } - } - return filtered -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/types.go -git commit -m "feat(ring:codereview): add common types for static analysis findings" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: Error message for syntax issues - - Fix: Correct syntax errors - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/types.go` - ---- - -## Task 3: Create Linter Runner Interface - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/runner.go` - -**Prerequisites:** -- Task 2 completed (types.go exists) - -**Step 1: Write the runner interface** - -Create file `scripts/ring:codereview/internal/ring:lint/runner.go`: - -```go -package lint - -import "context" - -// Language represents a programming language. -type Language string - -const ( - LanguageGo Language = "go" - LanguageTypeScript Language = "typescript" - LanguagePython Language = "python" -) - -// Linter defines the interface for all linter implementations. -type Linter interface { - // Name returns the linter's name (e.g., "golangci-lint", "eslint"). - Name() string - - // Language returns the language this linter supports. - Language() Language - - // Available checks if the linter is installed and available. - Available(ctx context.Context) bool - - // Version returns the linter's version string. - Version(ctx context.Context) (string, error) - - // Run executes the linter and returns findings. - // projectDir is the root directory of the project. - // files is the list of files/packages to analyze. - Run(ctx context.Context, projectDir string, files []string) (*Result, error) -} - -// Registry holds all registered linters. -type Registry struct { - linters map[Language][]Linter -} - -// NewRegistry creates a new linter registry. -func NewRegistry() *Registry { - return &Registry{ - linters: make(map[Language][]Linter), - } -} - -// Register adds a linter to the registry. -func (r *Registry) Register(l Linter) { - lang := l.Language() - r.linters[lang] = append(r.linters[lang], l) -} - -// GetLinters returns all linters for a specific language. -func (r *Registry) GetLinters(lang Language) []Linter { - return r.linters[lang] -} - -// GetAvailableLinters returns only available linters for a language. -func (r *Registry) GetAvailableLinters(ctx context.Context, lang Language) []Linter { - var available []Linter - for _, l := range r.linters[lang] { - if l.Available(ctx) { - available = append(available, l) - } - } - return available -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/runner.go -git commit -m "feat(ring:codereview): add Linter interface and Registry for linter management" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: Error message for interface definition issues - - Fix: Ensure all method signatures are correct - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/runner.go` - ---- - -## Task 4: Implement Tool Executor - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/executor.go` - -**Prerequisites:** -- Task 3 completed (runner.go exists) - -**Step 1: Write the executor** - -Create file `scripts/ring:codereview/internal/ring:lint/executor.go`: - -```go -package lint - -import ( - "bytes" - "context" - "fmt" - "os/exec" - "strings" - "time" -) - -// DefaultTimeout is the default timeout for linter execution. -const DefaultTimeout = 5 * time.Minute - -// ExecResult holds the result of command execution. -type ExecResult struct { - Stdout []byte - Stderr []byte - ExitCode int - Err error -} - -// Executor runs external commands. -type Executor struct { - timeout time.Duration -} - -// NewExecutor creates a new command executor. -func NewExecutor() *Executor { - return &Executor{ - timeout: DefaultTimeout, - } -} - -// WithTimeout sets a custom timeout. -func (e *Executor) WithTimeout(d time.Duration) *Executor { - e.timeout = d - return e -} - -// Run executes a command and returns the result. -func (e *Executor) Run(ctx context.Context, dir string, name string, args ...string) *ExecResult { - ctx, cancel := context.WithTimeout(ctx, e.timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, name, args...) - cmd.Dir = dir - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - - result := &ExecResult{ - Stdout: stdout.Bytes(), - Stderr: stderr.Bytes(), - } - - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - result.ExitCode = exitErr.ExitCode() - // Many linters return non-zero on findings, which is not an error - result.Err = nil - } else if ctx.Err() == context.DeadlineExceeded { - result.Err = fmt.Errorf("command timed out after %v", e.timeout) - } else { - result.Err = err - } - } - - return result -} - -// CommandAvailable checks if a command is available in PATH. -func (e *Executor) CommandAvailable(ctx context.Context, name string) bool { - _, err := exec.LookPath(name) - return err == nil -} - -// GetVersion runs a command with --version and extracts the version string. -func (e *Executor) GetVersion(ctx context.Context, name string, args ...string) (string, error) { - if len(args) == 0 { - args = []string{"--version"} - } - - result := e.Run(ctx, "", name, args...) - if result.Err != nil { - return "", result.Err - } - - output := string(result.Stdout) - if output == "" { - output = string(result.Stderr) - } - - // Extract first line and clean up - lines := strings.Split(strings.TrimSpace(output), "\n") - if len(lines) > 0 { - return strings.TrimSpace(lines[0]), nil - } - - return "unknown", nil -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/executor.go -git commit -m "feat(ring:codereview): add command executor for running external linters" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: Import paths and error handling - - Fix: Correct any typos in imports - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/executor.go` - ---- - -## Task 5: Implement Go: golangci-lint - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/golangci.go` - -**Prerequisites:** -- Task 4 completed (executor.go exists) - -**Step 1: Write the golangci-lint wrapper** - -Create file `scripts/ring:codereview/internal/ring:lint/golangci.go`: - -```go -package lint - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "strings" -) - -// golangciLintOutput represents golangci-lint JSON output. -type golangciLintOutput struct { - Issues []golangciIssue `json:"Issues"` -} - -type golangciIssue struct { - FromLinter string `json:"FromLinter"` - Text string `json:"Text"` - Severity string `json:"Severity"` - SourceLines []string `json:"SourceLines"` - Pos golangciPosition `json:"Pos"` -} - -type golangciPosition struct { - Filename string `json:"Filename"` - Line int `json:"Line"` - Column int `json:"Column"` -} - -// GolangciLint implements the golangci-lint wrapper. -type GolangciLint struct { - executor *Executor -} - -// NewGolangciLint creates a new golangci-lint wrapper. -func NewGolangciLint() *GolangciLint { - return &GolangciLint{ - executor: NewExecutor(), - } -} - -// Name returns the linter name. -func (g *GolangciLint) Name() string { - return "golangci-lint" -} - -// Language returns the supported language. -func (g *GolangciLint) Language() Language { - return LanguageGo -} - -// Available checks if golangci-lint is installed. -func (g *GolangciLint) Available(ctx context.Context) bool { - return g.executor.CommandAvailable(ctx, "golangci-lint") -} - -// Version returns the golangci-lint version. -func (g *GolangciLint) Version(ctx context.Context) (string, error) { - version, err := g.executor.GetVersion(ctx, "golangci-lint", "version", "--format", "short") - if err != nil { - return "", err - } - return strings.TrimPrefix(version, "v"), nil -} - -// Run executes golangci-lint on the specified packages. -func (g *GolangciLint) Run(ctx context.Context, projectDir string, packages []string) (*Result, error) { - result := NewResult() - - version, err := g.Version(ctx) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("golangci-lint version check failed: %v", err)) - } else { - result.ToolVersions["golangci-lint"] = version - } - - // Build arguments - args := []string{ - "run", - "--out-format=json", - "--issues-exit-code=0", // Don't fail on findings - } - - // Add packages to analyze - if len(packages) > 0 { - args = append(args, packages...) - } else { - args = append(args, "./...") - } - - execResult := g.executor.Run(ctx, projectDir, "golangci-lint", args...) - if execResult.Err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("golangci-lint execution failed: %v", execResult.Err)) - return result, nil - } - - // Parse JSON output - var output golangciLintOutput - if err := json.Unmarshal(execResult.Stdout, &output); err != nil { - // Try to parse partial output - result.Errors = append(result.Errors, fmt.Sprintf("golangci-lint output parse warning: %v", err)) - return result, nil - } - - // Convert to common format - for _, issue := range output.Issues { - finding := Finding{ - Tool: g.Name(), - Rule: issue.FromLinter, - Severity: mapGolangciSeverity(issue.Severity), - File: normalizeFilePath(projectDir, issue.Pos.Filename), - Line: issue.Pos.Line, - Column: issue.Pos.Column, - Message: issue.Text, - Category: mapGolangciCategory(issue.FromLinter), - } - result.AddFinding(finding) - } - - return result, nil -} - -// mapGolangciSeverity maps golangci-lint severity to common severity. -func mapGolangciSeverity(severity string) Severity { - switch strings.ToLower(severity) { - case "error": - return SeverityHigh - case "warning": - return SeverityWarning - default: - return SeverityInfo - } -} - -// mapGolangciCategory maps linter name to category. -func mapGolangciCategory(linter string) Category { - switch linter { - case "gosec", "gocritic": - return CategorySecurity - case "staticcheck", "typecheck": - return CategoryBug - case "gofmt", "goimports", "govet": - return CategoryStyle - case "ineffassign", "deadcode", "unused", "varcheck": - return CategoryUnused - case "gocyclo", "gocognit": - return CategoryComplexity - case "depguard": - return CategoryDeprecation - default: - return CategoryOther - } -} - -// normalizeFilePath converts absolute paths to relative paths. -func normalizeFilePath(projectDir, filePath string) string { - if filepath.IsAbs(filePath) { - if rel, err := filepath.Rel(projectDir, filePath); err == nil { - return rel - } - } - return filePath -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/golangci.go -git commit -m "feat(ring:codereview): add golangci-lint wrapper for Go static analysis" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: JSON struct tags and method signatures - - Fix: Ensure struct fields match golangci-lint JSON output - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/golangci.go` - ---- - -## Task 6: Implement Go: staticcheck - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/staticcheck.go` - -**Prerequisites:** -- Task 5 completed - -**Step 1: Write the staticcheck wrapper** - -Create file `scripts/ring:codereview/internal/ring:lint/staticcheck.go`: - -```go -package lint - -import ( - "context" - "encoding/json" - "fmt" - "strings" -) - -// staticcheckIssue represents a single staticcheck finding. -type staticcheckIssue struct { - Code string `json:"code"` - Severity string `json:"severity"` - Location staticcheckLocation `json:"location"` - Message string `json:"message"` - End staticcheckLocation `json:"end"` -} - -type staticcheckLocation struct { - File string `json:"file"` - Line int `json:"line"` - Column int `json:"column"` -} - -// Staticcheck implements the staticcheck wrapper. -type Staticcheck struct { - executor *Executor -} - -// NewStaticcheck creates a new staticcheck wrapper. -func NewStaticcheck() *Staticcheck { - return &Staticcheck{ - executor: NewExecutor(), - } -} - -// Name returns the linter name. -func (s *Staticcheck) Name() string { - return "staticcheck" -} - -// Language returns the supported language. -func (s *Staticcheck) Language() Language { - return LanguageGo -} - -// Available checks if staticcheck is installed. -func (s *Staticcheck) Available(ctx context.Context) bool { - return s.executor.CommandAvailable(ctx, "staticcheck") -} - -// Version returns the staticcheck version. -func (s *Staticcheck) Version(ctx context.Context) (string, error) { - version, err := s.executor.GetVersion(ctx, "staticcheck", "-version") - if err != nil { - return "", err - } - // Extract version from "staticcheck 2024.1.1 (v0.5.1)" - parts := strings.Fields(version) - if len(parts) >= 2 { - return parts[1], nil - } - return version, nil -} - -// Run executes staticcheck on the specified packages. -func (s *Staticcheck) Run(ctx context.Context, projectDir string, packages []string) (*Result, error) { - result := NewResult() - - version, err := s.Version(ctx) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("staticcheck version check failed: %v", err)) - } else { - result.ToolVersions["staticcheck"] = version - } - - // Build arguments - args := []string{"-f", "json"} - - // Add packages to analyze - if len(packages) > 0 { - args = append(args, packages...) - } else { - args = append(args, "./...") - } - - execResult := s.executor.Run(ctx, projectDir, "staticcheck", args...) - if execResult.Err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("staticcheck execution failed: %v", execResult.Err)) - return result, nil - } - - // Parse JSON lines output (one JSON object per line) - lines := strings.Split(string(execResult.Stdout), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - var issue staticcheckIssue - if err := json.Unmarshal([]byte(line), &issue); err != nil { - continue // Skip malformed lines - } - - finding := Finding{ - Tool: s.Name(), - Rule: issue.Code, - Severity: mapStaticcheckSeverity(issue.Code, issue.Severity), - File: normalizeFilePath(projectDir, issue.Location.File), - Line: issue.Location.Line, - Column: issue.Location.Column, - Message: issue.Message, - Category: mapStaticcheckCategory(issue.Code), - } - result.AddFinding(finding) - } - - return result, nil -} - -// mapStaticcheckSeverity maps staticcheck codes to severity. -func mapStaticcheckSeverity(code, severity string) Severity { - if strings.HasPrefix(code, "SA") { - return SeverityWarning - } - if strings.HasPrefix(code, "S1") { - return SeverityInfo - } - if strings.HasPrefix(code, "ST1") { - return SeverityInfo - } - if severity == "error" { - return SeverityHigh - } - return SeverityWarning -} - -// mapStaticcheckCategory maps staticcheck codes to categories. -func mapStaticcheckCategory(code string) Category { - switch { - case strings.HasPrefix(code, "SA1"): - return CategoryBug - case strings.HasPrefix(code, "SA2"): - return CategoryBug - case strings.HasPrefix(code, "SA3"): - return CategoryBug - case strings.HasPrefix(code, "SA4"): - return CategoryBug - case strings.HasPrefix(code, "SA5"): - return CategoryBug - case strings.HasPrefix(code, "SA6"): - return CategoryPerformance - case strings.HasPrefix(code, "SA9"): - return CategorySecurity - case strings.HasPrefix(code, "S1"): - return CategoryStyle - case strings.HasPrefix(code, "ST1"): - return CategoryStyle - case strings.HasPrefix(code, "QF"): - return CategoryStyle - default: - return CategoryOther - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/staticcheck.go -git commit -m "feat(ring:codereview): add staticcheck wrapper for Go static analysis" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: JSON struct definitions match staticcheck output - - Fix: Verify staticcheck JSON format with `staticcheck -f json ./...` - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/staticcheck.go` - ---- - -## Task 7: Implement Go: gosec - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/gosec.go` - -**Prerequisites:** -- Task 6 completed - -**Step 1: Write the gosec wrapper** - -Create file `scripts/ring:codereview/internal/ring:lint/gosec.go`: - -```go -package lint - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" -) - -// gosecOutput represents gosec JSON output. -type gosecOutput struct { - Issues []gosecIssue `json:"Issues"` - Stats gosecStats `json:"Stats"` -} - -type gosecIssue struct { - Severity string `json:"severity"` - Confidence string `json:"confidence"` - RuleID string `json:"rule_id"` - Details string `json:"details"` - File string `json:"file"` - Line string `json:"line"` - Column string `json:"column"` - Code string `json:"code"` -} - -type gosecStats struct { - Files int `json:"files"` - Lines int `json:"lines"` - Found int `json:"found"` -} - -// Gosec implements the gosec wrapper. -type Gosec struct { - executor *Executor -} - -// NewGosec creates a new gosec wrapper. -func NewGosec() *Gosec { - return &Gosec{ - executor: NewExecutor(), - } -} - -// Name returns the linter name. -func (g *Gosec) Name() string { - return "gosec" -} - -// Language returns the supported language. -func (g *Gosec) Language() Language { - return LanguageGo -} - -// Available checks if gosec is installed. -func (g *Gosec) Available(ctx context.Context) bool { - return g.executor.CommandAvailable(ctx, "gosec") -} - -// Version returns the gosec version. -func (g *Gosec) Version(ctx context.Context) (string, error) { - version, err := g.executor.GetVersion(ctx, "gosec", "-version") - if err != nil { - return "", err - } - // Extract version from "Version: X.Y.Z" or similar - for _, line := range strings.Split(version, "\n") { - if strings.Contains(line, "Version:") { - parts := strings.Split(line, ":") - if len(parts) >= 2 { - return strings.TrimSpace(parts[1]), nil - } - } - } - return strings.TrimSpace(version), nil -} - -// Run executes gosec on the specified packages. -func (g *Gosec) Run(ctx context.Context, projectDir string, packages []string) (*Result, error) { - result := NewResult() - - version, err := g.Version(ctx) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("gosec version check failed: %v", err)) - } else { - result.ToolVersions["gosec"] = version - } - - // Build arguments - args := []string{ - "-fmt=json", - "-quiet", - "-no-fail", // Don't exit non-zero on findings - } - - // Add packages to analyze - if len(packages) > 0 { - args = append(args, packages...) - } else { - args = append(args, "./...") - } - - execResult := g.executor.Run(ctx, projectDir, "gosec", args...) - if execResult.Err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("gosec execution failed: %v", execResult.Err)) - return result, nil - } - - // Parse JSON output - var output gosecOutput - if err := json.Unmarshal(execResult.Stdout, &output); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("gosec output parse warning: %v", err)) - return result, nil - } - - // Convert to common format - for _, issue := range output.Issues { - line, _ := strconv.Atoi(issue.Line) - col, _ := strconv.Atoi(issue.Column) - - finding := Finding{ - Tool: g.Name(), - Rule: issue.RuleID, - Severity: mapGosecSeverity(issue.Severity, issue.Confidence), - File: normalizeFilePath(projectDir, issue.File), - Line: line, - Column: col, - Message: issue.Details, - Category: CategorySecurity, - } - result.AddFinding(finding) - } - - return result, nil -} - -// mapGosecSeverity maps gosec severity and confidence to common severity. -func mapGosecSeverity(severity, confidence string) Severity { - sev := strings.ToUpper(severity) - conf := strings.ToUpper(confidence) - - if sev == "HIGH" && conf == "HIGH" { - return SeverityCritical - } - if sev == "HIGH" { - return SeverityHigh - } - if sev == "MEDIUM" { - return SeverityWarning - } - return SeverityInfo -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/gosec.go -git commit -m "feat(ring:codereview): add gosec wrapper for Go security analysis" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: strconv import and JSON struct tags - - Fix: Verify gosec JSON format with `gosec -fmt=json ./...` - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/gosec.go` - ---- - -## Task 8: Implement TypeScript: tsc - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/tsc.go` - -**Prerequisites:** -- Task 7 completed - -**Step 1: Write the tsc wrapper** - -Create file `scripts/ring:codereview/internal/ring:lint/tsc.go`: - -```go -package lint - -import ( - "bufio" - "context" - "fmt" - "regexp" - "strconv" - "strings" -) - -// tscDiagnosticRegex matches TypeScript compiler diagnostic output. -// Format: "file.ts(line,col): error TSxxxx: message" -var tscDiagnosticRegex = regexp.MustCompile(`^(.+)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.+)$`) - -// TSC implements the TypeScript compiler type checker wrapper. -type TSC struct { - executor *Executor -} - -// NewTSC creates a new tsc wrapper. -func NewTSC() *TSC { - return &TSC{ - executor: NewExecutor(), - } -} - -// Name returns the linter name. -func (t *TSC) Name() string { - return "tsc" -} - -// Language returns the supported language. -func (t *TSC) Language() Language { - return LanguageTypeScript -} - -// Available checks if tsc is installed. -func (t *TSC) Available(ctx context.Context) bool { - // Check for project-local tsc first, then global - return t.executor.CommandAvailable(ctx, "npx") || t.executor.CommandAvailable(ctx, "tsc") -} - -// Version returns the tsc version. -func (t *TSC) Version(ctx context.Context) (string, error) { - // Try npx tsc first (project-local) - version, err := t.executor.GetVersion(ctx, "npx", "tsc", "--version") - if err != nil { - // Fall back to global tsc - version, err = t.executor.GetVersion(ctx, "tsc", "--version") - } - if err != nil { - return "", err - } - // Extract version from "Version X.Y.Z" - parts := strings.Fields(version) - for i, p := range parts { - if p == "Version" && i+1 < len(parts) { - return parts[i+1], nil - } - } - return strings.TrimSpace(version), nil -} - -// Run executes tsc type checking on the project. -func (t *TSC) Run(ctx context.Context, projectDir string, files []string) (*Result, error) { - result := NewResult() - - version, err := t.Version(ctx) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("tsc version check failed: %v", err)) - } else { - result.ToolVersions["typescript"] = version - } - - // Run tsc --noEmit to type check without emitting files - args := []string{"tsc", "--noEmit", "--pretty", "false"} - - execResult := t.executor.Run(ctx, projectDir, "npx", args...) - if execResult.Err != nil { - // Try global tsc - execResult = t.executor.Run(ctx, projectDir, "tsc", args[1:]...) - if execResult.Err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("tsc execution failed: %v", execResult.Err)) - return result, nil - } - } - - // Parse output line by line - scanner := bufio.NewScanner(strings.NewReader(string(execResult.Stdout))) - for scanner.Scan() { - line := scanner.Text() - matches := tscDiagnosticRegex.FindStringSubmatch(line) - if len(matches) != 7 { - continue - } - - lineNum, _ := strconv.Atoi(matches[2]) - col, _ := strconv.Atoi(matches[3]) - - finding := Finding{ - Tool: t.Name(), - Rule: matches[5], // TSxxxx - Severity: mapTSCSeverity(matches[4]), - File: normalizeFilePath(projectDir, matches[1]), - Line: lineNum, - Column: col, - Message: matches[6], - Category: CategoryType, - } - result.AddFinding(finding) - } - - return result, nil -} - -// mapTSCSeverity maps tsc error/warning to common severity. -func mapTSCSeverity(level string) Severity { - switch strings.ToLower(level) { - case "error": - return SeverityHigh - case "warning": - return SeverityWarning - default: - return SeverityInfo - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/tsc.go -git commit -m "feat(ring:codereview): add TypeScript compiler (tsc) wrapper for type checking" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: regexp package import and pattern - - Fix: Test regexp pattern against sample tsc output - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/tsc.go` - ---- - -## Task 9: Implement TypeScript: eslint - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/eslint.go` - -**Prerequisites:** -- Task 8 completed - -**Step 1: Write the eslint wrapper** - -Create file `scripts/ring:codereview/internal/ring:lint/eslint.go`: - -```go -package lint - -import ( - "context" - "encoding/json" - "fmt" - "strings" -) - -// eslintOutput represents eslint JSON output (array of file results). -type eslintOutput []eslintFileResult - -type eslintFileResult struct { - FilePath string `json:"filePath"` - Messages []eslintMessage `json:"messages"` -} - -type eslintMessage struct { - RuleID string `json:"ruleId"` - Severity int `json:"severity"` // 1 = warning, 2 = error - Message string `json:"message"` - Line int `json:"line"` - Column int `json:"column"` -} - -// ESLint implements the eslint wrapper. -type ESLint struct { - executor *Executor -} - -// NewESLint creates a new eslint wrapper. -func NewESLint() *ESLint { - return &ESLint{ - executor: NewExecutor(), - } -} - -// Name returns the linter name. -func (e *ESLint) Name() string { - return "eslint" -} - -// Language returns the supported language. -func (e *ESLint) Language() Language { - return LanguageTypeScript -} - -// Available checks if eslint is installed. -func (e *ESLint) Available(ctx context.Context) bool { - return e.executor.CommandAvailable(ctx, "npx") -} - -// Version returns the eslint version. -func (e *ESLint) Version(ctx context.Context) (string, error) { - version, err := e.executor.GetVersion(ctx, "npx", "eslint", "--version") - if err != nil { - return "", err - } - return strings.TrimPrefix(strings.TrimSpace(version), "v"), nil -} - -// Run executes eslint on the specified files. -func (e *ESLint) Run(ctx context.Context, projectDir string, files []string) (*Result, error) { - result := NewResult() - - version, err := e.Version(ctx) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("eslint version check failed: %v", err)) - } else { - result.ToolVersions["eslint"] = version - } - - // Build arguments - args := []string{ - "eslint", - "--format", "json", - "--no-error-on-unmatched-pattern", - } - - // Add files to lint - if len(files) > 0 { - args = append(args, files...) - } else { - args = append(args, ".") - } - - execResult := e.executor.Run(ctx, projectDir, "npx", args...) - if execResult.Err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("eslint execution failed: %v", execResult.Err)) - return result, nil - } - - // Parse JSON output - var output eslintOutput - if err := json.Unmarshal(execResult.Stdout, &output); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("eslint output parse warning: %v", err)) - return result, nil - } - - // Convert to common format - for _, file := range output { - for _, msg := range file.Messages { - ruleID := msg.RuleID - if ruleID == "" { - ruleID = "parse-error" - } - - finding := Finding{ - Tool: e.Name(), - Rule: ruleID, - Severity: mapESLintSeverity(msg.Severity), - File: normalizeFilePath(projectDir, file.FilePath), - Line: msg.Line, - Column: msg.Column, - Message: msg.Message, - Category: mapESLintCategory(ruleID), - } - result.AddFinding(finding) - } - } - - return result, nil -} - -// mapESLintSeverity maps eslint severity (1=warn, 2=error) to common severity. -func mapESLintSeverity(severity int) Severity { - switch severity { - case 2: - return SeverityHigh - case 1: - return SeverityWarning - default: - return SeverityInfo - } -} - -// mapESLintCategory maps eslint rule IDs to categories. -func mapESLintCategory(ruleID string) Category { - switch { - case strings.HasPrefix(ruleID, "@typescript-eslint/"): - return CategoryType - case strings.Contains(ruleID, "security"): - return CategorySecurity - case strings.Contains(ruleID, "no-unused"): - return CategoryUnused - case strings.HasPrefix(ruleID, "import/"): - return CategoryStyle - case strings.HasPrefix(ruleID, "react"): - return CategoryStyle - case ruleID == "parse-error": - return CategoryBug - default: - return CategoryStyle - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/eslint.go -git commit -m "feat(ring:codereview): add ESLint wrapper for TypeScript linting" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: JSON struct definitions for eslint output - - Fix: Verify eslint JSON format with `npx eslint --format json .` - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/eslint.go` - ---- - -## Task 10: Implement Python: ruff - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/ruff.go` - -**Prerequisites:** -- Task 9 completed - -**Step 1: Write the ruff wrapper** - -Create file `scripts/ring:codereview/internal/ring:lint/ruff.go`: - -```go -package lint - -import ( - "context" - "encoding/json" - "fmt" - "strings" -) - -// ruffOutput represents ruff JSON output (array of diagnostics). -type ruffOutput []ruffDiagnostic - -type ruffDiagnostic struct { - Code string `json:"code"` - Message string `json:"message"` - Location ruffLocation `json:"location"` - Fix *ruffFix `json:"fix"` - Filename string `json:"filename"` -} - -type ruffLocation struct { - Row int `json:"row"` - Column int `json:"column"` -} - -type ruffFix struct { - Message string `json:"message"` -} - -// Ruff implements the ruff linter wrapper. -type Ruff struct { - executor *Executor -} - -// NewRuff creates a new ruff wrapper. -func NewRuff() *Ruff { - return &Ruff{ - executor: NewExecutor(), - } -} - -// Name returns the linter name. -func (r *Ruff) Name() string { - return "ruff" -} - -// Language returns the supported language. -func (r *Ruff) Language() Language { - return LanguagePython -} - -// Available checks if ruff is installed. -func (r *Ruff) Available(ctx context.Context) bool { - return r.executor.CommandAvailable(ctx, "ruff") -} - -// Version returns the ruff version. -func (r *Ruff) Version(ctx context.Context) (string, error) { - version, err := r.executor.GetVersion(ctx, "ruff", "--version") - if err != nil { - return "", err - } - // Extract version from "ruff X.Y.Z" - parts := strings.Fields(version) - if len(parts) >= 2 { - return parts[1], nil - } - return strings.TrimSpace(version), nil -} - -// Run executes ruff on the specified files. -func (r *Ruff) Run(ctx context.Context, projectDir string, files []string) (*Result, error) { - result := NewResult() - - version, err := r.Version(ctx) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("ruff version check failed: %v", err)) - } else { - result.ToolVersions["ruff"] = version - } - - // Build arguments - args := []string{ - "check", - "--output-format", "json", - "--exit-zero", // Don't fail on findings - } - - // Add files to lint - if len(files) > 0 { - args = append(args, files...) - } else { - args = append(args, ".") - } - - execResult := r.executor.Run(ctx, projectDir, "ruff", args...) - if execResult.Err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("ruff execution failed: %v", execResult.Err)) - return result, nil - } - - // Parse JSON output - var output ruffOutput - if err := json.Unmarshal(execResult.Stdout, &output); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("ruff output parse warning: %v", err)) - return result, nil - } - - // Convert to common format - for _, diag := range output { - suggestion := "" - if diag.Fix != nil { - suggestion = diag.Fix.Message - } - - finding := Finding{ - Tool: r.Name(), - Rule: diag.Code, - Severity: mapRuffSeverity(diag.Code), - File: normalizeFilePath(projectDir, diag.Filename), - Line: diag.Location.Row, - Column: diag.Location.Column, - Message: diag.Message, - Suggestion: suggestion, - Category: mapRuffCategory(diag.Code), - } - result.AddFinding(finding) - } - - return result, nil -} - -// mapRuffSeverity maps ruff codes to severity. -func mapRuffSeverity(code string) Severity { - switch { - case strings.HasPrefix(code, "S"): - return SeverityHigh // Security - case strings.HasPrefix(code, "E"): - return SeverityWarning // Errors - case strings.HasPrefix(code, "F"): - return SeverityWarning // Pyflakes - case strings.HasPrefix(code, "W"): - return SeverityWarning // Warnings - case strings.HasPrefix(code, "B"): - return SeverityWarning // Bugbear - default: - return SeverityInfo - } -} - -// mapRuffCategory maps ruff codes to categories. -func mapRuffCategory(code string) Category { - switch { - case strings.HasPrefix(code, "S"): - return CategorySecurity - case strings.HasPrefix(code, "F"): - return CategoryBug - case strings.HasPrefix(code, "E"): - return CategoryStyle - case strings.HasPrefix(code, "W"): - return CategoryStyle - case strings.HasPrefix(code, "B"): - return CategoryBug - case strings.HasPrefix(code, "I"): - return CategoryStyle // Import sorting - case strings.HasPrefix(code, "UP"): - return CategoryDeprecation // Pyupgrade - case strings.HasPrefix(code, "C"): - return CategoryComplexity - default: - return CategoryOther - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/ruff.go -git commit -m "feat(ring:codereview): add ruff wrapper for Python fast linting" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: JSON struct definitions match ruff output - - Fix: Verify ruff JSON format with `ruff check --output-format json .` - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/ruff.go` - ---- - -## Task 11: Implement Python: mypy - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/mypy.go` - -**Prerequisites:** -- Task 10 completed - -**Step 1: Write the mypy wrapper** - -Create file `scripts/ring:codereview/internal/ring:lint/mypy.go`: - -```go -package lint - -import ( - "context" - "encoding/json" - "fmt" - "strings" -) - -// mypyOutput represents mypy JSON output. -type mypyOutput struct { - Messages []mypyMessage `json:"messages"` -} - -type mypyMessage struct { - File string `json:"file"` - Line int `json:"line"` - Column int `json:"column"` - Severity string `json:"severity"` - Code string `json:"code"` - Message string `json:"message"` -} - -// Mypy implements the mypy type checker wrapper. -type Mypy struct { - executor *Executor -} - -// NewMypy creates a new mypy wrapper. -func NewMypy() *Mypy { - return &Mypy{ - executor: NewExecutor(), - } -} - -// Name returns the linter name. -func (m *Mypy) Name() string { - return "mypy" -} - -// Language returns the supported language. -func (m *Mypy) Language() Language { - return LanguagePython -} - -// Available checks if mypy is installed. -func (m *Mypy) Available(ctx context.Context) bool { - return m.executor.CommandAvailable(ctx, "mypy") -} - -// Version returns the mypy version. -func (m *Mypy) Version(ctx context.Context) (string, error) { - version, err := m.executor.GetVersion(ctx, "mypy", "--version") - if err != nil { - return "", err - } - // Extract version from "mypy X.Y.Z (compiled: yes)" - parts := strings.Fields(version) - if len(parts) >= 2 { - return parts[1], nil - } - return strings.TrimSpace(version), nil -} - -// Run executes mypy type checking on the specified files. -func (m *Mypy) Run(ctx context.Context, projectDir string, files []string) (*Result, error) { - result := NewResult() - - version, err := m.Version(ctx) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("mypy version check failed: %v", err)) - } else { - result.ToolVersions["mypy"] = version - } - - // Build arguments - args := []string{ - "--output", "json", - "--no-error-summary", - "--show-error-codes", - } - - // Add files to check - if len(files) > 0 { - args = append(args, files...) - } else { - args = append(args, ".") - } - - execResult := m.executor.Run(ctx, projectDir, "mypy", args...) - if execResult.Err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("mypy execution failed: %v", execResult.Err)) - return result, nil - } - - // mypy JSON output is one JSON object per line - lines := strings.Split(string(execResult.Stdout), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - var msg mypyMessage - if err := json.Unmarshal([]byte(line), &msg); err != nil { - continue // Skip malformed lines - } - - finding := Finding{ - Tool: m.Name(), - Rule: msg.Code, - Severity: mapMypySeverity(msg.Severity), - File: normalizeFilePath(projectDir, msg.File), - Line: msg.Line, - Column: msg.Column, - Message: msg.Message, - Category: CategoryType, - } - result.AddFinding(finding) - } - - return result, nil -} - -// mapMypySeverity maps mypy severity to common severity. -func mapMypySeverity(severity string) Severity { - switch strings.ToLower(severity) { - case "error": - return SeverityHigh - case "warning": - return SeverityWarning - case "note": - return SeverityInfo - default: - return SeverityWarning - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/mypy.go -git commit -m "feat(ring:codereview): add mypy wrapper for Python type checking" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: JSON parsing for line-by-line output - - Fix: Verify mypy JSON format with `mypy --output json .` - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/mypy.go` - ---- - -## Task 12: Implement Python: pylint - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/pylint.go` - -**Prerequisites:** -- Task 11 completed - -**Step 1: Write the pylint wrapper** - -Create file `scripts/ring:codereview/internal/ring:lint/pylint.go`: - -```go -package lint - -import ( - "context" - "encoding/json" - "fmt" - "strings" -) - -// pylintOutput represents pylint JSON output (array of messages). -type pylintOutput []pylintMessage - -type pylintMessage struct { - Type string `json:"type"` // convention, refactor, warning, error, fatal - Module string `json:"module"` - Obj string `json:"obj"` - Line int `json:"line"` - Column int `json:"column"` - Path string `json:"path"` - Symbol string `json:"symbol"` - Message string `json:"message"` - MessageID string `json:"message-id"` -} - -// Pylint implements the pylint wrapper. -type Pylint struct { - executor *Executor -} - -// NewPylint creates a new pylint wrapper. -func NewPylint() *Pylint { - return &Pylint{ - executor: NewExecutor(), - } -} - -// Name returns the linter name. -func (p *Pylint) Name() string { - return "pylint" -} - -// Language returns the supported language. -func (p *Pylint) Language() Language { - return LanguagePython -} - -// Available checks if pylint is installed. -func (p *Pylint) Available(ctx context.Context) bool { - return p.executor.CommandAvailable(ctx, "pylint") -} - -// Version returns the pylint version. -func (p *Pylint) Version(ctx context.Context) (string, error) { - version, err := p.executor.GetVersion(ctx, "pylint", "--version") - if err != nil { - return "", err - } - // Extract version from "pylint X.Y.Z\n..." - lines := strings.Split(version, "\n") - if len(lines) > 0 { - parts := strings.Fields(lines[0]) - if len(parts) >= 2 { - return parts[1], nil - } - } - return strings.TrimSpace(version), nil -} - -// Run executes pylint on the specified files. -func (p *Pylint) Run(ctx context.Context, projectDir string, files []string) (*Result, error) { - result := NewResult() - - version, err := p.Version(ctx) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("pylint version check failed: %v", err)) - } else { - result.ToolVersions["pylint"] = version - } - - // Build arguments - args := []string{ - "--output-format=json", - "--exit-zero", // Don't fail on findings - } - - // Add files to lint - if len(files) > 0 { - args = append(args, files...) - } else { - args = append(args, ".") - } - - execResult := p.executor.Run(ctx, projectDir, "pylint", args...) - if execResult.Err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("pylint execution failed: %v", execResult.Err)) - return result, nil - } - - // Parse JSON output - var output pylintOutput - if err := json.Unmarshal(execResult.Stdout, &output); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("pylint output parse warning: %v", err)) - return result, nil - } - - // Convert to common format - for _, msg := range output { - finding := Finding{ - Tool: p.Name(), - Rule: msg.MessageID, - Severity: mapPylintSeverity(msg.Type), - File: normalizeFilePath(projectDir, msg.Path), - Line: msg.Line, - Column: msg.Column, - Message: fmt.Sprintf("%s: %s", msg.Symbol, msg.Message), - Category: mapPylintCategory(msg.Type, msg.MessageID), - } - result.AddFinding(finding) - } - - return result, nil -} - -// mapPylintSeverity maps pylint message types to severity. -func mapPylintSeverity(msgType string) Severity { - switch strings.ToLower(msgType) { - case "fatal", "error": - return SeverityHigh - case "warning": - return SeverityWarning - case "refactor", "convention": - return SeverityInfo - default: - return SeverityInfo - } -} - -// mapPylintCategory maps pylint message types and IDs to categories. -func mapPylintCategory(msgType, msgID string) Category { - switch strings.ToLower(msgType) { - case "fatal", "error": - return CategoryBug - case "warning": - if strings.HasPrefix(msgID, "W0611") || strings.HasPrefix(msgID, "W0612") { - return CategoryUnused - } - return CategoryBug - case "refactor": - return CategoryComplexity - case "convention": - return CategoryStyle - default: - return CategoryOther - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/pylint.go -git commit -m "feat(ring:codereview): add pylint wrapper for comprehensive Python linting" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: JSON struct field tags match pylint output - - Fix: Verify pylint JSON format with `pylint --output-format=json .` - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/pylint.go` - ---- - -## Task 13: Implement Python: bandit - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/bandit.go` - -**Prerequisites:** -- Task 12 completed - -**Step 1: Write the bandit wrapper** - -Create file `scripts/ring:codereview/internal/ring:lint/bandit.go`: - -```go -package lint - -import ( - "context" - "encoding/json" - "fmt" - "strings" -) - -// banditOutput represents bandit JSON output. -type banditOutput struct { - Results []banditResult `json:"results"` - Metrics banditMetrics `json:"metrics"` -} - -type banditResult struct { - Code string `json:"code"` - Filename string `json:"filename"` - IssueText string `json:"issue_text"` - IssueSeverity string `json:"issue_severity"` - IssueConfidence string `json:"issue_confidence"` - LineNumber int `json:"line_number"` - LineRange []int `json:"line_range"` - MoreInfo string `json:"more_info"` - TestID string `json:"test_id"` - TestName string `json:"test_name"` -} - -type banditMetrics struct { - TotalIssues int `json:"SEVERITY.HIGH"` -} - -// Bandit implements the bandit security scanner wrapper. -type Bandit struct { - executor *Executor -} - -// NewBandit creates a new bandit wrapper. -func NewBandit() *Bandit { - return &Bandit{ - executor: NewExecutor(), - } -} - -// Name returns the linter name. -func (b *Bandit) Name() string { - return "bandit" -} - -// Language returns the supported language. -func (b *Bandit) Language() Language { - return LanguagePython -} - -// Available checks if bandit is installed. -func (b *Bandit) Available(ctx context.Context) bool { - return b.executor.CommandAvailable(ctx, "bandit") -} - -// Version returns the bandit version. -func (b *Bandit) Version(ctx context.Context) (string, error) { - version, err := b.executor.GetVersion(ctx, "bandit", "--version") - if err != nil { - return "", err - } - // Extract version from "bandit X.Y.Z" - parts := strings.Fields(version) - if len(parts) >= 2 { - return parts[1], nil - } - return strings.TrimSpace(version), nil -} - -// Run executes bandit security analysis on the specified files. -func (b *Bandit) Run(ctx context.Context, projectDir string, files []string) (*Result, error) { - result := NewResult() - - version, err := b.Version(ctx) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("bandit version check failed: %v", err)) - } else { - result.ToolVersions["bandit"] = version - } - - // Build arguments - args := []string{ - "-f", "json", - "-q", // Quiet mode - } - - // Add files to scan - if len(files) > 0 { - args = append(args, files...) - } else { - args = append(args, "-r", ".") // Recursive scan - } - - execResult := b.executor.Run(ctx, projectDir, "bandit", args...) - if execResult.Err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("bandit execution failed: %v", execResult.Err)) - return result, nil - } - - // Parse JSON output - var output banditOutput - if err := json.Unmarshal(execResult.Stdout, &output); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("bandit output parse warning: %v", err)) - return result, nil - } - - // Convert to common format - for _, res := range output.Results { - finding := Finding{ - Tool: b.Name(), - Rule: res.TestID, - Severity: mapBanditSeverity(res.IssueSeverity, res.IssueConfidence), - File: normalizeFilePath(projectDir, res.Filename), - Line: res.LineNumber, - Column: 1, // Bandit doesn't provide column info - Message: fmt.Sprintf("%s: %s", res.TestName, res.IssueText), - Suggestion: res.MoreInfo, - Category: CategorySecurity, - } - result.AddFinding(finding) - } - - return result, nil -} - -// mapBanditSeverity maps bandit severity and confidence to common severity. -func mapBanditSeverity(severity, confidence string) Severity { - sev := strings.ToUpper(severity) - conf := strings.ToUpper(confidence) - - if sev == "HIGH" && conf == "HIGH" { - return SeverityCritical - } - if sev == "HIGH" { - return SeverityHigh - } - if sev == "MEDIUM" { - return SeverityWarning - } - return SeverityInfo -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/ring:lint/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/bandit.go -git commit -m "feat(ring:codereview): add bandit wrapper for Python security analysis" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: JSON struct definitions match bandit output - - Fix: Verify bandit JSON format with `bandit -f json -r .` - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/bandit.go` - ---- - -## Task 14: Create Scope Reader - -**Files:** -- Create: `scripts/ring:codereview/internal/scope/reader.go` - -**Prerequisites:** -- Task 13 completed - -**Step 1: Write the scope reader** - -Create file `scripts/ring:codereview/internal/scope/reader.go`: - -```go -// Package scope handles reading and parsing scope.json from Phase 0. -package scope - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/LerianStudio/ring/scripts/ring:codereview/internal/ring:lint" -) - -// Scope represents the scope.json structure from Phase 0. -type Scope struct { - BaseRef string `json:"base_ref"` - HeadRef string `json:"head_ref"` - Language string `json:"language"` // Primary detected language - Files map[string]FileList `json:"files"` - Stats Stats `json:"stats"` - Packages map[string][]string `json:"packages_affected"` -} - -// FileList holds categorized file lists. -type FileList struct { - Modified []string `json:"modified"` - Added []string `json:"added"` - Deleted []string `json:"deleted"` -} - -// Stats holds change statistics. -type Stats struct { - TotalFiles int `json:"total_files"` - TotalAdditions int `json:"total_additions"` - TotalDeletions int `json:"total_deletions"` -} - -// ReadScope reads and parses scope.json from the given path. -func ReadScope(scopePath string) (*Scope, error) { - data, err := os.ReadFile(scopePath) - if err != nil { - return nil, fmt.Errorf("failed to read scope.json: %w", err) - } - - var scope Scope - if err := json.Unmarshal(data, &scope); err != nil { - return nil, fmt.Errorf("failed to parse scope.json: %w", err) - } - - return &scope, nil -} - -// GetLanguage returns the primary language as a lint.Language. -func (s *Scope) GetLanguage() lint.Language { - switch s.Language { - case "go": - return lint.LanguageGo - case "typescript", "ts": - return lint.LanguageTypeScript - case "python", "py": - return lint.LanguagePython - default: - return lint.Language(s.Language) - } -} - -// GetAllFiles returns all changed files (modified + added) for a language. -func (s *Scope) GetAllFiles(lang string) []string { - files, ok := s.Files[lang] - if !ok { - return nil - } - - var all []string - all = append(all, files.Modified...) - all = append(all, files.Added...) - return all -} - -// GetAllFilesMap returns a map of all changed files for quick lookup. -func (s *Scope) GetAllFilesMap() map[string]bool { - fileMap := make(map[string]bool) - for _, files := range s.Files { - for _, f := range files.Modified { - fileMap[f] = true - } - for _, f := range files.Added { - fileMap[f] = true - } - } - return fileMap -} - -// GetPackages returns the affected packages for a language. -func (s *Scope) GetPackages(lang string) []string { - return s.Packages[lang] -} - -// DefaultScopePath returns the default scope.json path. -func DefaultScopePath(projectDir string) string { - return filepath.Join(projectDir, ".ring", "ring:codereview", "scope.json") -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/scope/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/scope/reader.go -git commit -m "feat(ring:codereview): add scope.json reader for Phase 0 integration" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: Import path for lint package - - Fix: Ensure module path matches go.mod - - Rollback: `git checkout -- scripts/ring:codereview/internal/scope/reader.go` - ---- - -## Task 15: Create Output Writer - -**Files:** -- Create: `scripts/ring:codereview/internal/output/json.go` - -**Prerequisites:** -- Task 14 completed - -**Step 1: Write the output writer** - -Create file `scripts/ring:codereview/internal/output/json.go`: - -```go -// Package output handles writing analysis results to files. -package output - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/LerianStudio/ring/scripts/ring:codereview/internal/ring:lint" -) - -// Writer handles writing analysis results. -type Writer struct { - outputDir string -} - -// NewWriter creates a new output writer. -func NewWriter(outputDir string) *Writer { - return &Writer{ - outputDir: outputDir, - } -} - -// EnsureDir creates the output directory if it doesn't exist. -func (w *Writer) EnsureDir() error { - return os.MkdirAll(w.outputDir, 0755) -} - -// WriteResult writes the analysis result to static-analysis.json. -func (w *Writer) WriteResult(result *lint.Result) error { - return w.writeJSON("static-analysis.json", result) -} - -// WriteLanguageResult writes a language-specific result file. -func (w *Writer) WriteLanguageResult(lang lint.Language, result *lint.Result) error { - filename := fmt.Sprintf("%s-lint.json", lang) - return w.writeJSON(filename, result) -} - -// writeJSON writes data as formatted JSON to a file. -func (w *Writer) writeJSON(filename string, data interface{}) error { - path := filepath.Join(w.outputDir, filename) - - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - if err := os.WriteFile(path, jsonData, 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", path, err) - } - - return nil -} - -// DefaultOutputDir returns the default output directory. -func DefaultOutputDir(projectDir string) string { - return filepath.Join(projectDir, ".ring", "ring:codereview") -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/output/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/output/json.go -git commit -m "feat(ring:codereview): add JSON output writer for analysis results" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: Import path for lint package - - Fix: Ensure module path matches go.mod - - Rollback: `git checkout -- scripts/ring:codereview/internal/output/json.go` - ---- - -## Task 16: Implement Orchestrator - -**Files:** -- Create: `scripts/ring:codereview/cmd/static-analysis/main.go` - -**Prerequisites:** -- Tasks 1-15 completed - -**Step 1: Write the main orchestrator** - -Create file `scripts/ring:codereview/cmd/static-analysis/main.go`: - -```go -// Package main implements the static-analysis binary. -package main - -import ( - "context" - "flag" - "fmt" - "log" - "os" - "path/filepath" - "time" - - "github.com/LerianStudio/ring/scripts/ring:codereview/internal/ring:lint" - "github.com/LerianStudio/ring/scripts/ring:codereview/internal/output" - "github.com/LerianStudio/ring/scripts/ring:codereview/internal/scope" -) - -func main() { - // Parse flags - scopePath := flag.String("scope", "", "Path to scope.json (default: .ring/ring:codereview/scope.json)") - outputPath := flag.String("output", "", "Output directory (default: .ring/ring:codereview/)") - verbose := flag.Bool("v", false, "Verbose output") - timeout := flag.Duration("timeout", 5*time.Minute, "Timeout for analysis") - flag.Parse() - - // Determine project directory (current working directory) - projectDir, err := os.Getwd() - if err != nil { - log.Fatalf("Failed to get working directory: %v", err) - } - - // Set default paths - if *scopePath == "" { - *scopePath = scope.DefaultScopePath(projectDir) - } - if *outputPath == "" { - *outputPath = output.DefaultOutputDir(projectDir) - } - - // Create context with timeout - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - - // Read scope - if *verbose { - log.Printf("Reading scope from: %s", *scopePath) - } - s, err := scope.ReadScope(*scopePath) - if err != nil { - log.Fatalf("Failed to read scope: %v", err) - } - - // Get language - lang := s.GetLanguage() - if *verbose { - log.Printf("Detected language: %s", lang) - } - - // Initialize registry and register linters - registry := lint.NewRegistry() - registerLinters(registry) - - // Get available linters for detected language - linters := registry.GetAvailableLinters(ctx, lang) - if len(linters) == 0 { - log.Printf("Warning: No linters available for language: %s", lang) - linters = []lint.Linter{} - } - - if *verbose { - log.Printf("Available linters: %d", len(linters)) - for _, l := range linters { - log.Printf(" - %s", l.Name()) - } - } - - // Run all available linters - aggregateResult := lint.NewResult() - changedFiles := s.GetAllFilesMap() - - for _, linter := range linters { - if *verbose { - log.Printf("Running %s...", linter.Name()) - } - - // Get files/packages for this linter - var targets []string - if lang == lint.LanguageGo { - // For Go, use packages - targets = s.GetPackages("go") - } else { - // For TS/Python, use files - targets = s.GetAllFiles(string(lang)) - } - - result, err := linter.Run(ctx, projectDir, targets) - if err != nil { - log.Printf("Warning: %s failed: %v", linter.Name(), err) - aggregateResult.Errors = append(aggregateResult.Errors, fmt.Sprintf("%s: %v", linter.Name(), err)) - continue - } - - // Filter to changed files only and merge - filtered := result.FilterByFiles(changedFiles) - aggregateResult.Merge(filtered) - - if *verbose { - log.Printf(" %s: %d findings", linter.Name(), len(filtered.Findings)) - } - } - - // Deduplicate findings (same file:line:message from different tools) - deduplicateFindings(aggregateResult) - - // Ensure output directory exists - writer := output.NewWriter(*outputPath) - if err := writer.EnsureDir(); err != nil { - log.Fatalf("Failed to create output directory: %v", err) - } - - // Write results - if err := writer.WriteResult(aggregateResult); err != nil { - log.Fatalf("Failed to write results: %v", err) - } - - // Write language-specific result - if err := writer.WriteLanguageResult(lang, aggregateResult); err != nil { - log.Fatalf("Failed to write language result: %v", err) - } - - // Print summary - fmt.Printf("Static analysis complete:\n") - fmt.Printf(" Files analyzed: %d\n", len(changedFiles)) - fmt.Printf(" Critical: %d\n", aggregateResult.Summary.Critical) - fmt.Printf(" High: %d\n", aggregateResult.Summary.High) - fmt.Printf(" Warning: %d\n", aggregateResult.Summary.Warning) - fmt.Printf(" Info: %d\n", aggregateResult.Summary.Info) - fmt.Printf(" Output: %s\n", filepath.Join(*outputPath, "static-analysis.json")) - - if len(aggregateResult.Errors) > 0 { - fmt.Printf("\nWarnings during analysis:\n") - for _, e := range aggregateResult.Errors { - fmt.Printf(" - %s\n", e) - } - } -} - -// registerLinters adds all linters to the registry. -func registerLinters(r *lint.Registry) { - // Go linters - r.Register(lint.NewGolangciLint()) - r.Register(lint.NewStaticcheck()) - r.Register(lint.NewGosec()) - - // TypeScript linters - r.Register(lint.NewTSC()) - r.Register(lint.NewESLint()) - - // Python linters - r.Register(lint.NewRuff()) - r.Register(lint.NewMypy()) - r.Register(lint.NewPylint()) - r.Register(lint.NewBandit()) -} - -// deduplicateFindings removes duplicate findings based on file:line:message. -func deduplicateFindings(result *lint.Result) { - seen := make(map[string]bool) - var unique []lint.Finding - - // Reset summary - result.Summary = lint.Summary{} - - for _, f := range result.Findings { - key := fmt.Sprintf("%s:%d:%s", f.File, f.Line, f.Message) - if !seen[key] { - seen[key] = true - unique = append(unique, f) - - // Update summary - switch f.Severity { - case lint.SeverityCritical: - result.Summary.Critical++ - case lint.SeverityHigh: - result.Summary.High++ - case lint.SeverityWarning: - result.Summary.Warning++ - case lint.SeverityInfo: - result.Summary.Info++ - } - } - } - - result.Findings = unique -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build -o bin/static-analysis ./cmd/static-analysis/` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Verify binary exists** - -Run: `ls -la scripts/ring:codereview/bin/static-analysis` - -**Expected output:** -``` --rwxr-xr-x 1 ... static-analysis -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/cmd/static-analysis/main.go -git commit -m "feat(ring:codereview): add static-analysis orchestrator binary" -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: All import paths match module structure - - Fix: Run `go mod tidy` to resolve dependencies - - Rollback: `git checkout -- scripts/ring:codereview/cmd/static-analysis/main.go` - -2. **Binary not created:** - - Check: `bin/` directory exists - - Fix: `mkdir -p scripts/ring:codereview/bin` - ---- - -## Task 17: Add Unit Tests: Types - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/types_test.go` - -**Prerequisites:** -- Task 16 completed - -**Step 1: Write tests for types** - -Create file `scripts/ring:codereview/internal/ring:lint/types_test.go`: - -```go -package lint - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewResult(t *testing.T) { - result := NewResult() - - assert.NotNil(t, result) - assert.Empty(t, result.Findings) - assert.Empty(t, result.ToolVersions) - assert.Empty(t, result.Errors) - assert.Equal(t, 0, result.Summary.Critical) - assert.Equal(t, 0, result.Summary.High) - assert.Equal(t, 0, result.Summary.Warning) - assert.Equal(t, 0, result.Summary.Info) -} - -func TestResult_AddFinding(t *testing.T) { - tests := []struct { - name string - severity Severity - wantCrit int - wantHigh int - wantWarn int - wantInfo int - }{ - {"critical", SeverityCritical, 1, 0, 0, 0}, - {"high", SeverityHigh, 0, 1, 0, 0}, - {"warning", SeverityWarning, 0, 0, 1, 0}, - {"info", SeverityInfo, 0, 0, 0, 1}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := NewResult() - finding := Finding{ - Tool: "test", - Rule: "TEST001", - Severity: tt.severity, - File: "test.go", - Line: 1, - Column: 1, - Message: "test message", - Category: CategoryBug, - } - - result.AddFinding(finding) - - assert.Len(t, result.Findings, 1) - assert.Equal(t, tt.wantCrit, result.Summary.Critical) - assert.Equal(t, tt.wantHigh, result.Summary.High) - assert.Equal(t, tt.wantWarn, result.Summary.Warning) - assert.Equal(t, tt.wantInfo, result.Summary.Info) - }) - } -} - -func TestResult_Merge(t *testing.T) { - result1 := NewResult() - result1.ToolVersions["tool1"] = "1.0.0" - result1.AddFinding(Finding{ - Tool: "tool1", Rule: "R001", Severity: SeverityHigh, - File: "a.go", Line: 1, Message: "issue 1", - }) - - result2 := NewResult() - result2.ToolVersions["tool2"] = "2.0.0" - result2.AddFinding(Finding{ - Tool: "tool2", Rule: "R002", Severity: SeverityWarning, - File: "b.go", Line: 2, Message: "issue 2", - }) - result2.Errors = append(result2.Errors, "error from tool2") - - result1.Merge(result2) - - assert.Len(t, result1.Findings, 2) - assert.Equal(t, "1.0.0", result1.ToolVersions["tool1"]) - assert.Equal(t, "2.0.0", result1.ToolVersions["tool2"]) - assert.Equal(t, 1, result1.Summary.High) - assert.Equal(t, 1, result1.Summary.Warning) - assert.Len(t, result1.Errors, 1) -} - -func TestResult_FilterByFiles(t *testing.T) { - result := NewResult() - result.ToolVersions["test"] = "1.0.0" - result.AddFinding(Finding{ - Tool: "test", Rule: "R001", Severity: SeverityHigh, - File: "changed.go", Line: 1, Message: "in scope", - }) - result.AddFinding(Finding{ - Tool: "test", Rule: "R002", Severity: SeverityWarning, - File: "unchanged.go", Line: 1, Message: "out of scope", - }) - result.Errors = append(result.Errors, "test error") - - files := map[string]bool{"changed.go": true} - filtered := result.FilterByFiles(files) - - assert.Len(t, filtered.Findings, 1) - assert.Equal(t, "changed.go", filtered.Findings[0].File) - assert.Equal(t, 1, filtered.Summary.High) - assert.Equal(t, 0, filtered.Summary.Warning) - assert.Equal(t, "1.0.0", filtered.ToolVersions["test"]) - assert.Len(t, filtered.Errors, 1) -} -``` - -**Step 2: Run tests** - -Run: `cd scripts/ring:codereview && go test ./internal/ring:lint/... -v` - -**Expected output:** -``` -=== RUN TestNewResult ---- PASS: TestNewResult (0.00s) -=== RUN TestResult_AddFinding -=== RUN TestResult_AddFinding/critical -=== RUN TestResult_AddFinding/high -=== RUN TestResult_AddFinding/warning -=== RUN TestResult_AddFinding/info ---- PASS: TestResult_AddFinding (0.00s) - --- PASS: TestResult_AddFinding/critical (0.00s) - --- PASS: TestResult_AddFinding/high (0.00s) - --- PASS: TestResult_AddFinding/warning (0.00s) - --- PASS: TestResult_AddFinding/info (0.00s) -=== RUN TestResult_Merge ---- PASS: TestResult_Merge (0.00s) -=== RUN TestResult_FilterByFiles ---- PASS: TestResult_FilterByFiles (0.00s) -PASS -ok github.com/LerianStudio/ring/scripts/ring:codereview/internal/ring:lint -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/types_test.go -git commit -m "test(ring:codereview): add unit tests for lint types" -``` - -**If Task Fails:** - -1. **Tests fail:** - - Check: Test assertions match implementation - - Fix: Adjust test expectations or fix implementation - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/types_test.go` - ---- - -## Task 18: Add Unit Tests: golangci Parser - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/golangci_test.go` - -**Prerequisites:** -- Task 17 completed - -**Step 1: Write parser tests** - -Create file `scripts/ring:codereview/internal/ring:lint/golangci_test.go`: - -```go -package lint - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMapGolangciSeverity(t *testing.T) { - tests := []struct { - input string - expected Severity - }{ - {"error", SeverityHigh}, - {"ERROR", SeverityHigh}, - {"warning", SeverityWarning}, - {"WARNING", SeverityWarning}, - {"info", SeverityInfo}, - {"", SeverityInfo}, - {"unknown", SeverityInfo}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := mapGolangciSeverity(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestMapGolangciCategory(t *testing.T) { - tests := []struct { - linter string - expected Category - }{ - {"gosec", CategorySecurity}, - {"gocritic", CategorySecurity}, - {"staticcheck", CategoryBug}, - {"typecheck", CategoryBug}, - {"gofmt", CategoryStyle}, - {"goimports", CategoryStyle}, - {"govet", CategoryStyle}, - {"ineffassign", CategoryUnused}, - {"deadcode", CategoryUnused}, - {"unused", CategoryUnused}, - {"varcheck", CategoryUnused}, - {"gocyclo", CategoryComplexity}, - {"gocognit", CategoryComplexity}, - {"depguard", CategoryDeprecation}, - {"unknown", CategoryOther}, - } - - for _, tt := range tests { - t.Run(tt.linter, func(t *testing.T) { - result := mapGolangciCategory(tt.linter) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestNormalizeFilePath(t *testing.T) { - tests := []struct { - name string - projectDir string - filePath string - expected string - }{ - { - name: "relative path unchanged", - projectDir: "/project", - filePath: "internal/handler.go", - expected: "internal/handler.go", - }, - { - name: "absolute path converted", - projectDir: "/project", - filePath: "/project/internal/handler.go", - expected: "internal/handler.go", - }, - { - name: "outside project stays absolute", - projectDir: "/project", - filePath: "/other/file.go", - expected: "/other/file.go", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := normalizeFilePath(tt.projectDir, tt.filePath) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestGolangciLint_Name(t *testing.T) { - g := NewGolangciLint() - assert.Equal(t, "golangci-lint", g.Name()) -} - -func TestGolangciLint_Language(t *testing.T) { - g := NewGolangciLint() - assert.Equal(t, LanguageGo, g.Language()) -} -``` - -**Step 2: Run tests** - -Run: `cd scripts/ring:codereview && go test ./internal/ring:lint/... -v -run Golangci` - -**Expected output:** -``` -=== RUN TestMapGolangciSeverity -... ---- PASS: TestMapGolangciSeverity (0.00s) -=== RUN TestMapGolangciCategory -... ---- PASS: TestMapGolangciCategory (0.00s) -=== RUN TestNormalizeFilePath -... ---- PASS: TestNormalizeFilePath (0.00s) -=== RUN TestGolangciLint_Name ---- PASS: TestGolangciLint_Name (0.00s) -=== RUN TestGolangciLint_Language ---- PASS: TestGolangciLint_Language (0.00s) -PASS -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/golangci_test.go -git commit -m "test(ring:codereview): add unit tests for golangci-lint parser" -``` - -**If Task Fails:** - -1. **Tests fail:** - - Check: Test assertions match implementation - - Fix: Verify mapping functions return expected values - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/golangci_test.go` - ---- - -## Task 19: Add Unit Tests: eslint Parser - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/eslint_test.go` - -**Prerequisites:** -- Task 18 completed - -**Step 1: Write parser tests** - -Create file `scripts/ring:codereview/internal/ring:lint/eslint_test.go`: - -```go -package lint - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMapESLintSeverity(t *testing.T) { - tests := []struct { - input int - expected Severity - }{ - {2, SeverityHigh}, - {1, SeverityWarning}, - {0, SeverityInfo}, - {99, SeverityInfo}, - } - - for _, tt := range tests { - t.Run("", func(t *testing.T) { - result := mapESLintSeverity(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestMapESLintCategory(t *testing.T) { - tests := []struct { - ruleID string - expected Category - }{ - {"@typescript-eslint/no-unused-vars", CategoryType}, - {"@typescript-eslint/explicit-function-return-type", CategoryType}, - {"security/detect-object-injection", CategorySecurity}, - {"no-unused-vars", CategoryUnused}, - {"no-unused-expressions", CategoryUnused}, - {"import/order", CategoryStyle}, - {"import/no-unresolved", CategoryStyle}, - {"react/jsx-uses-react", CategoryStyle}, - {"react-hooks/rules-of-hooks", CategoryStyle}, - {"parse-error", CategoryBug}, - {"semi", CategoryStyle}, - {"unknown-rule", CategoryStyle}, - } - - for _, tt := range tests { - t.Run(tt.ruleID, func(t *testing.T) { - result := mapESLintCategory(tt.ruleID) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestESLint_Name(t *testing.T) { - e := NewESLint() - assert.Equal(t, "eslint", e.Name()) -} - -func TestESLint_Language(t *testing.T) { - e := NewESLint() - assert.Equal(t, LanguageTypeScript, e.Language()) -} -``` - -**Step 2: Run tests** - -Run: `cd scripts/ring:codereview && go test ./internal/ring:lint/... -v -run ESLint` - -**Expected output:** -``` -=== RUN TestMapESLintSeverity ---- PASS: TestMapESLintSeverity (0.00s) -=== RUN TestMapESLintCategory -... ---- PASS: TestMapESLintCategory (0.00s) -=== RUN TestESLint_Name ---- PASS: TestESLint_Name (0.00s) -=== RUN TestESLint_Language ---- PASS: TestESLint_Language (0.00s) -PASS -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/eslint_test.go -git commit -m "test(ring:codereview): add unit tests for ESLint parser" -``` - -**If Task Fails:** - -1. **Tests fail:** - - Check: Test assertions match implementation - - Fix: Verify mapping functions - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/eslint_test.go` - ---- - -## Task 20: Add Unit Tests: ruff Parser - -**Files:** -- Create: `scripts/ring:codereview/internal/ring:lint/ruff_test.go` - -**Prerequisites:** -- Task 19 completed - -**Step 1: Write parser tests** - -Create file `scripts/ring:codereview/internal/ring:lint/ruff_test.go`: - -```go -package lint - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMapRuffSeverity(t *testing.T) { - tests := []struct { - code string - expected Severity - }{ - {"S101", SeverityHigh}, // Security - {"S501", SeverityHigh}, // Security - {"E501", SeverityWarning}, // Errors - {"F401", SeverityWarning}, // Pyflakes - {"W503", SeverityWarning}, // Warnings - {"B001", SeverityWarning}, // Bugbear - {"I001", SeverityInfo}, // Import sorting - {"D100", SeverityInfo}, // Docstring - {"N801", SeverityInfo}, // Naming - } - - for _, tt := range tests { - t.Run(tt.code, func(t *testing.T) { - result := mapRuffSeverity(tt.code) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestMapRuffCategory(t *testing.T) { - tests := []struct { - code string - expected Category - }{ - {"S101", CategorySecurity}, - {"S501", CategorySecurity}, - {"F401", CategoryBug}, - {"F841", CategoryBug}, - {"E501", CategoryStyle}, - {"E302", CategoryStyle}, - {"W503", CategoryStyle}, - {"W291", CategoryStyle}, - {"B001", CategoryBug}, - {"B007", CategoryBug}, - {"I001", CategoryStyle}, - {"UP001", CategoryDeprecation}, - {"UP035", CategoryDeprecation}, - {"C901", CategoryComplexity}, - {"D100", CategoryOther}, - } - - for _, tt := range tests { - t.Run(tt.code, func(t *testing.T) { - result := mapRuffCategory(tt.code) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestRuff_Name(t *testing.T) { - r := NewRuff() - assert.Equal(t, "ruff", r.Name()) -} - -func TestRuff_Language(t *testing.T) { - r := NewRuff() - assert.Equal(t, LanguagePython, r.Language()) -} -``` - -**Step 2: Run tests** - -Run: `cd scripts/ring:codereview && go test ./internal/ring:lint/... -v -run Ruff` - -**Expected output:** -``` -=== RUN TestMapRuffSeverity -... ---- PASS: TestMapRuffSeverity (0.00s) -=== RUN TestMapRuffCategory -... ---- PASS: TestMapRuffCategory (0.00s) -=== RUN TestRuff_Name ---- PASS: TestRuff_Name (0.00s) -=== RUN TestRuff_Language ---- PASS: TestRuff_Language (0.00s) -PASS -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/ring:lint/ruff_test.go -git commit -m "test(ring:codereview): add unit tests for ruff parser" -``` - -**If Task Fails:** - -1. **Tests fail:** - - Check: Test assertions match implementation - - Fix: Verify mapping functions - - Rollback: `git checkout -- scripts/ring:codereview/internal/ring:lint/ruff_test.go` - ---- - -## Task 21: Integration Test - -**Files:** -- Create: `scripts/ring:codereview/testdata/scope.json` (test fixture) -- Create: `scripts/ring:codereview/integration_test.go` - -**Prerequisites:** -- Task 20 completed - -**Step 1: Create test fixture directory** - -```bash -mkdir -p scripts/ring:codereview/testdata -``` - -**Step 2: Create test scope.json** - -Create file `scripts/ring:codereview/testdata/scope.json`: - -```json -{ - "base_ref": "main", - "head_ref": "HEAD", - "language": "go", - "files": { - "go": { - "modified": ["internal/handler/user.go"], - "added": ["internal/service/notification.go"], - "deleted": [] - } - }, - "stats": { - "total_files": 2, - "total_additions": 100, - "total_deletions": 10 - }, - "packages_affected": { - "go": ["./internal/handler", "./internal/service"] - } -} -``` - -**Step 3: Create integration test** - -Create file `scripts/ring:codereview/integration_test.go`: - -```go -//go:build integration - -package main - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/LerianStudio/ring/scripts/ring:codereview/internal/ring:lint" - "github.com/LerianStudio/ring/scripts/ring:codereview/internal/scope" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestScopeReader(t *testing.T) { - scopePath := filepath.Join("testdata", "scope.json") - s, err := scope.ReadScope(scopePath) - - require.NoError(t, err) - assert.Equal(t, "main", s.BaseRef) - assert.Equal(t, "HEAD", s.HeadRef) - assert.Equal(t, "go", s.Language) - assert.Equal(t, lint.LanguageGo, s.GetLanguage()) - - files := s.GetAllFiles("go") - assert.Len(t, files, 2) - assert.Contains(t, files, "internal/handler/user.go") - assert.Contains(t, files, "internal/service/notification.go") - - fileMap := s.GetAllFilesMap() - assert.True(t, fileMap["internal/handler/user.go"]) - assert.True(t, fileMap["internal/service/notification.go"]) - assert.False(t, fileMap["nonexistent.go"]) - - packages := s.GetPackages("go") - assert.Len(t, packages, 2) -} - -func TestLinterRegistry(t *testing.T) { - ctx := context.Background() - registry := lint.NewRegistry() - - // Register all linters - registry.Register(lint.NewGolangciLint()) - registry.Register(lint.NewStaticcheck()) - registry.Register(lint.NewGosec()) - registry.Register(lint.NewTSC()) - registry.Register(lint.NewESLint()) - registry.Register(lint.NewRuff()) - registry.Register(lint.NewMypy()) - registry.Register(lint.NewPylint()) - registry.Register(lint.NewBandit()) - - // Check Go linters registered - goLinters := registry.GetLinters(lint.LanguageGo) - assert.Len(t, goLinters, 3) - - // Check TS linters registered - tsLinters := registry.GetLinters(lint.LanguageTypeScript) - assert.Len(t, tsLinters, 2) - - // Check Python linters registered - pyLinters := registry.GetLinters(lint.LanguagePython) - assert.Len(t, pyLinters, 4) - - // Available linters depend on what's installed - availableGo := registry.GetAvailableLinters(ctx, lint.LanguageGo) - t.Logf("Available Go linters: %d", len(availableGo)) - for _, l := range availableGo { - t.Logf(" - %s", l.Name()) - } -} - -func TestResultAggregation(t *testing.T) { - result := lint.NewResult() - - // Simulate findings from multiple tools - result.AddFinding(lint.Finding{ - Tool: "golangci-lint", - Rule: "SA1019", - Severity: lint.SeverityWarning, - File: "internal/handler/user.go", - Line: 45, - Column: 12, - Message: "deprecated API", - Category: lint.CategoryDeprecation, - }) - - result.AddFinding(lint.Finding{ - Tool: "gosec", - Rule: "G401", - Severity: lint.SeverityHigh, - File: "internal/handler/user.go", - Line: 67, - Column: 8, - Message: "weak crypto", - Category: lint.CategorySecurity, - }) - - // Verify aggregation - assert.Len(t, result.Findings, 2) - assert.Equal(t, 0, result.Summary.Critical) - assert.Equal(t, 1, result.Summary.High) - assert.Equal(t, 1, result.Summary.Warning) - assert.Equal(t, 0, result.Summary.Info) - - // Test filtering - fileMap := map[string]bool{ - "internal/handler/user.go": true, - } - filtered := result.FilterByFiles(fileMap) - assert.Len(t, filtered.Findings, 2) - - // Filter to non-existent file - fileMap2 := map[string]bool{ - "other.go": true, - } - filtered2 := result.FilterByFiles(fileMap2) - assert.Len(t, filtered2.Findings, 0) -} - -func TestOutputWriterCreatesDirectory(t *testing.T) { - tmpDir := t.TempDir() - outputDir := filepath.Join(tmpDir, ".ring", "ring:codereview") - - // Verify directory doesn't exist - _, err := os.Stat(outputDir) - assert.True(t, os.IsNotExist(err)) - - // Create writer and ensure directory - // Note: We'd need to import output package, skipping for unit test -} -``` - -**Step 4: Run integration tests** - -Run: `cd scripts/ring:codereview && go test -tags=integration -v` - -**Expected output:** -``` -=== RUN TestScopeReader ---- PASS: TestScopeReader (0.00s) -=== RUN TestLinterRegistry - integration_test.go:XX: Available Go linters: X - integration_test.go:XX: - golangci-lint (if installed) ---- PASS: TestLinterRegistry (0.00s) -=== RUN TestResultAggregation ---- PASS: TestResultAggregation (0.00s) -PASS -``` - -**Step 5: Commit** - -```bash -git add scripts/ring:codereview/testdata/scope.json scripts/ring:codereview/integration_test.go -git commit -m "test(ring:codereview): add integration tests for static analysis" -``` - -**If Task Fails:** - -1. **Tests fail:** - - Check: Test file paths and JSON fixture validity - - Fix: Verify testdata directory structure - - Rollback: `rm -rf scripts/ring:codereview/testdata scripts/ring:codereview/integration_test.go` - ---- - -## Task 22: Code Review - -### Task 22: Run Code Review - -1. **Dispatch all 5 reviewers in parallel:** - - REQUIRED SUB-SKILL: Use ring:requesting-code-review - - All reviewers run simultaneously (ring:code-reviewer, ring:business-logic-reviewer, ring:security-reviewer, ring:test-reviewer, ring:nil-safety-reviewer) - - Wait for all to complete - -2. **Handle findings by severity (MANDATORY):** - -**Critical/High/Medium Issues:** -- Fix immediately (do NOT add TODO comments for these severities) -- Re-run all 5 reviewers in parallel after fixes -- Repeat until zero Critical/High/Medium issues remain - -**Low Issues:** -- Add `TODO(review):` comments in code at the relevant location -- Format: `TODO(review): [Issue description] (reported by [reviewer] on [date], severity: Low)` -- This tracks tech debt for future resolution - -**Cosmetic/Nitpick Issues:** -- Add `FIXME(nitpick):` comments in code at the relevant location -- Format: `FIXME(nitpick): [Issue description] (reported by [reviewer] on [date], severity: Cosmetic)` -- Low-priority improvements tracked inline - -3. **Proceed only when:** - - Zero Critical/High/Medium issues remain - - All Low issues have TODO(review): comments added - - All Cosmetic issues have FIXME(nitpick): comments added - ---- - -## Task 23: Build and Verify - -**Files:** -- Build: `scripts/ring:codereview/bin/static-analysis` - -**Prerequisites:** -- Task 22 completed (code review passed) - -**Step 1: Build final binary** - -Run: `cd scripts/ring:codereview && go build -o bin/static-analysis ./cmd/static-analysis/` - -**Expected output:** -``` -(no output - successful build) -``` - -**Step 2: Verify binary runs** - -Run: `scripts/ring:codereview/bin/static-analysis --help` - -**Expected output:** -``` -Usage of static-analysis: - -output string - Output directory (default: .ring/ring:codereview/) - -scope string - Path to scope.json (default: .ring/ring:codereview/scope.json) - -timeout duration - Timeout for analysis (default 5m0s) - -v Verbose output -``` - -**Step 3: Run all unit tests** - -Run: `cd scripts/ring:codereview && go test ./... -v` - -**Expected output:** -``` -=== RUN TestNewResult ---- PASS: TestNewResult (0.00s) -... -PASS -ok github.com/LerianStudio/ring/scripts/ring:codereview/internal/ring:lint X.XXXs -ok github.com/LerianStudio/ring/scripts/ring:codereview/internal/scope X.XXXs -ok github.com/LerianStudio/ring/scripts/ring:codereview/internal/output X.XXXs -``` - -**Step 4: Final commit** - -```bash -git add scripts/ring:codereview/bin/.gitkeep -git commit -m "feat(ring:codereview): complete Phase 1 static analysis implementation" -``` - -**Step 5: Tag milestone** - -```bash -git tag -a ring:codereview-phase1-complete -m "Phase 1: Static Analysis implementation complete" -``` - -**If Task Fails:** - -1. **Build fails:** - - Check: `go mod tidy` to resolve dependencies - - Fix: Address any compilation errors from previous tasks - - Rollback: Review task-by-task to find issue - -2. **Tests fail:** - - Check: Test output for specific failures - - Fix: Address test failures before proceeding - - Don't proceed until all tests pass - ---- - -## Summary - -This plan implements Phase 1: Static Analysis for the Codereview Enhancement feature. Upon completion: - -**Deliverables:** -- `scripts/ring:codereview/bin/static-analysis` - Main orchestrator binary -- `internal/ring:lint/` - 9 linter wrappers (3 Go, 2 TS, 4 Python) -- `internal/scope/` - Scope reader for Phase 0 integration -- `internal/output/` - JSON output writer -- Unit tests for all parsers and types - -**CLI Usage:** -```bash -# Run with defaults (reads .ring/ring:codereview/scope.json) -static-analysis - -# Run with custom paths -static-analysis --scope=/path/to/scope.json --output=/path/to/output/ - -# Verbose mode -static-analysis -v -``` - -**Output Files:** -- `.ring/ring:codereview/static-analysis.json` - Aggregate results -- `.ring/ring:codereview/{lang}-lint.json` - Language-specific results - -**Next Phase:** Phase 2 (AST Extraction) will build on this by adding semantic diff capabilities. diff --git a/docs/plans/2026-01-13-codereview-phase2-ast-extraction.md b/docs/plans/2026-01-13-codereview-phase2-ast-extraction.md deleted file mode 100644 index 6230025b..00000000 --- a/docs/plans/2026-01-13-codereview-phase2-ast-extraction.md +++ /dev/null @@ -1,3381 +0,0 @@ -# Phase 2: AST Extraction Implementation Plan - -## Overview - -Extract semantic changes from code (not line-level diffs) for Go, TypeScript, and Python. This phase enables reviewers to understand **what** changed semantically (functions added/modified, types changed, signatures altered) rather than just which lines differ. - -## Goals - -1. Parse code files into AST representations -2. Compare before/after ASTs to detect semantic changes -3. Generate structured JSON output for downstream consumers -4. Support Go, TypeScript, and Python as primary languages - -## Directory Structure - -``` -scripts/ring:codereview/ -├── cmd/ast-extractor/ -│ └── main.go # CLI entry point -├── internal/ast/ -│ ├── types.go # Shared types (FunctionDiff, TypeDiff, etc.) -│ ├── extractor.go # Common interface and orchestration -│ ├── golang.go # Go AST extraction -│ ├── typescript.go # TypeScript extraction (calls ts/ subprocess) -│ └── python.go # Python extraction (calls py/ subprocess) -├── ts/ -│ ├── ast-extractor.ts # TypeScript AST extraction -│ ├── package.json # Dependencies (typescript) -│ └── tsconfig.json # TypeScript config -├── py/ -│ └── ast_extractor.py # Python AST extraction -└── testdata/ - ├── go/ # Go test fixtures - ├── ts/ # TypeScript test fixtures - └── py/ # Python test fixtures -``` - -## Shared Types Schema - -```go -// Output JSON schema for all languages -type SemanticDiff struct { - Language string `json:"language"` - FilePath string `json:"file_path"` - Functions []FunctionDiff `json:"functions"` - Types []TypeDiff `json:"types"` - Imports []ImportDiff `json:"imports"` - Summary ChangeSummary `json:"summary"` -} - -type FunctionDiff struct { - Name string `json:"name"` - ChangeType string `json:"change_type"` // added, removed, modified, renamed - Before *FuncSig `json:"before,omitempty"` - After *FuncSig `json:"after,omitempty"` - BodyDiff string `json:"body_diff,omitempty"` // semantic description -} - -type FuncSig struct { - Params []Param `json:"params"` - Returns []string `json:"returns"` - Receiver string `json:"receiver,omitempty"` // Go methods - IsAsync bool `json:"is_async,omitempty"` // Python/TS - Decorators []string `json:"decorators,omitempty"` // Python - IsExported bool `json:"is_exported"` -} - -type TypeDiff struct { - Name string `json:"name"` - Kind string `json:"kind"` // struct, interface, class, type alias - ChangeType string `json:"change_type"` - Fields []FieldDiff `json:"fields,omitempty"` -} - -type ChangeSummary struct { - FunctionsAdded int `json:"functions_added"` - FunctionsRemoved int `json:"functions_removed"` - FunctionsModified int `json:"functions_modified"` - TypesAdded int `json:"types_added"` - TypesRemoved int `json:"types_removed"` - TypesModified int `json:"types_modified"` -} -``` - ---- - -## Tasks - -### Task 1: Create Directory Structure -**Time:** 2 min - -Create the base directory structure for the AST extraction system. - -```bash -mkdir -p scripts/ring:codereview/cmd/ast-extractor -mkdir -p scripts/ring:codereview/internal/ast -mkdir -p scripts/ring:codereview/ts -mkdir -p scripts/ring:codereview/py -mkdir -p scripts/ring:codereview/testdata/{go,ts,py} -``` - -**Verification:** -```bash -ls -la scripts/ring:codereview/ -# Should show: cmd/, internal/, ts/, py/, testdata/ -``` - ---- - -### Task 2: Define Shared Types (types.go) -**Time:** 5 min - -Create the shared type definitions used by all language extractors. - -**File:** `scripts/ring:codereview/internal/ast/types.go` - -```go -package ast - -// ChangeType represents the kind of change detected -type ChangeType string - -const ( - ChangeAdded ChangeType = "added" - ChangeRemoved ChangeType = "removed" - ChangeModified ChangeType = "modified" - ChangeRenamed ChangeType = "renamed" -) - -// Param represents a function parameter -type Param struct { - Name string `json:"name"` - Type string `json:"type"` -} - -// FieldDiff represents a change in a struct/class field -type FieldDiff struct { - Name string `json:"name"` - ChangeType ChangeType `json:"change_type"` - OldType string `json:"old_type,omitempty"` - NewType string `json:"new_type,omitempty"` -} - -// FuncSig represents a function signature -type FuncSig struct { - Params []Param `json:"params"` - Returns []string `json:"returns"` - Receiver string `json:"receiver,omitempty"` - IsAsync bool `json:"is_async,omitempty"` - Decorators []string `json:"decorators,omitempty"` - IsExported bool `json:"is_exported"` - StartLine int `json:"start_line"` - EndLine int `json:"end_line"` -} - -// FunctionDiff represents a change in a function -type FunctionDiff struct { - Name string `json:"name"` - ChangeType ChangeType `json:"change_type"` - Before *FuncSig `json:"before,omitempty"` - After *FuncSig `json:"after,omitempty"` - BodyDiff string `json:"body_diff,omitempty"` -} - -// TypeDiff represents a change in a type definition -type TypeDiff struct { - Name string `json:"name"` - Kind string `json:"kind"` - ChangeType ChangeType `json:"change_type"` - Fields []FieldDiff `json:"fields,omitempty"` - StartLine int `json:"start_line"` - EndLine int `json:"end_line"` -} - -// ImportDiff represents a change in imports -type ImportDiff struct { - Path string `json:"path"` - Alias string `json:"alias,omitempty"` - ChangeType ChangeType `json:"change_type"` -} - -// ChangeSummary provides counts of changes -type ChangeSummary struct { - FunctionsAdded int `json:"functions_added"` - FunctionsRemoved int `json:"functions_removed"` - FunctionsModified int `json:"functions_modified"` - TypesAdded int `json:"types_added"` - TypesRemoved int `json:"types_removed"` - TypesModified int `json:"types_modified"` - ImportsAdded int `json:"imports_added"` - ImportsRemoved int `json:"imports_removed"` -} - -// SemanticDiff represents the complete semantic diff for a file -type SemanticDiff struct { - Language string `json:"language"` - FilePath string `json:"file_path"` - Functions []FunctionDiff `json:"functions"` - Types []TypeDiff `json:"types"` - Imports []ImportDiff `json:"imports"` - Summary ChangeSummary `json:"summary"` - Error string `json:"error,omitempty"` -} - -// FilePair represents before/after versions of a file -type FilePair struct { - BeforePath string - AfterPath string - Language string -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/ast/ -``` - ---- - -### Task 3: Create Extractor Interface (extractor.go) -**Time:** 5 min - -Define the common interface that all language-specific extractors implement. - -**File:** `scripts/ring:codereview/internal/ast/extractor.go` - -```go -package ast - -import ( - "context" - "fmt" - "path/filepath" - "strings" -) - -// Extractor defines the interface for language-specific AST extractors -type Extractor interface { - // ExtractDiff compares two file versions and returns semantic differences - ExtractDiff(ctx context.Context, beforePath, afterPath string) (*SemanticDiff, error) - - // SupportedExtensions returns file extensions this extractor handles - SupportedExtensions() []string - - // Language returns the language name - Language() string -} - -// Registry holds all registered extractors -type Registry struct { - extractors map[string]Extractor -} - -// NewRegistry creates a new extractor registry -func NewRegistry() *Registry { - return &Registry{ - extractors: make(map[string]Extractor), - } -} - -// Register adds an extractor to the registry -func (r *Registry) Register(e Extractor) { - for _, ext := range e.SupportedExtensions() { - r.extractors[ext] = e - } -} - -// GetExtractor returns the appropriate extractor for a file -func (r *Registry) GetExtractor(filePath string) (Extractor, error) { - ext := strings.ToLower(filepath.Ext(filePath)) - if e, ok := r.extractors[ext]; ok { - return e, nil - } - return nil, fmt.Errorf("no extractor registered for extension: %s", ext) -} - -// ExtractAll processes multiple file pairs and returns semantic diffs -func (r *Registry) ExtractAll(ctx context.Context, pairs []FilePair) ([]SemanticDiff, error) { - results := make([]SemanticDiff, 0, len(pairs)) - - for _, pair := range pairs { - path := pair.AfterPath - if path == "" { - path = pair.BeforePath - } - - extractor, err := r.GetExtractor(path) - if err != nil { - results = append(results, SemanticDiff{ - FilePath: path, - Error: err.Error(), - }) - continue - } - - diff, err := extractor.ExtractDiff(ctx, pair.BeforePath, pair.AfterPath) - if err != nil { - results = append(results, SemanticDiff{ - FilePath: path, - Language: extractor.Language(), - Error: err.Error(), - }) - continue - } - - results = append(results, *diff) - } - - return results, nil -} - -// ComputeSummary calculates the change summary from diffs -func ComputeSummary(funcs []FunctionDiff, types []TypeDiff, imports []ImportDiff) ChangeSummary { - summary := ChangeSummary{} - - for _, f := range funcs { - switch f.ChangeType { - case ChangeAdded: - summary.FunctionsAdded++ - case ChangeRemoved: - summary.FunctionsRemoved++ - case ChangeModified, ChangeRenamed: - summary.FunctionsModified++ - } - } - - for _, t := range types { - switch t.ChangeType { - case ChangeAdded: - summary.TypesAdded++ - case ChangeRemoved: - summary.TypesRemoved++ - case ChangeModified, ChangeRenamed: - summary.TypesModified++ - } - } - - for _, i := range imports { - switch i.ChangeType { - case ChangeAdded: - summary.ImportsAdded++ - case ChangeRemoved: - summary.ImportsRemoved++ - } - } - - return summary -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/ast/ -``` - ---- - -### Task 4: Implement Go AST Parser - Core Parsing (golang.go part 1) -**Time:** 5 min - -Create the Go AST extractor with core parsing functionality. - -**File:** `scripts/ring:codereview/internal/ast/golang.go` - -```go -package ast - -import ( - "bytes" - "context" - "fmt" - "go/ast" - "go/parser" - "go/printer" - "go/token" - "os" - "strings" - "unicode" -) - -// GoExtractor implements AST extraction for Go files -type GoExtractor struct{} - -// NewGoExtractor creates a new Go AST extractor -func NewGoExtractor() *GoExtractor { - return &GoExtractor{} -} - -func (g *GoExtractor) Language() string { - return "go" -} - -func (g *GoExtractor) SupportedExtensions() []string { - return []string{".go"} -} - -// ParsedFile holds parsed AST information for a Go file -type ParsedFile struct { - Fset *token.FileSet - File *ast.File - Functions map[string]*GoFunc - Types map[string]*GoType - Imports map[string]string // path -> alias -} - -// GoFunc represents a parsed Go function -type GoFunc struct { - Name string - Receiver string - Params []Param - Returns []string - IsExported bool - StartLine int - EndLine int - BodyHash string -} - -// GoType represents a parsed Go type -type GoType struct { - Name string - Kind string // struct, interface, alias - Fields []GoField - Methods []string // interface methods - IsExported bool - StartLine int - EndLine int -} - -// GoField represents a struct field -type GoField struct { - Name string - Type string - Tag string -} - -func (g *GoExtractor) parseFile(path string) (*ParsedFile, error) { - if path == "" { - return &ParsedFile{ - Functions: make(map[string]*GoFunc), - Types: make(map[string]*GoType), - Imports: make(map[string]string), - }, nil - } - - content, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) - } - - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, content, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("failed to parse Go file: %w", err) - } - - parsed := &ParsedFile{ - Fset: fset, - File: file, - Functions: make(map[string]*GoFunc), - Types: make(map[string]*GoType), - Imports: make(map[string]string), - } - - // Extract imports - for _, imp := range file.Imports { - path := strings.Trim(imp.Path.Value, `"`) - alias := "" - if imp.Name != nil { - alias = imp.Name.Name - } - parsed.Imports[path] = alias - } - - // Extract functions and types - ast.Inspect(file, func(n ast.Node) bool { - switch node := n.(type) { - case *ast.FuncDecl: - fn := g.extractFunc(fset, node) - key := fn.Name - if fn.Receiver != "" { - key = fn.Receiver + "." + fn.Name - } - parsed.Functions[key] = fn - - case *ast.GenDecl: - if node.Tok == token.TYPE { - for _, spec := range node.Specs { - if ts, ok := spec.(*ast.TypeSpec); ok { - t := g.extractType(fset, ts) - parsed.Types[t.Name] = t - } - } - } - } - return true - }) - - return parsed, nil -} - -func (g *GoExtractor) extractFunc(fset *token.FileSet, fn *ast.FuncDecl) *GoFunc { - goFn := &GoFunc{ - Name: fn.Name.Name, - IsExported: unicode.IsUpper(rune(fn.Name.Name[0])), - StartLine: fset.Position(fn.Pos()).Line, - EndLine: fset.Position(fn.End()).Line, - } - - // Extract receiver - if fn.Recv != nil && len(fn.Recv.List) > 0 { - goFn.Receiver = g.typeToString(fn.Recv.List[0].Type) - } - - // Extract parameters - if fn.Type.Params != nil { - for _, field := range fn.Type.Params.List { - typeStr := g.typeToString(field.Type) - if len(field.Names) == 0 { - goFn.Params = append(goFn.Params, Param{Type: typeStr}) - } else { - for _, name := range field.Names { - goFn.Params = append(goFn.Params, Param{ - Name: name.Name, - Type: typeStr, - }) - } - } - } - } - - // Extract return types - if fn.Type.Results != nil { - for _, field := range fn.Type.Results.List { - goFn.Returns = append(goFn.Returns, g.typeToString(field.Type)) - } - } - - // Hash the body for change detection - if fn.Body != nil { - var buf bytes.Buffer - printer.Fprint(&buf, fset, fn.Body) - goFn.BodyHash = fmt.Sprintf("%x", buf.Bytes()) - } - - return goFn -} - -func (g *GoExtractor) extractType(fset *token.FileSet, ts *ast.TypeSpec) *GoType { - goType := &GoType{ - Name: ts.Name.Name, - IsExported: unicode.IsUpper(rune(ts.Name.Name[0])), - StartLine: fset.Position(ts.Pos()).Line, - EndLine: fset.Position(ts.End()).Line, - } - - switch t := ts.Type.(type) { - case *ast.StructType: - goType.Kind = "struct" - if t.Fields != nil { - for _, field := range t.Fields.List { - typeStr := g.typeToString(field.Type) - tag := "" - if field.Tag != nil { - tag = field.Tag.Value - } - if len(field.Names) == 0 { - // Embedded field - goType.Fields = append(goType.Fields, GoField{ - Name: typeStr, - Type: typeStr, - Tag: tag, - }) - } else { - for _, name := range field.Names { - goType.Fields = append(goType.Fields, GoField{ - Name: name.Name, - Type: typeStr, - Tag: tag, - }) - } - } - } - } - - case *ast.InterfaceType: - goType.Kind = "interface" - if t.Methods != nil { - for _, method := range t.Methods.List { - if len(method.Names) > 0 { - goType.Methods = append(goType.Methods, method.Names[0].Name) - } - } - } - - default: - goType.Kind = "alias" - } - - return goType -} - -func (g *GoExtractor) typeToString(expr ast.Expr) string { - var buf bytes.Buffer - printer.Fprint(&buf, token.NewFileSet(), expr) - return buf.String() -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/ast/ -``` - ---- - -### Task 5: Implement Go AST Diff Comparison (golang.go part 2) -**Time:** 5 min - -Add the diff comparison logic to the Go extractor. - -**Append to file:** `scripts/ring:codereview/internal/ast/golang.go` - -```go -// ExtractDiff compares two Go files and returns semantic differences -func (g *GoExtractor) ExtractDiff(ctx context.Context, beforePath, afterPath string) (*SemanticDiff, error) { - before, err := g.parseFile(beforePath) - if err != nil { - return nil, fmt.Errorf("parsing before file: %w", err) - } - - after, err := g.parseFile(afterPath) - if err != nil { - return nil, fmt.Errorf("parsing after file: %w", err) - } - - diff := &SemanticDiff{ - Language: "go", - FilePath: afterPath, - } - - if afterPath == "" { - diff.FilePath = beforePath - } - - // Compare functions - diff.Functions = g.compareFunctions(before.Functions, after.Functions) - - // Compare types - diff.Types = g.compareTypes(before.Types, after.Types) - - // Compare imports - diff.Imports = g.compareImports(before.Imports, after.Imports) - - // Compute summary - diff.Summary = ComputeSummary(diff.Functions, diff.Types, diff.Imports) - - return diff, nil -} - -func (g *GoExtractor) compareFunctions(before, after map[string]*GoFunc) []FunctionDiff { - var diffs []FunctionDiff - - // Find removed and modified functions - for name, beforeFn := range before { - afterFn, exists := after[name] - if !exists { - diffs = append(diffs, FunctionDiff{ - Name: name, - ChangeType: ChangeRemoved, - Before: g.funcToSig(beforeFn), - }) - continue - } - - // Check if modified - if g.funcChanged(beforeFn, afterFn) { - diff := FunctionDiff{ - Name: name, - ChangeType: ChangeModified, - Before: g.funcToSig(beforeFn), - After: g.funcToSig(afterFn), - } - diff.BodyDiff = g.describeFuncChange(beforeFn, afterFn) - diffs = append(diffs, diff) - } - } - - // Find added functions - for name, afterFn := range after { - if _, exists := before[name]; !exists { - diffs = append(diffs, FunctionDiff{ - Name: name, - ChangeType: ChangeAdded, - After: g.funcToSig(afterFn), - }) - } - } - - return diffs -} - -func (g *GoExtractor) funcToSig(fn *GoFunc) *FuncSig { - return &FuncSig{ - Params: fn.Params, - Returns: fn.Returns, - Receiver: fn.Receiver, - IsExported: fn.IsExported, - StartLine: fn.StartLine, - EndLine: fn.EndLine, - } -} - -func (g *GoExtractor) funcChanged(before, after *GoFunc) bool { - // Check signature changes - if !g.paramsEqual(before.Params, after.Params) { - return true - } - if !g.stringsEqual(before.Returns, after.Returns) { - return true - } - if before.Receiver != after.Receiver { - return true - } - // Check body changes - if before.BodyHash != after.BodyHash { - return true - } - return false -} - -func (g *GoExtractor) describeFuncChange(before, after *GoFunc) string { - var changes []string - - if !g.paramsEqual(before.Params, after.Params) { - changes = append(changes, "parameters changed") - } - if !g.stringsEqual(before.Returns, after.Returns) { - changes = append(changes, "return types changed") - } - if before.Receiver != after.Receiver { - changes = append(changes, "receiver changed") - } - if before.BodyHash != after.BodyHash { - changes = append(changes, "implementation changed") - } - - return strings.Join(changes, ", ") -} - -func (g *GoExtractor) compareTypes(before, after map[string]*GoType) []TypeDiff { - var diffs []TypeDiff - - // Find removed and modified types - for name, beforeType := range before { - afterType, exists := after[name] - if !exists { - diffs = append(diffs, TypeDiff{ - Name: name, - Kind: beforeType.Kind, - ChangeType: ChangeRemoved, - StartLine: beforeType.StartLine, - EndLine: beforeType.EndLine, - }) - continue - } - - // Check if modified - fieldDiffs := g.compareFields(beforeType.Fields, afterType.Fields) - if len(fieldDiffs) > 0 || beforeType.Kind != afterType.Kind { - diffs = append(diffs, TypeDiff{ - Name: name, - Kind: afterType.Kind, - ChangeType: ChangeModified, - Fields: fieldDiffs, - StartLine: afterType.StartLine, - EndLine: afterType.EndLine, - }) - } - } - - // Find added types - for name, afterType := range after { - if _, exists := before[name]; !exists { - diffs = append(diffs, TypeDiff{ - Name: name, - Kind: afterType.Kind, - ChangeType: ChangeAdded, - StartLine: afterType.StartLine, - EndLine: afterType.EndLine, - }) - } - } - - return diffs -} - -func (g *GoExtractor) compareFields(before, after []GoField) []FieldDiff { - var diffs []FieldDiff - - beforeMap := make(map[string]GoField) - for _, f := range before { - beforeMap[f.Name] = f - } - - afterMap := make(map[string]GoField) - for _, f := range after { - afterMap[f.Name] = f - } - - // Find removed and modified fields - for name, beforeField := range beforeMap { - afterField, exists := afterMap[name] - if !exists { - diffs = append(diffs, FieldDiff{ - Name: name, - ChangeType: ChangeRemoved, - OldType: beforeField.Type, - }) - continue - } - - if beforeField.Type != afterField.Type { - diffs = append(diffs, FieldDiff{ - Name: name, - ChangeType: ChangeModified, - OldType: beforeField.Type, - NewType: afterField.Type, - }) - } - } - - // Find added fields - for name, afterField := range afterMap { - if _, exists := beforeMap[name]; !exists { - diffs = append(diffs, FieldDiff{ - Name: name, - ChangeType: ChangeAdded, - NewType: afterField.Type, - }) - } - } - - return diffs -} - -func (g *GoExtractor) compareImports(before, after map[string]string) []ImportDiff { - var diffs []ImportDiff - - for path, alias := range before { - if _, exists := after[path]; !exists { - diffs = append(diffs, ImportDiff{ - Path: path, - Alias: alias, - ChangeType: ChangeRemoved, - }) - } - } - - for path, alias := range after { - if _, exists := before[path]; !exists { - diffs = append(diffs, ImportDiff{ - Path: path, - Alias: alias, - ChangeType: ChangeAdded, - }) - } - } - - return diffs -} - -func (g *GoExtractor) paramsEqual(a, b []Param) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i].Name != b[i].Name || a[i].Type != b[i].Type { - return false - } - } - return true -} - -func (g *GoExtractor) stringsEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/ast/ -``` - ---- - -### Task 6: Create Go Test Fixtures -**Time:** 3 min - -Create test fixtures for the Go AST extractor. - -**File:** `scripts/ring:codereview/testdata/go/before.go` - -```go -package example - -import ( - "fmt" - "strings" -) - -// User represents a user in the system -type User struct { - ID int - Name string -} - -// Greeter interface for greeting -type Greeter interface { - Greet() string -} - -// Hello returns a greeting message -func Hello(name string) string { - return fmt.Sprintf("Hello, %s!", name) -} - -// (u *User) GetName returns the user's name -func (u *User) GetName() string { - return u.Name -} - -// FormatName formats a name with optional prefix -func FormatName(name string) string { - return strings.Title(name) -} -``` - -**File:** `scripts/ring:codereview/testdata/go/after.go` - -```go -package example - -import ( - "context" - "fmt" -) - -// User represents a user in the system -type User struct { - ID int - Name string - Email string // Added field - IsActive bool // Added field -} - -// Greeter interface for greeting -type Greeter interface { - Greet() string - GreetWithContext(ctx context.Context) string // Added method -} - -// Config is a new type -type Config struct { - Debug bool - Timeout int -} - -// Hello returns a greeting message (signature changed) -func Hello(ctx context.Context, name string) (string, error) { - if name == "" { - return "", fmt.Errorf("name is required") - } - return fmt.Sprintf("Hello, %s!", name), nil -} - -// (u *User) GetName returns the user's name -func (u *User) GetName() string { - return u.Name -} - -// (u *User) GetEmail is a new method -func (u *User) GetEmail() string { - return u.Email -} - -// NewGreeting is a new function -func NewGreeting(prefix, name string) string { - return fmt.Sprintf("%s, %s!", prefix, name) -} -``` - -**Verification:** -```bash -ls scripts/ring:codereview/testdata/go/ -# Should show: before.go, after.go -``` - ---- - -### Task 7: Add Go Extractor Unit Tests -**Time:** 5 min - -Create unit tests for the Go AST extractor. - -**File:** `scripts/ring:codereview/internal/ast/golang_test.go` - -```go -package ast - -import ( - "context" - "path/filepath" - "testing" -) - -func TestGoExtractor_ExtractDiff(t *testing.T) { - extractor := NewGoExtractor() - - beforePath := filepath.Join("..", "..", "testdata", "go", "before.go") - afterPath := filepath.Join("..", "..", "testdata", "go", "after.go") - - diff, err := extractor.ExtractDiff(context.Background(), beforePath, afterPath) - if err != nil { - t.Fatalf("ExtractDiff failed: %v", err) - } - - // Verify language - if diff.Language != "go" { - t.Errorf("expected language 'go', got '%s'", diff.Language) - } - - // Verify function changes - funcChanges := make(map[string]ChangeType) - for _, f := range diff.Functions { - funcChanges[f.Name] = f.ChangeType - } - - // Hello should be modified (signature changed) - if ct, ok := funcChanges["Hello"]; !ok || ct != ChangeModified { - t.Errorf("expected Hello to be modified, got %v", funcChanges["Hello"]) - } - - // FormatName should be removed - if ct, ok := funcChanges["FormatName"]; !ok || ct != ChangeRemoved { - t.Errorf("expected FormatName to be removed, got %v", funcChanges["FormatName"]) - } - - // NewGreeting should be added - if ct, ok := funcChanges["NewGreeting"]; !ok || ct != ChangeAdded { - t.Errorf("expected NewGreeting to be added, got %v", funcChanges["NewGreeting"]) - } - - // User.GetEmail should be added - if ct, ok := funcChanges["*User.GetEmail"]; !ok || ct != ChangeAdded { - t.Errorf("expected *User.GetEmail to be added, got %v", funcChanges["*User.GetEmail"]) - } - - // Verify type changes - typeChanges := make(map[string]ChangeType) - for _, ty := range diff.Types { - typeChanges[ty.Name] = ty.ChangeType - } - - // User should be modified (fields added) - if ct, ok := typeChanges["User"]; !ok || ct != ChangeModified { - t.Errorf("expected User to be modified, got %v", typeChanges["User"]) - } - - // Config should be added - if ct, ok := typeChanges["Config"]; !ok || ct != ChangeAdded { - t.Errorf("expected Config to be added, got %v", typeChanges["Config"]) - } - - // Verify import changes - importChanges := make(map[string]ChangeType) - for _, imp := range diff.Imports { - importChanges[imp.Path] = imp.ChangeType - } - - // strings should be removed - if ct, ok := importChanges["strings"]; !ok || ct != ChangeRemoved { - t.Errorf("expected 'strings' import to be removed, got %v", importChanges["strings"]) - } - - // context should be added - if ct, ok := importChanges["context"]; !ok || ct != ChangeAdded { - t.Errorf("expected 'context' import to be added, got %v", importChanges["context"]) - } - - // Verify summary - if diff.Summary.FunctionsAdded < 2 { - t.Errorf("expected at least 2 functions added, got %d", diff.Summary.FunctionsAdded) - } - if diff.Summary.FunctionsRemoved < 1 { - t.Errorf("expected at least 1 function removed, got %d", diff.Summary.FunctionsRemoved) - } - if diff.Summary.TypesAdded < 1 { - t.Errorf("expected at least 1 type added, got %d", diff.Summary.TypesAdded) - } -} - -func TestGoExtractor_NewFile(t *testing.T) { - extractor := NewGoExtractor() - - afterPath := filepath.Join("..", "..", "testdata", "go", "after.go") - - diff, err := extractor.ExtractDiff(context.Background(), "", afterPath) - if err != nil { - t.Fatalf("ExtractDiff failed: %v", err) - } - - // All functions should be added - for _, f := range diff.Functions { - if f.ChangeType != ChangeAdded { - t.Errorf("expected function %s to be added, got %s", f.Name, f.ChangeType) - } - } -} - -func TestGoExtractor_DeletedFile(t *testing.T) { - extractor := NewGoExtractor() - - beforePath := filepath.Join("..", "..", "testdata", "go", "before.go") - - diff, err := extractor.ExtractDiff(context.Background(), beforePath, "") - if err != nil { - t.Fatalf("ExtractDiff failed: %v", err) - } - - // All functions should be removed - for _, f := range diff.Functions { - if f.ChangeType != ChangeRemoved { - t.Errorf("expected function %s to be removed, got %s", f.Name, f.ChangeType) - } - } -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go test ./internal/ast/ -v -run TestGoExtractor -``` - ---- - -### Task 8: Create TypeScript AST Extractor Package -**Time:** 3 min - -Set up the TypeScript package for AST extraction. - -**File:** `scripts/ring:codereview/ts/package.json` - -```json -{ - "name": "ast-extractor-ts", - "version": "1.0.0", - "description": "TypeScript AST extraction for semantic diffs", - "main": "dist/ast-extractor.js", - "scripts": { - "build": "tsc", - "extract": "node dist/ast-extractor.js" - }, - "dependencies": { - "typescript": "^5.3.0" - }, - "devDependencies": { - "@types/node": "^20.10.0" - } -} -``` - -**File:** `scripts/ring:codereview/ts/tsconfig.json` - -```json -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "resolveJsonModule": true - }, - "include": ["*.ts"], - "exclude": ["node_modules", "dist"] -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview/ts && npm install && npm run build -``` - ---- - -### Task 9: Implement TypeScript AST Extractor -**Time:** 5 min - -Create the TypeScript AST extraction logic using the TypeScript Compiler API. - -**File:** `scripts/ring:codereview/ts/ast-extractor.ts` - -```typescript -import * as ts from 'typescript'; -import * as fs from 'fs'; -import * as path from 'path'; - -interface Param { - name: string; - type: string; -} - -interface FuncSig { - params: Param[]; - returns: string[]; - is_async: boolean; - is_exported: boolean; - start_line: number; - end_line: number; -} - -interface FunctionDiff { - name: string; - change_type: 'added' | 'removed' | 'modified' | 'renamed'; - before?: FuncSig; - after?: FuncSig; - body_diff?: string; -} - -interface FieldDiff { - name: string; - change_type: 'added' | 'removed' | 'modified'; - old_type?: string; - new_type?: string; -} - -interface TypeDiff { - name: string; - kind: string; - change_type: 'added' | 'removed' | 'modified' | 'renamed'; - fields?: FieldDiff[]; - start_line: number; - end_line: number; -} - -interface ImportDiff { - path: string; - alias?: string; - change_type: 'added' | 'removed'; -} - -interface ChangeSummary { - functions_added: number; - functions_removed: number; - functions_modified: number; - types_added: number; - types_removed: number; - types_modified: number; - imports_added: number; - imports_removed: number; -} - -interface SemanticDiff { - language: string; - file_path: string; - functions: FunctionDiff[]; - types: TypeDiff[]; - imports: ImportDiff[]; - summary: ChangeSummary; - error?: string; -} - -interface ParsedFunc { - name: string; - params: Param[]; - returns: string[]; - isAsync: boolean; - isExported: boolean; - startLine: number; - endLine: number; - bodyText: string; -} - -interface ParsedType { - name: string; - kind: string; - fields: Map; - isExported: boolean; - startLine: number; - endLine: number; -} - -interface ParsedFile { - functions: Map; - types: Map; - imports: Map; -} - -function parseFile(filePath: string): ParsedFile { - const result: ParsedFile = { - functions: new Map(), - types: new Map(), - imports: new Map(), - }; - - if (!filePath || !fs.existsSync(filePath)) { - return result; - } - - const content = fs.readFileSync(filePath, 'utf-8'); - const sourceFile = ts.createSourceFile( - filePath, - content, - ts.ScriptTarget.Latest, - true - ); - - function getLineNumber(pos: number): number { - return sourceFile.getLineAndCharacterOfPosition(pos).line + 1; - } - - function typeToString(type: ts.TypeNode | undefined): string { - if (!type) return 'any'; - return content.substring(type.pos, type.end).trim(); - } - - function isExported(node: ts.Node): boolean { - return ( - ts.canHaveModifiers(node) && - ts.getModifiers(node)?.some( - (m) => m.kind === ts.SyntaxKind.ExportKeyword - ) || false - ); - } - - function visit(node: ts.Node) { - // Extract imports - if (ts.isImportDeclaration(node)) { - const moduleSpecifier = node.moduleSpecifier; - if (ts.isStringLiteral(moduleSpecifier)) { - const importPath = moduleSpecifier.text; - let alias = ''; - if (node.importClause?.name) { - alias = node.importClause.name.text; - } - result.imports.set(importPath, alias); - } - } - - // Extract functions - if (ts.isFunctionDeclaration(node) && node.name) { - const func: ParsedFunc = { - name: node.name.text, - params: [], - returns: [], - isAsync: node.modifiers?.some( - (m) => m.kind === ts.SyntaxKind.AsyncKeyword - ) || false, - isExported: isExported(node), - startLine: getLineNumber(node.pos), - endLine: getLineNumber(node.end), - bodyText: node.body ? content.substring(node.body.pos, node.body.end) : '', - }; - - node.parameters.forEach((param) => { - func.params.push({ - name: param.name.getText(sourceFile), - type: typeToString(param.type), - }); - }); - - if (node.type) { - func.returns.push(typeToString(node.type)); - } - - result.functions.set(func.name, func); - } - - // Extract arrow functions assigned to const - if (ts.isVariableStatement(node)) { - const exported = isExported(node); - node.declarationList.declarations.forEach((decl) => { - if ( - ts.isIdentifier(decl.name) && - decl.initializer && - ts.isArrowFunction(decl.initializer) - ) { - const arrow = decl.initializer; - const func: ParsedFunc = { - name: decl.name.text, - params: [], - returns: [], - isAsync: arrow.modifiers?.some( - (m) => m.kind === ts.SyntaxKind.AsyncKeyword - ) || false, - isExported: exported, - startLine: getLineNumber(node.pos), - endLine: getLineNumber(node.end), - bodyText: content.substring(arrow.body.pos, arrow.body.end), - }; - - arrow.parameters.forEach((param) => { - func.params.push({ - name: param.name.getText(sourceFile), - type: typeToString(param.type), - }); - }); - - if (arrow.type) { - func.returns.push(typeToString(arrow.type)); - } - - result.functions.set(func.name, func); - } - }); - } - - // Extract interfaces - if (ts.isInterfaceDeclaration(node)) { - const parsedType: ParsedType = { - name: node.name.text, - kind: 'interface', - fields: new Map(), - isExported: isExported(node), - startLine: getLineNumber(node.pos), - endLine: getLineNumber(node.end), - }; - - node.members.forEach((member) => { - if (ts.isPropertySignature(member) && member.name) { - const name = member.name.getText(sourceFile); - const type = typeToString(member.type); - parsedType.fields.set(name, type); - } - }); - - result.types.set(parsedType.name, parsedType); - } - - // Extract type aliases - if (ts.isTypeAliasDeclaration(node)) { - const parsedType: ParsedType = { - name: node.name.text, - kind: 'type', - fields: new Map(), - isExported: isExported(node), - startLine: getLineNumber(node.pos), - endLine: getLineNumber(node.end), - }; - - if (ts.isTypeLiteralNode(node.type)) { - node.type.members.forEach((member) => { - if (ts.isPropertySignature(member) && member.name) { - const name = member.name.getText(sourceFile); - const type = typeToString(member.type); - parsedType.fields.set(name, type); - } - }); - } - - result.types.set(parsedType.name, parsedType); - } - - // Extract classes - if (ts.isClassDeclaration(node) && node.name) { - const parsedType: ParsedType = { - name: node.name.text, - kind: 'class', - fields: new Map(), - isExported: isExported(node), - startLine: getLineNumber(node.pos), - endLine: getLineNumber(node.end), - }; - - node.members.forEach((member) => { - if (ts.isPropertyDeclaration(member) && member.name) { - const name = member.name.getText(sourceFile); - const type = typeToString(member.type); - parsedType.fields.set(name, type); - } - // Extract class methods as functions - if (ts.isMethodDeclaration(member) && member.name) { - const methodName = `${node.name!.text}.${member.name.getText(sourceFile)}`; - const func: ParsedFunc = { - name: methodName, - params: [], - returns: [], - isAsync: member.modifiers?.some( - (m) => m.kind === ts.SyntaxKind.AsyncKeyword - ) || false, - isExported: isExported(node), - startLine: getLineNumber(member.pos), - endLine: getLineNumber(member.end), - bodyText: member.body ? content.substring(member.body.pos, member.body.end) : '', - }; - - member.parameters.forEach((param) => { - func.params.push({ - name: param.name.getText(sourceFile), - type: typeToString(param.type), - }); - }); - - if (member.type) { - func.returns.push(typeToString(member.type)); - } - - result.functions.set(func.name, func); - } - }); - - result.types.set(parsedType.name, parsedType); - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return result; -} - -function compareFunctions( - before: Map, - after: Map -): FunctionDiff[] { - const diffs: FunctionDiff[] = []; - - // Find removed and modified - before.forEach((beforeFunc, name) => { - const afterFunc = after.get(name); - if (!afterFunc) { - diffs.push({ - name, - change_type: 'removed', - before: { - params: beforeFunc.params, - returns: beforeFunc.returns, - is_async: beforeFunc.isAsync, - is_exported: beforeFunc.isExported, - start_line: beforeFunc.startLine, - end_line: beforeFunc.endLine, - }, - }); - return; - } - - // Check for modifications - const sigChanged = - JSON.stringify(beforeFunc.params) !== JSON.stringify(afterFunc.params) || - JSON.stringify(beforeFunc.returns) !== JSON.stringify(afterFunc.returns) || - beforeFunc.isAsync !== afterFunc.isAsync; - - const bodyChanged = beforeFunc.bodyText !== afterFunc.bodyText; - - if (sigChanged || bodyChanged) { - const changes: string[] = []; - if (JSON.stringify(beforeFunc.params) !== JSON.stringify(afterFunc.params)) { - changes.push('parameters changed'); - } - if (JSON.stringify(beforeFunc.returns) !== JSON.stringify(afterFunc.returns)) { - changes.push('return type changed'); - } - if (beforeFunc.isAsync !== afterFunc.isAsync) { - changes.push('async modifier changed'); - } - if (bodyChanged) { - changes.push('implementation changed'); - } - - diffs.push({ - name, - change_type: 'modified', - before: { - params: beforeFunc.params, - returns: beforeFunc.returns, - is_async: beforeFunc.isAsync, - is_exported: beforeFunc.isExported, - start_line: beforeFunc.startLine, - end_line: beforeFunc.endLine, - }, - after: { - params: afterFunc.params, - returns: afterFunc.returns, - is_async: afterFunc.isAsync, - is_exported: afterFunc.isExported, - start_line: afterFunc.startLine, - end_line: afterFunc.endLine, - }, - body_diff: changes.join(', '), - }); - } - }); - - // Find added - after.forEach((afterFunc, name) => { - if (!before.has(name)) { - diffs.push({ - name, - change_type: 'added', - after: { - params: afterFunc.params, - returns: afterFunc.returns, - is_async: afterFunc.isAsync, - is_exported: afterFunc.isExported, - start_line: afterFunc.startLine, - end_line: afterFunc.endLine, - }, - }); - } - }); - - return diffs; -} - -function compareTypes( - before: Map, - after: Map -): TypeDiff[] { - const diffs: TypeDiff[] = []; - - before.forEach((beforeType, name) => { - const afterType = after.get(name); - if (!afterType) { - diffs.push({ - name, - kind: beforeType.kind, - change_type: 'removed', - start_line: beforeType.startLine, - end_line: beforeType.endLine, - }); - return; - } - - // Compare fields - const fieldDiffs: FieldDiff[] = []; - beforeType.fields.forEach((type, fieldName) => { - const afterFieldType = afterType.fields.get(fieldName); - if (!afterFieldType) { - fieldDiffs.push({ - name: fieldName, - change_type: 'removed', - old_type: type, - }); - } else if (afterFieldType !== type) { - fieldDiffs.push({ - name: fieldName, - change_type: 'modified', - old_type: type, - new_type: afterFieldType, - }); - } - }); - - afterType.fields.forEach((type, fieldName) => { - if (!beforeType.fields.has(fieldName)) { - fieldDiffs.push({ - name: fieldName, - change_type: 'added', - new_type: type, - }); - } - }); - - if (fieldDiffs.length > 0 || beforeType.kind !== afterType.kind) { - diffs.push({ - name, - kind: afterType.kind, - change_type: 'modified', - fields: fieldDiffs, - start_line: afterType.startLine, - end_line: afterType.endLine, - }); - } - }); - - after.forEach((afterType, name) => { - if (!before.has(name)) { - diffs.push({ - name, - kind: afterType.kind, - change_type: 'added', - start_line: afterType.startLine, - end_line: afterType.endLine, - }); - } - }); - - return diffs; -} - -function compareImports( - before: Map, - after: Map -): ImportDiff[] { - const diffs: ImportDiff[] = []; - - before.forEach((alias, importPath) => { - if (!after.has(importPath)) { - diffs.push({ - path: importPath, - alias: alias || undefined, - change_type: 'removed', - }); - } - }); - - after.forEach((alias, importPath) => { - if (!before.has(importPath)) { - diffs.push({ - path: importPath, - alias: alias || undefined, - change_type: 'added', - }); - } - }); - - return diffs; -} - -function extractDiff(beforePath: string, afterPath: string): SemanticDiff { - const before = parseFile(beforePath); - const after = parseFile(afterPath); - - const functions = compareFunctions(before.functions, after.functions); - const types = compareTypes(before.types, after.types); - const imports = compareImports(before.imports, after.imports); - - const summary: ChangeSummary = { - functions_added: functions.filter((f) => f.change_type === 'added').length, - functions_removed: functions.filter((f) => f.change_type === 'removed').length, - functions_modified: functions.filter((f) => f.change_type === 'modified').length, - types_added: types.filter((t) => t.change_type === 'added').length, - types_removed: types.filter((t) => t.change_type === 'removed').length, - types_modified: types.filter((t) => t.change_type === 'modified').length, - imports_added: imports.filter((i) => i.change_type === 'added').length, - imports_removed: imports.filter((i) => i.change_type === 'removed').length, - }; - - return { - language: 'typescript', - file_path: afterPath || beforePath, - functions, - types, - imports, - summary, - }; -} - -// CLI entry point -function main() { - const args = process.argv.slice(2); - if (args.length < 2) { - console.error('Usage: ast-extractor.ts '); - console.error('Use empty string "" for new/deleted files'); - process.exit(1); - } - - const beforePath = args[0] === '""' || args[0] === '' ? '' : args[0]; - const afterPath = args[1] === '""' || args[1] === '' ? '' : args[1]; - - try { - const diff = extractDiff(beforePath, afterPath); - console.log(JSON.stringify(diff, null, 2)); - } catch (error) { - const diff: SemanticDiff = { - language: 'typescript', - file_path: afterPath || beforePath, - functions: [], - types: [], - imports: [], - summary: { - functions_added: 0, - functions_removed: 0, - functions_modified: 0, - types_added: 0, - types_removed: 0, - types_modified: 0, - imports_added: 0, - imports_removed: 0, - }, - error: error instanceof Error ? error.message : String(error), - }; - console.log(JSON.stringify(diff, null, 2)); - process.exit(1); - } -} - -main(); -``` - -**Verification:** -```bash -cd scripts/ring:codereview/ts && npm run build -``` - ---- - -### Task 10: Create TypeScript Test Fixtures -**Time:** 3 min - -Create test fixtures for the TypeScript AST extractor. - -**File:** `scripts/ring:codereview/testdata/ts/before.ts` - -```typescript -import { useState } from 'react'; -import axios from 'axios'; - -export interface User { - id: number; - name: string; -} - -export type Status = 'active' | 'inactive'; - -export function greet(name: string): string { - return `Hello, ${name}!`; -} - -export async function fetchUser(id: number): Promise { - const response = await axios.get(`/users/${id}`); - return response.data; -} - -export const formatName = (name: string): string => { - return name.trim().toUpperCase(); -}; - -export class UserService { - private baseUrl: string; - - constructor(baseUrl: string) { - this.baseUrl = baseUrl; - } - - async getUser(id: number): Promise { - const response = await axios.get(`${this.baseUrl}/users/${id}`); - return response.data; - } -} -``` - -**File:** `scripts/ring:codereview/testdata/ts/after.ts` - -```typescript -import { useState, useEffect } from 'react'; -import { api } from './api'; - -export interface User { - id: number; - name: string; - email: string; // Added field - isActive: boolean; // Added field -} - -export type Status = 'active' | 'inactive' | 'pending'; // Added 'pending' - -export interface Config { // New interface - debug: boolean; - timeout: number; -} - -export function greet(name: string, greeting?: string): string { // Added parameter - return `${greeting || 'Hello'}, ${name}!`; -} - -export async function fetchUser(id: number, options?: Config): Promise { // Added parameter - const response = await api.get(`/users/${id}`, options); - return response.data; -} - -// formatName removed - -export const validateEmail = (email: string): boolean => { // New function - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -}; - -export class UserService { - private baseUrl: string; - private config: Config; // Added field - - constructor(baseUrl: string, config: Config) { // Changed signature - this.baseUrl = baseUrl; - this.config = config; - } - - async getUser(id: number): Promise { - const response = await api.get(`${this.baseUrl}/users/${id}`); - return response.data; - } - - async updateUser(id: number, data: Partial): Promise { // New method - const response = await api.put(`${this.baseUrl}/users/${id}`, data); - return response.data; - } -} -``` - -**Verification:** -```bash -ls scripts/ring:codereview/testdata/ts/ -# Should show: before.ts, after.ts -``` - ---- - -### Task 11: Implement Python AST Extractor -**Time:** 5 min - -Create the Python AST extraction script. - -**File:** `scripts/ring:codereview/py/ast_extractor.py` - -```python -#!/usr/bin/env python3 -""" -Python AST Extractor for Semantic Diffs. - -Extracts functions, classes, and imports from Python files -and compares before/after versions to generate semantic diffs. -""" - -import ast -import json -import sys -from dataclasses import dataclass, field, asdict -from pathlib import Path -from typing import Optional - - -@dataclass -class Param: - name: str - type: str = "" - - -@dataclass -class FuncSig: - params: list[Param] - returns: list[str] - is_async: bool = False - decorators: list[str] = field(default_factory=list) - is_exported: bool = True - start_line: int = 0 - end_line: int = 0 - - -@dataclass -class FunctionDiff: - name: str - change_type: str # added, removed, modified, renamed - before: Optional[FuncSig] = None - after: Optional[FuncSig] = None - body_diff: str = "" - - -@dataclass -class FieldDiff: - name: str - change_type: str - old_type: str = "" - new_type: str = "" - - -@dataclass -class TypeDiff: - name: str - kind: str # class, dataclass - change_type: str - fields: list[FieldDiff] = field(default_factory=list) - start_line: int = 0 - end_line: int = 0 - - -@dataclass -class ImportDiff: - path: str - alias: str = "" - change_type: str = "" - - -@dataclass -class ChangeSummary: - functions_added: int = 0 - functions_removed: int = 0 - functions_modified: int = 0 - types_added: int = 0 - types_removed: int = 0 - types_modified: int = 0 - imports_added: int = 0 - imports_removed: int = 0 - - -@dataclass -class SemanticDiff: - language: str - file_path: str - functions: list[FunctionDiff] - types: list[TypeDiff] - imports: list[ImportDiff] - summary: ChangeSummary - error: str = "" - - -@dataclass -class ParsedFunc: - name: str - params: list[Param] - returns: list[str] - is_async: bool - decorators: list[str] - is_exported: bool - start_line: int - end_line: int - body_hash: str - - -@dataclass -class ParsedClass: - name: str - is_dataclass: bool - fields: dict[str, str] # name -> type - methods: list[str] - is_exported: bool - start_line: int - end_line: int - - -@dataclass -class ParsedFile: - functions: dict[str, ParsedFunc] - classes: dict[str, ParsedClass] - imports: dict[str, str] # module -> alias - - -def get_annotation_str(node: Optional[ast.expr]) -> str: - """Convert an annotation AST node to string.""" - if node is None: - return "" - return ast.unparse(node) - - -def parse_file(file_path: str) -> ParsedFile: - """Parse a Python file and extract semantic information.""" - result = ParsedFile(functions={}, classes={}, imports={}) - - if not file_path or not Path(file_path).exists(): - return result - - content = Path(file_path).read_text() - try: - tree = ast.parse(content) - except SyntaxError as e: - return result - - for node in ast.walk(tree): - # Extract imports - if isinstance(node, ast.Import): - for alias in node.names: - result.imports[alias.name] = alias.asname or "" - - elif isinstance(node, ast.ImportFrom): - module = node.module or "" - for alias in node.names: - key = f"{module}.{alias.name}" if module else alias.name - result.imports[key] = alias.asname or "" - - # Process top-level definitions - for node in ast.iter_child_nodes(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - func = _parse_function(node, content) - result.functions[func.name] = func - - elif isinstance(node, ast.ClassDef): - cls = _parse_class(node, content) - result.classes[cls.name] = cls - - # Extract methods as functions - for item in node.body: - if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): - method = _parse_function(item, content) - method.name = f"{cls.name}.{method.name}" - result.functions[method.name] = method - - return result - - -def _parse_function(node: ast.FunctionDef | ast.AsyncFunctionDef, content: str) -> ParsedFunc: - """Parse a function definition.""" - params = [] - for arg in node.args.args: - params.append(Param( - name=arg.arg, - type=get_annotation_str(arg.annotation) - )) - - returns = [] - if node.returns: - returns.append(get_annotation_str(node.returns)) - - decorators = [] - for dec in node.decorator_list: - if isinstance(dec, ast.Name): - decorators.append(dec.id) - elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name): - decorators.append(dec.func.id) - elif isinstance(dec, ast.Attribute): - decorators.append(ast.unparse(dec)) - - # Hash the body for change detection - body_lines = content.split('\n')[node.lineno - 1:node.end_lineno] - body_hash = str(hash('\n'.join(body_lines))) - - return ParsedFunc( - name=node.name, - params=params, - returns=returns, - is_async=isinstance(node, ast.AsyncFunctionDef), - decorators=decorators, - is_exported=not node.name.startswith('_'), - start_line=node.lineno, - end_line=node.end_lineno or node.lineno, - body_hash=body_hash, - ) - - -def _parse_class(node: ast.ClassDef, content: str) -> ParsedClass: - """Parse a class definition.""" - is_dataclass = any( - (isinstance(d, ast.Name) and d.id == 'dataclass') or - (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == 'dataclass') - for d in node.decorator_list - ) - - fields: dict[str, str] = {} - methods: list[str] = [] - - for item in node.body: - if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): - fields[item.target.id] = get_annotation_str(item.annotation) - elif isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): - methods.append(item.name) - - return ParsedClass( - name=node.name, - is_dataclass=is_dataclass, - fields=fields, - methods=methods, - is_exported=not node.name.startswith('_'), - start_line=node.lineno, - end_line=node.end_lineno or node.lineno, - ) - - -def compare_functions( - before: dict[str, ParsedFunc], - after: dict[str, ParsedFunc] -) -> list[FunctionDiff]: - """Compare functions between before and after versions.""" - diffs = [] - - # Find removed and modified - for name, before_func in before.items(): - after_func = after.get(name) - if after_func is None: - diffs.append(FunctionDiff( - name=name, - change_type="removed", - before=FuncSig( - params=before_func.params, - returns=before_func.returns, - is_async=before_func.is_async, - decorators=before_func.decorators, - is_exported=before_func.is_exported, - start_line=before_func.start_line, - end_line=before_func.end_line, - ), - )) - continue - - # Check for modifications - changes = [] - if before_func.params != after_func.params: - changes.append("parameters changed") - if before_func.returns != after_func.returns: - changes.append("return type changed") - if before_func.is_async != after_func.is_async: - changes.append("async modifier changed") - if before_func.decorators != after_func.decorators: - changes.append("decorators changed") - if before_func.body_hash != after_func.body_hash: - changes.append("implementation changed") - - if changes: - diffs.append(FunctionDiff( - name=name, - change_type="modified", - before=FuncSig( - params=before_func.params, - returns=before_func.returns, - is_async=before_func.is_async, - decorators=before_func.decorators, - is_exported=before_func.is_exported, - start_line=before_func.start_line, - end_line=before_func.end_line, - ), - after=FuncSig( - params=after_func.params, - returns=after_func.returns, - is_async=after_func.is_async, - decorators=after_func.decorators, - is_exported=after_func.is_exported, - start_line=after_func.start_line, - end_line=after_func.end_line, - ), - body_diff=", ".join(changes), - )) - - # Find added - for name, after_func in after.items(): - if name not in before: - diffs.append(FunctionDiff( - name=name, - change_type="added", - after=FuncSig( - params=after_func.params, - returns=after_func.returns, - is_async=after_func.is_async, - decorators=after_func.decorators, - is_exported=after_func.is_exported, - start_line=after_func.start_line, - end_line=after_func.end_line, - ), - )) - - return diffs - - -def compare_classes( - before: dict[str, ParsedClass], - after: dict[str, ParsedClass] -) -> list[TypeDiff]: - """Compare classes between before and after versions.""" - diffs = [] - - for name, before_cls in before.items(): - after_cls = after.get(name) - if after_cls is None: - diffs.append(TypeDiff( - name=name, - kind="dataclass" if before_cls.is_dataclass else "class", - change_type="removed", - start_line=before_cls.start_line, - end_line=before_cls.end_line, - )) - continue - - # Compare fields - field_diffs = [] - for field_name, field_type in before_cls.fields.items(): - after_type = after_cls.fields.get(field_name) - if after_type is None: - field_diffs.append(FieldDiff( - name=field_name, - change_type="removed", - old_type=field_type, - )) - elif after_type != field_type: - field_diffs.append(FieldDiff( - name=field_name, - change_type="modified", - old_type=field_type, - new_type=after_type, - )) - - for field_name, field_type in after_cls.fields.items(): - if field_name not in before_cls.fields: - field_diffs.append(FieldDiff( - name=field_name, - change_type="added", - new_type=field_type, - )) - - if field_diffs or before_cls.is_dataclass != after_cls.is_dataclass: - diffs.append(TypeDiff( - name=name, - kind="dataclass" if after_cls.is_dataclass else "class", - change_type="modified", - fields=field_diffs, - start_line=after_cls.start_line, - end_line=after_cls.end_line, - )) - - for name, after_cls in after.items(): - if name not in before: - diffs.append(TypeDiff( - name=name, - kind="dataclass" if after_cls.is_dataclass else "class", - change_type="added", - start_line=after_cls.start_line, - end_line=after_cls.end_line, - )) - - return diffs - - -def compare_imports( - before: dict[str, str], - after: dict[str, str] -) -> list[ImportDiff]: - """Compare imports between before and after versions.""" - diffs = [] - - for path, alias in before.items(): - if path not in after: - diffs.append(ImportDiff(path=path, alias=alias, change_type="removed")) - - for path, alias in after.items(): - if path not in before: - diffs.append(ImportDiff(path=path, alias=alias, change_type="added")) - - return diffs - - -def extract_diff(before_path: str, after_path: str) -> SemanticDiff: - """Extract semantic diff between two Python files.""" - before = parse_file(before_path) - after = parse_file(after_path) - - functions = compare_functions(before.functions, after.functions) - types = compare_classes(before.classes, after.classes) - imports = compare_imports(before.imports, after.imports) - - summary = ChangeSummary( - functions_added=sum(1 for f in functions if f.change_type == "added"), - functions_removed=sum(1 for f in functions if f.change_type == "removed"), - functions_modified=sum(1 for f in functions if f.change_type == "modified"), - types_added=sum(1 for t in types if t.change_type == "added"), - types_removed=sum(1 for t in types if t.change_type == "removed"), - types_modified=sum(1 for t in types if t.change_type == "modified"), - imports_added=sum(1 for i in imports if i.change_type == "added"), - imports_removed=sum(1 for i in imports if i.change_type == "removed"), - ) - - return SemanticDiff( - language="python", - file_path=after_path or before_path, - functions=functions, - types=types, - imports=imports, - summary=summary, - ) - - -def dataclass_to_dict(obj): - """Recursively convert dataclass to dict.""" - if hasattr(obj, '__dataclass_fields__'): - result = {} - for key, value in asdict(obj).items(): - if value is None: - continue - if isinstance(value, list) and not value: - continue - if isinstance(value, str) and not value: - continue - result[key] = value - return result - elif isinstance(obj, list): - return [dataclass_to_dict(item) for item in obj] - elif isinstance(obj, dict): - return {k: dataclass_to_dict(v) for k, v in obj.items()} - return obj - - -def main(): - """CLI entry point.""" - if len(sys.argv) < 3: - print("Usage: ast_extractor.py ", file=sys.stderr) - print('Use empty string "" for new/deleted files', file=sys.stderr) - sys.exit(1) - - before_path = sys.argv[1] if sys.argv[1] not in ('""', '') else '' - after_path = sys.argv[2] if sys.argv[2] not in ('""', '') else '' - - try: - diff = extract_diff(before_path, after_path) - output = dataclass_to_dict(diff) - print(json.dumps(output, indent=2)) - except Exception as e: - error_diff = SemanticDiff( - language="python", - file_path=after_path or before_path, - functions=[], - types=[], - imports=[], - summary=ChangeSummary(), - error=str(e), - ) - print(json.dumps(dataclass_to_dict(error_diff), indent=2)) - sys.exit(1) - - -if __name__ == "__main__": - main() -``` - -**Verification:** -```bash -python3 scripts/ring:codereview/py/ast_extractor.py --help 2>&1 || true -# Should show usage message -``` - ---- - -### Task 12: Create Python Test Fixtures -**Time:** 3 min - -Create test fixtures for the Python AST extractor. - -**File:** `scripts/ring:codereview/testdata/py/before.py` - -```python -"""Example module for testing AST extraction.""" - -import os -from typing import Optional, List -from dataclasses import dataclass - -@dataclass -class User: - id: int - name: str - - -class UserService: - """Service for managing users.""" - - def __init__(self, db_url: str): - self.db_url = db_url - - def get_user(self, user_id: int) -> Optional[User]: - """Get a user by ID.""" - return None - - def list_users(self) -> List[User]: - """List all users.""" - return [] - - -def greet(name: str) -> str: - """Return a greeting message.""" - return f"Hello, {name}!" - - -async def fetch_data(url: str) -> dict: - """Fetch data from a URL.""" - return {} - - -def format_name(name: str) -> str: - """Format a name.""" - return name.strip().title() -``` - -**File:** `scripts/ring:codereview/testdata/py/after.py` - -```python -"""Example module for testing AST extraction.""" - -import logging -from typing import Optional, List, Dict -from dataclasses import dataclass, field - -@dataclass -class User: - id: int - name: str - email: str # Added field - is_active: bool = True # Added field with default - - -@dataclass -class Config: # New dataclass - debug: bool = False - timeout: int = 30 - - -class UserService: - """Service for managing users.""" - - def __init__(self, db_url: str, config: Config): # Changed signature - self.db_url = db_url - self.config = config - - def get_user(self, user_id: int) -> Optional[User]: - """Get a user by ID.""" - logging.info(f"Fetching user {user_id}") # Changed implementation - return None - - def list_users(self, active_only: bool = False) -> List[User]: # Changed signature - """List all users.""" - return [] - - async def update_user(self, user_id: int, data: Dict) -> User: # New async method - """Update a user.""" - return User(id=user_id, name="", email="") - - -def greet(name: str, greeting: str = "Hello") -> str: # Added parameter - """Return a greeting message.""" - return f"{greeting}, {name}!" - - -async def fetch_data(url: str, timeout: int = 30) -> dict: # Added parameter - """Fetch data from a URL.""" - return {} - - -# format_name removed - - -def validate_email(email: str) -> bool: # New function - """Validate an email address.""" - return "@" in email -``` - -**Verification:** -```bash -ls scripts/ring:codereview/testdata/py/ -# Should show: before.py, after.py -``` - ---- - -### Task 13: Implement TypeScript Bridge for Go (typescript.go) -**Time:** 4 min - -Create the Go bridge that calls the TypeScript extractor. - -**File:** `scripts/ring:codereview/internal/ast/typescript.go` - -```go -package ast - -import ( - "context" - "encoding/json" - "fmt" - "os/exec" - "path/filepath" -) - -// TypeScriptExtractor implements AST extraction for TypeScript files -type TypeScriptExtractor struct { - nodeExecutable string - scriptPath string -} - -// NewTypeScriptExtractor creates a new TypeScript AST extractor -func NewTypeScriptExtractor(scriptDir string) *TypeScriptExtractor { - return &TypeScriptExtractor{ - nodeExecutable: "node", - scriptPath: filepath.Join(scriptDir, "ts", "dist", "ast-extractor.js"), - } -} - -func (t *TypeScriptExtractor) Language() string { - return "typescript" -} - -func (t *TypeScriptExtractor) SupportedExtensions() []string { - return []string{".ts", ".tsx", ".js", ".jsx"} -} - -func (t *TypeScriptExtractor) ExtractDiff(ctx context.Context, beforePath, afterPath string) (*SemanticDiff, error) { - before := beforePath - if before == "" { - before = `""` - } - after := afterPath - if after == "" { - after = `""` - } - - cmd := exec.CommandContext(ctx, t.nodeExecutable, t.scriptPath, before, after) - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return nil, fmt.Errorf("typescript extractor failed: %s", string(exitErr.Stderr)) - } - return nil, fmt.Errorf("failed to run typescript extractor: %w", err) - } - - var diff SemanticDiff - if err := json.Unmarshal(output, &diff); err != nil { - return nil, fmt.Errorf("failed to parse typescript extractor output: %w", err) - } - - return &diff, nil -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/ast/ -``` - ---- - -### Task 14: Implement Python Bridge for Go (python.go) -**Time:** 4 min - -Create the Go bridge that calls the Python extractor. - -**File:** `scripts/ring:codereview/internal/ast/python.go` - -```go -package ast - -import ( - "context" - "encoding/json" - "fmt" - "os/exec" - "path/filepath" -) - -// PythonExtractor implements AST extraction for Python files -type PythonExtractor struct { - pythonExecutable string - scriptPath string -} - -// NewPythonExtractor creates a new Python AST extractor -func NewPythonExtractor(scriptDir string) *PythonExtractor { - return &PythonExtractor{ - pythonExecutable: "python3", - scriptPath: filepath.Join(scriptDir, "py", "ast_extractor.py"), - } -} - -func (p *PythonExtractor) Language() string { - return "python" -} - -func (p *PythonExtractor) SupportedExtensions() []string { - return []string{".py", ".pyi"} -} - -func (p *PythonExtractor) ExtractDiff(ctx context.Context, beforePath, afterPath string) (*SemanticDiff, error) { - before := beforePath - if before == "" { - before = `""` - } - after := afterPath - if after == "" { - after = `""` - } - - cmd := exec.CommandContext(ctx, p.pythonExecutable, p.scriptPath, before, after) - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return nil, fmt.Errorf("python extractor failed: %s", string(exitErr.Stderr)) - } - return nil, fmt.Errorf("failed to run python extractor: %w", err) - } - - var diff SemanticDiff - if err := json.Unmarshal(output, &diff); err != nil { - return nil, fmt.Errorf("failed to parse python extractor output: %w", err) - } - - return &diff, nil -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/ast/ -``` - ---- - -### Task 15: Implement Semantic Diff Renderer -**Time:** 5 min - -Create a renderer that converts AST JSON to human-readable markdown. - -**File:** `scripts/ring:codereview/internal/ast/renderer.go` - -```go -package ast - -import ( - "encoding/json" - "fmt" - "strings" -) - -// RenderMarkdown converts a SemanticDiff to markdown format -func RenderMarkdown(diff *SemanticDiff) string { - var sb strings.Builder - - sb.WriteString(fmt.Sprintf("# Semantic Changes: %s\n\n", diff.FilePath)) - sb.WriteString(fmt.Sprintf("**Language:** %s\n\n", diff.Language)) - - // Summary section - sb.WriteString("## Summary\n\n") - sb.WriteString("| Category | Added | Removed | Modified |\n") - sb.WriteString("|----------|-------|---------|----------|\n") - sb.WriteString(fmt.Sprintf("| Functions | %d | %d | %d |\n", - diff.Summary.FunctionsAdded, - diff.Summary.FunctionsRemoved, - diff.Summary.FunctionsModified)) - sb.WriteString(fmt.Sprintf("| Types | %d | %d | %d |\n", - diff.Summary.TypesAdded, - diff.Summary.TypesRemoved, - diff.Summary.TypesModified)) - sb.WriteString(fmt.Sprintf("| Imports | %d | %d | - |\n\n", - diff.Summary.ImportsAdded, - diff.Summary.ImportsRemoved)) - - // Functions section - if len(diff.Functions) > 0 { - sb.WriteString("## Functions\n\n") - for _, fn := range diff.Functions { - sb.WriteString(renderFunction(fn)) - } - } - - // Types section - if len(diff.Types) > 0 { - sb.WriteString("## Types\n\n") - for _, t := range diff.Types { - sb.WriteString(renderType(t)) - } - } - - // Imports section - if len(diff.Imports) > 0 { - sb.WriteString("## Imports\n\n") - for _, imp := range diff.Imports { - sb.WriteString(renderImport(imp)) - } - } - - return sb.String() -} - -func renderFunction(fn FunctionDiff) string { - var sb strings.Builder - - icon := getChangeIcon(fn.ChangeType) - sb.WriteString(fmt.Sprintf("### %s `%s`\n\n", icon, fn.Name)) - - switch fn.ChangeType { - case ChangeAdded: - sb.WriteString("**Status:** Added\n\n") - if fn.After != nil { - sb.WriteString("```\n") - sb.WriteString(formatSignature(fn.Name, fn.After)) - sb.WriteString("```\n\n") - } - - case ChangeRemoved: - sb.WriteString("**Status:** Removed\n\n") - if fn.Before != nil { - sb.WriteString("```\n") - sb.WriteString(formatSignature(fn.Name, fn.Before)) - sb.WriteString("```\n\n") - } - - case ChangeModified: - sb.WriteString("**Status:** Modified\n\n") - if fn.BodyDiff != "" { - sb.WriteString(fmt.Sprintf("**Changes:** %s\n\n", fn.BodyDiff)) - } - - if fn.Before != nil && fn.After != nil { - sb.WriteString("**Before:**\n```\n") - sb.WriteString(formatSignature(fn.Name, fn.Before)) - sb.WriteString("```\n\n") - sb.WriteString("**After:**\n```\n") - sb.WriteString(formatSignature(fn.Name, fn.After)) - sb.WriteString("```\n\n") - } - } - - return sb.String() -} - -func renderType(t TypeDiff) string { - var sb strings.Builder - - icon := getChangeIcon(t.ChangeType) - sb.WriteString(fmt.Sprintf("### %s `%s` (%s)\n\n", icon, t.Name, t.Kind)) - sb.WriteString(fmt.Sprintf("**Status:** %s\n", capitalizeFirst(string(t.ChangeType)))) - sb.WriteString(fmt.Sprintf("**Lines:** %d-%d\n\n", t.StartLine, t.EndLine)) - - if len(t.Fields) > 0 { - sb.WriteString("**Field Changes:**\n\n") - sb.WriteString("| Field | Change | Old Type | New Type |\n") - sb.WriteString("|-------|--------|----------|----------|\n") - for _, f := range t.Fields { - sb.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", - f.Name, f.ChangeType, f.OldType, f.NewType)) - } - sb.WriteString("\n") - } - - return sb.String() -} - -func renderImport(imp ImportDiff) string { - icon := getChangeIcon(imp.ChangeType) - alias := "" - if imp.Alias != "" { - alias = fmt.Sprintf(" as %s", imp.Alias) - } - return fmt.Sprintf("- %s `%s`%s\n", icon, imp.Path, alias) -} - -func formatSignature(name string, sig *FuncSig) string { - var params []string - for _, p := range sig.Params { - if p.Type != "" { - params = append(params, fmt.Sprintf("%s: %s", p.Name, p.Type)) - } else { - params = append(params, p.Name) - } - } - - returns := strings.Join(sig.Returns, ", ") - if returns == "" { - returns = "void" - } - - prefix := "" - if sig.IsAsync { - prefix = "async " - } - if sig.Receiver != "" { - prefix += fmt.Sprintf("(%s) ", sig.Receiver) - } - - return fmt.Sprintf("%sfunc %s(%s) -> %s\n", prefix, name, strings.Join(params, ", "), returns) -} - -func getChangeIcon(changeType ChangeType) string { - switch changeType { - case ChangeAdded: - return "+" - case ChangeRemoved: - return "-" - case ChangeModified: - return "~" - case ChangeRenamed: - return ">" - default: - return "?" - } -} - -func capitalizeFirst(s string) string { - if s == "" { - return s - } - return strings.ToUpper(s[:1]) + s[1:] -} - -// RenderJSON returns the diff as formatted JSON -func RenderJSON(diff *SemanticDiff) ([]byte, error) { - return json.MarshalIndent(diff, "", " ") -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/ast/ -``` - ---- - -### Task 16: Implement CLI Entry Point (main.go) -**Time:** 5 min - -Create the main CLI tool that orchestrates AST extraction. - -**File:** `scripts/ring:codereview/cmd/ast-extractor/main.go` - -```go -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "ring:codereview/internal/ast" -) - -var ( - beforeFile = flag.String("before", "", "Path to the before version of the file") - afterFile = flag.String("after", "", "Path to the after version of the file") - language = flag.String("lang", "", "Force language (go, typescript, python)") - outputFmt = flag.String("output", "json", "Output format: json or markdown") - scriptDir = flag.String("scripts", "", "Directory containing language scripts (ts/, py/)") - timeout = flag.Duration("timeout", 30*time.Second, "Extraction timeout") - batchFile = flag.String("batch", "", "JSON file with batch of file pairs to process") -) - -func main() { - flag.Parse() - - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func run() error { - // Determine script directory - scriptsPath := *scriptDir - if scriptsPath == "" { - // Default to relative path from executable - exe, err := os.Executable() - if err == nil { - scriptsPath = filepath.Join(filepath.Dir(exe), "..", "..") - } else { - scriptsPath = "." - } - } - - // Create registry with all extractors - registry := ast.NewRegistry() - registry.Register(ast.NewGoExtractor()) - registry.Register(ast.NewTypeScriptExtractor(scriptsPath)) - registry.Register(ast.NewPythonExtractor(scriptsPath)) - - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - - // Handle batch mode - if *batchFile != "" { - return processBatch(ctx, registry, *batchFile) - } - - // Single file mode - if *beforeFile == "" && *afterFile == "" { - return fmt.Errorf("either -before, -after, or -batch must be specified") - } - - // Determine file path for language detection - filePath := *afterFile - if filePath == "" { - filePath = *beforeFile - } - - // Get extractor - var extractor ast.Extractor - var err error - - if *language != "" { - extractor, err = getExtractorByLanguage(registry, *language, scriptsPath) - } else { - extractor, err = registry.GetExtractor(filePath) - } - - if err != nil { - return fmt.Errorf("failed to get extractor: %w", err) - } - - // Extract diff - diff, err := extractor.ExtractDiff(ctx, *beforeFile, *afterFile) - if err != nil { - return fmt.Errorf("extraction failed: %w", err) - } - - // Output result - return outputDiff(diff) -} - -func getExtractorByLanguage(registry *ast.Registry, lang string, scriptsPath string) (ast.Extractor, error) { - switch strings.ToLower(lang) { - case "go", "golang": - return ast.NewGoExtractor(), nil - case "ts", "typescript", "javascript", "js": - return ast.NewTypeScriptExtractor(scriptsPath), nil - case "py", "python": - return ast.NewPythonExtractor(scriptsPath), nil - default: - return nil, fmt.Errorf("unknown language: %s", lang) - } -} - -func processBatch(ctx context.Context, registry *ast.Registry, batchPath string) error { - data, err := os.ReadFile(batchPath) - if err != nil { - return fmt.Errorf("failed to read batch file: %w", err) - } - - var pairs []ast.FilePair - if err := json.Unmarshal(data, &pairs); err != nil { - return fmt.Errorf("failed to parse batch file: %w", err) - } - - diffs, err := registry.ExtractAll(ctx, pairs) - if err != nil { - return fmt.Errorf("batch extraction failed: %w", err) - } - - // Output all diffs - if *outputFmt == "markdown" { - for _, diff := range diffs { - fmt.Println(ast.RenderMarkdown(&diff)) - fmt.Println("---\n") - } - } else { - output, err := json.MarshalIndent(diffs, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal output: %w", err) - } - fmt.Println(string(output)) - } - - return nil -} - -func outputDiff(diff *ast.SemanticDiff) error { - if *outputFmt == "markdown" { - fmt.Println(ast.RenderMarkdown(diff)) - return nil - } - - output, err := ast.RenderJSON(diff) - if err != nil { - return fmt.Errorf("failed to marshal output: %w", err) - } - - fmt.Println(string(output)) - return nil -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build -o bin/ast-extractor ./cmd/ast-extractor/ -``` - ---- - -### Task 17: Create go.mod for the Module -**Time:** 2 min - -Initialize the Go module. - -**File:** `scripts/ring:codereview/go.mod` - -``` -module ring:codereview - -go 1.21 -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go mod tidy -``` - ---- - -### Task 18: Add Integration Test -**Time:** 5 min - -Create an integration test that exercises all extractors. - -**File:** `scripts/ring:codereview/internal/ast/integration_test.go` - -```go -//go:build integration - -package ast - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" -) - -func TestIntegration_AllExtractors(t *testing.T) { - // Skip if testdata doesn't exist - testdataDir := filepath.Join("..", "..", "testdata") - if _, err := os.Stat(testdataDir); os.IsNotExist(err) { - t.Skip("testdata directory not found") - } - - scriptsDir := filepath.Join("..", "..") - - tests := []struct { - name string - extractor Extractor - beforePath string - afterPath string - wantAdded int - wantRemoved int - }{ - { - name: "Go", - extractor: NewGoExtractor(), - beforePath: filepath.Join(testdataDir, "go", "before.go"), - afterPath: filepath.Join(testdataDir, "go", "after.go"), - wantAdded: 2, // At least NewGreeting, User.GetEmail - wantRemoved: 1, // FormatName - }, - { - name: "TypeScript", - extractor: NewTypeScriptExtractor(scriptsDir), - beforePath: filepath.Join(testdataDir, "ts", "before.ts"), - afterPath: filepath.Join(testdataDir, "ts", "after.ts"), - wantAdded: 2, // validateEmail, UserService.updateUser - wantRemoved: 1, // formatName - }, - { - name: "Python", - extractor: NewPythonExtractor(scriptsDir), - beforePath: filepath.Join(testdataDir, "py", "before.py"), - afterPath: filepath.Join(testdataDir, "py", "after.py"), - wantAdded: 2, // validate_email, UserService.update_user - wantRemoved: 1, // format_name - }, - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Skip if files don't exist - if _, err := os.Stat(tt.beforePath); os.IsNotExist(err) { - t.Skipf("before file not found: %s", tt.beforePath) - } - if _, err := os.Stat(tt.afterPath); os.IsNotExist(err) { - t.Skipf("after file not found: %s", tt.afterPath) - } - - diff, err := tt.extractor.ExtractDiff(ctx, tt.beforePath, tt.afterPath) - if err != nil { - t.Fatalf("ExtractDiff failed: %v", err) - } - - if diff.Error != "" { - t.Fatalf("diff contains error: %s", diff.Error) - } - - if diff.Summary.FunctionsAdded < tt.wantAdded { - t.Errorf("expected at least %d functions added, got %d", - tt.wantAdded, diff.Summary.FunctionsAdded) - } - - if diff.Summary.FunctionsRemoved < tt.wantRemoved { - t.Errorf("expected at least %d functions removed, got %d", - tt.wantRemoved, diff.Summary.FunctionsRemoved) - } - - // Verify markdown rendering doesn't panic - md := RenderMarkdown(diff) - if md == "" { - t.Error("markdown render returned empty string") - } - - // Verify JSON rendering - jsonBytes, err := RenderJSON(diff) - if err != nil { - t.Errorf("JSON render failed: %v", err) - } - if len(jsonBytes) == 0 { - t.Error("JSON render returned empty bytes") - } - }) - } -} - -func TestIntegration_Registry(t *testing.T) { - scriptsDir := filepath.Join("..", "..") - - registry := NewRegistry() - registry.Register(NewGoExtractor()) - registry.Register(NewTypeScriptExtractor(scriptsDir)) - registry.Register(NewPythonExtractor(scriptsDir)) - - tests := []struct { - ext string - wantLang string - }{ - {".go", "go"}, - {".ts", "typescript"}, - {".tsx", "typescript"}, - {".js", "typescript"}, - {".py", "python"}, - {".pyi", "python"}, - } - - for _, tt := range tests { - t.Run(tt.ext, func(t *testing.T) { - extractor, err := registry.GetExtractor("test" + tt.ext) - if err != nil { - t.Fatalf("GetExtractor failed: %v", err) - } - if extractor.Language() != tt.wantLang { - t.Errorf("expected language %s, got %s", tt.wantLang, extractor.Language()) - } - }) - } - - // Test unknown extension - _, err := registry.GetExtractor("test.unknown") - if err == nil { - t.Error("expected error for unknown extension") - } -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go test -tags=integration ./internal/ast/ -v -``` - ---- - -## Execution Order - -Execute tasks in this order for minimal context switching: - -1. **Setup Phase (Tasks 1-3):** ~10 min - - Create directories - - Define types - - Create extractor interface - -2. **Go Extractor (Tasks 4-7):** ~18 min - - Implement parser - - Implement diff comparison - - Create test fixtures - - Add unit tests - -3. **TypeScript Extractor (Tasks 8-10):** ~11 min - - Create package - - Implement extractor - - Create test fixtures - -4. **Python Extractor (Tasks 11-12):** ~8 min - - Implement extractor - - Create test fixtures - -5. **Bridges and Renderer (Tasks 13-15):** ~13 min - - TypeScript bridge - - Python bridge - - Markdown renderer - -6. **CLI and Tests (Tasks 16-18):** ~12 min - - CLI entry point - - go.mod setup - - Integration tests - -**Total estimated time:** ~72 min (actual may vary) - ---- - -## Verification Commands - -After completing all tasks: - -```bash -# Build everything -cd scripts/ring:codereview -go mod tidy -go build ./... - -# Build TypeScript extractor -cd ts && npm install && npm run build && cd .. - -# Run unit tests -go test ./internal/ast/ -v - -# Run integration tests (requires Node.js and Python) -go test -tags=integration ./internal/ast/ -v - -# Test CLI -./bin/ast-extractor -before testdata/go/before.go -after testdata/go/after.go -./bin/ast-extractor -before testdata/go/before.go -after testdata/go/after.go -output markdown - -# Test TypeScript directly -node ts/dist/ast-extractor.js testdata/ts/before.ts testdata/ts/after.ts - -# Test Python directly -python3 py/ast_extractor.py testdata/py/before.py testdata/py/after.py -``` - ---- - -## Dependencies - -- **Go:** 1.21+ (for go/ast, go/parser) -- **Node.js:** 18+ (for TypeScript Compiler API) -- **Python:** 3.10+ (for ast module with match statements) -- **npm packages:** typescript ^5.3.0 - ---- - -## Output Schema Reference - -All extractors produce JSON conforming to this schema: - -```json -{ - "language": "go|typescript|python", - "file_path": "/path/to/file", - "functions": [ - { - "name": "FunctionName", - "change_type": "added|removed|modified|renamed", - "before": { /* FuncSig or null */ }, - "after": { /* FuncSig or null */ }, - "body_diff": "description of changes" - } - ], - "types": [ - { - "name": "TypeName", - "kind": "struct|interface|class|type", - "change_type": "added|removed|modified", - "fields": [ - { - "name": "fieldName", - "change_type": "added|removed|modified", - "old_type": "OldType", - "new_type": "NewType" - } - ] - } - ], - "imports": [ - { - "path": "module/path", - "alias": "optionalAlias", - "change_type": "added|removed" - } - ], - "summary": { - "functions_added": 0, - "functions_removed": 0, - "functions_modified": 0, - "types_added": 0, - "types_removed": 0, - "types_modified": 0, - "imports_added": 0, - "imports_removed": 0 - }, - "error": "optional error message" -} -``` diff --git a/docs/plans/2026-01-13-codereview-phase3-call-graph-analysis.md b/docs/plans/2026-01-13-codereview-phase3-call-graph-analysis.md deleted file mode 100644 index 4fd64cae..00000000 --- a/docs/plans/2026-01-13-codereview-phase3-call-graph-analysis.md +++ /dev/null @@ -1,3165 +0,0 @@ -# Phase 3: Call Graph Analysis Implementation Plan - -> **For Agents:** REQUIRED SUB-SKILL: Use ring:executing-plans to implement this plan task-by-task. - -**Goal:** Build call graph analysis for Go, TypeScript, and Python that identifies callers, callees, and test coverage for modified functions. - -**Architecture:** Multi-language call graph analyzer that wraps external tools (callgraph/guru for Go, dependency-cruiser for TypeScript, pyan3 for Python) with a unified Go binary orchestrator. Consumes AST output from Phase 2, produces structured JSON with impact analysis. - -**Tech Stack:** -- Go 1.21+ (main binary, internal packages) -- golang.org/x/tools/go/callgraph (CHA algorithm) -- golang.org/x/tools/go/packages (package loading) -- dependency-cruiser (TypeScript) -- pyan3 + custom Python script (Python) - -**Global Prerequisites:** -- Environment: macOS/Linux, Go 1.21+, Node.js 18+, Python 3.10+ -- Tools: `go`, `node`, `npm`, `python3`, `pip` -- Access: Write access to `scripts/ring:codereview/` directory -- State: Phase 2 completed (provides `{lang}-ast.json` files) - -**Verification before starting:** -```bash -# Run ALL these commands and verify output: -go version # Expected: go version go1.21+ or higher -node --version # Expected: v18.0.0 or higher -python3 --version # Expected: Python 3.10+ or higher -ls scripts/ring:codereview/internal/ast/ # Expected: golang.go, typescript.go, python.go (from Phase 2) -``` - -## Historical Precedent - -**Query:** "call graph analysis static code review" -**Index Status:** Empty (new feature) - -No historical data available for call graph analysis. This is a new capability. -Proceeding with standard planning approach. - ---- - -## Task Overview - -| Task | Description | Time Est. | -|------|-------------|-----------| -| 1-4 | Go call graph core implementation | 20 min | -| 5-7 | TypeScript call graph wrapper | 15 min | -| 8-11 | Python call graph implementation | 20 min | -| 12-14 | Impact summary renderer | 15 min | -| 15-17 | CLI orchestrator | 15 min | -| 18-20 | Unit tests | 15 min | -| 21 | Integration test | 5 min | -| 22 | Code review checkpoint | 10 min | - -**Total estimated time:** 115 minutes - ---- - -## Task 1: Create internal/callgraph/types.go - -**Files:** -- Create: `scripts/ring:codereview/internal/callgraph/types.go` - -**Prerequisites:** -- Tools: Go 1.21+ -- Directory `scripts/ring:codereview/internal/` must exist (from Phase 2) - -**Step 1: Write the types file** - -```go -// Package callgraph provides call graph analysis for multiple languages. -package callgraph - -// CallInfo represents a single caller or callee relationship. -type CallInfo struct { - Function string `json:"function"` - File string `json:"file"` - Line int `json:"line"` - CallSite string `json:"call_site,omitempty"` -} - -// TestCoverage represents a test that covers a function. -type TestCoverage struct { - TestFunction string `json:"test_function"` - File string `json:"file"` - Line int `json:"line"` -} - -// FunctionCallGraph represents the call graph for a single modified function. -type FunctionCallGraph struct { - Function string `json:"function"` - File string `json:"file"` - Callers []CallInfo `json:"callers"` - Callees []CallInfo `json:"callees"` - TestCoverage []TestCoverage `json:"test_coverage"` -} - -// ImpactAnalysis summarizes the impact of all changes. -type ImpactAnalysis struct { - DirectCallers int `json:"direct_callers"` - TransitiveCallers int `json:"transitive_callers"` - AffectedTests int `json:"affected_tests"` - AffectedPackages []string `json:"affected_packages"` -} - -// CallGraphResult is the output schema for call graph analysis. -type CallGraphResult struct { - Language string `json:"language"` - ModifiedFunctions []FunctionCallGraph `json:"modified_functions"` - ImpactAnalysis ImpactAnalysis `json:"impact_analysis"` - TimeBudgetExceeded bool `json:"time_budget_exceeded,omitempty"` - PartialResults bool `json:"partial_results,omitempty"` - Warnings []string `json:"warnings,omitempty"` -} - -// ModifiedFunction represents a function from AST analysis (input). -type ModifiedFunction struct { - Name string `json:"name"` - File string `json:"file"` - Package string `json:"package"` - Receiver string `json:"receiver,omitempty"` -} - -// Analyzer defines the interface for language-specific call graph analyzers. -type Analyzer interface { - // Analyze builds call graph for the given modified functions. - // timeBudgetSec is the maximum time allowed (0 = no limit). - Analyze(modifiedFuncs []ModifiedFunction, timeBudgetSec int) (*CallGraphResult, error) -} -``` - -**Step 2: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/callgraph/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/callgraph/types.go -git commit -m "feat(ring:codereview): add call graph types for Phase 3" -``` - ---- - -## Task 2: Create internal/callgraph/golang.go - Package Loading - -**Files:** -- Create: `scripts/ring:codereview/internal/callgraph/golang.go` - -**Prerequisites:** -- Task 1 completed -- `go.mod` exists in `scripts/ring:codereview/` - -**Step 1: Update go.mod with dependencies** - -Run: `cd scripts/ring:codereview && go get golang.org/x/tools/go/packages golang.org/x/tools/go/callgraph/cha golang.org/x/tools/go/ssa golang.org/x/tools/go/ssa/ssautil` - -**Expected output:** -``` -go: added golang.org/x/tools v0.X.X -``` - -**Step 2: Write the Go analyzer foundation** - -```go -package callgraph - -import ( - "context" - "fmt" - "go/token" - "path/filepath" - "strings" - "time" - - "golang.org/x/tools/go/packages" - "golang.org/x/tools/go/callgraph" - "golang.org/x/tools/go/callgraph/cha" - "golang.org/x/tools/go/ssa" - "golang.org/x/tools/go/ssa/ssautil" -) - -// GoAnalyzer implements call graph analysis for Go code. -type GoAnalyzer struct { - workDir string - testDirs []string -} - -// NewGoAnalyzer creates a new Go call graph analyzer. -func NewGoAnalyzer(workDir string) *GoAnalyzer { - return &GoAnalyzer{ - workDir: workDir, - testDirs: []string{}, - } -} - -// loadPackages loads Go packages for the given patterns. -func (g *GoAnalyzer) loadPackages(ctx context.Context, patterns []string) ([]*packages.Package, error) { - cfg := &packages.Config{ - Mode: packages.NeedName | - packages.NeedFiles | - packages.NeedCompiledGoFiles | - packages.NeedImports | - packages.NeedDeps | - packages.NeedTypes | - packages.NeedSyntax | - packages.NeedTypesInfo, - Dir: g.workDir, - Context: ctx, - } - - pkgs, err := packages.Load(cfg, patterns...) - if err != nil { - return nil, fmt.Errorf("loading packages: %w", err) - } - - // Check for package errors - var errs []string - for _, pkg := range pkgs { - for _, e := range pkg.Errors { - errs = append(errs, e.Error()) - } - } - if len(errs) > 0 { - return pkgs, fmt.Errorf("package errors: %s", strings.Join(errs, "; ")) - } - - return pkgs, nil -} - -// buildSSA builds SSA form for the loaded packages. -func (g *GoAnalyzer) buildSSA(pkgs []*packages.Package) (*ssa.Program, []*ssa.Package) { - prog, ssaPkgs := ssautil.AllPackages(pkgs, ssa.InstantiateGenerics) - prog.Build() - return prog, ssaPkgs -} - -// getAffectedPackages extracts unique package patterns from modified functions. -func getAffectedPackages(funcs []ModifiedFunction) []string { - pkgSet := make(map[string]bool) - for _, f := range funcs { - if f.Package != "" { - pkgSet[f.Package] = true - } else if f.File != "" { - // Derive package from file path - dir := filepath.Dir(f.File) - pkgSet["./"+dir] = true - } - } - - patterns := make([]string, 0, len(pkgSet)) - for pkg := range pkgSet { - patterns = append(patterns, pkg) - } - return patterns -} -``` - -**Step 3: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/callgraph/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/internal/callgraph/golang.go scripts/ring:codereview/go.mod scripts/ring:codereview/go.sum -git commit -m "feat(ring:codereview): add Go call graph package loading" -``` - ---- - -## Task 3: Implement Go Call Graph Analysis - -**Files:** -- Modify: `scripts/ring:codereview/internal/callgraph/golang.go` - -**Prerequisites:** -- Task 2 completed - -**Step 1: Add the Analyze method** - -Append to `golang.go`: - -```go -// Analyze implements Analyzer interface for Go. -func (g *GoAnalyzer) Analyze(modifiedFuncs []ModifiedFunction, timeBudgetSec int) (*CallGraphResult, error) { - result := &CallGraphResult{ - Language: "go", - ModifiedFunctions: []FunctionCallGraph{}, - ImpactAnalysis: ImpactAnalysis{}, - } - - if len(modifiedFuncs) == 0 { - return result, nil - } - - // Set up context with timeout - ctx := context.Background() - var cancel context.CancelFunc - if timeBudgetSec > 0 { - ctx, cancel = context.WithTimeout(ctx, time.Duration(timeBudgetSec)*time.Second) - defer cancel() - } - - // Get affected packages - patterns := getAffectedPackages(modifiedFuncs) - if len(patterns) == 0 { - patterns = []string{"./..."} - } - - // Load packages - pkgs, err := g.loadPackages(ctx, patterns) - if err != nil { - // Return partial results if we can - result.Warnings = append(result.Warnings, fmt.Sprintf("package loading error: %v", err)) - if len(pkgs) == 0 { - return result, err - } - result.PartialResults = true - } - - // Check timeout - select { - case <-ctx.Done(): - result.TimeBudgetExceeded = true - result.PartialResults = true - result.Warnings = append(result.Warnings, "time budget exceeded during package loading") - return result, nil - default: - } - - // Build SSA - prog, _ := g.buildSSA(pkgs) - - // Build call graph using CHA algorithm (fast, conservative) - cg := cha.CallGraph(prog) - - // Find callers and callees for each modified function - affectedPkgs := make(map[string]bool) - for _, modFunc := range modifiedFuncs { - funcCG := g.analyzeFunction(cg, prog, modFunc, pkgs) - result.ModifiedFunctions = append(result.ModifiedFunctions, funcCG) - - // Track affected packages - for _, caller := range funcCG.Callers { - pkg := extractPackageFromFile(caller.File) - if pkg != "" { - affectedPkgs[pkg] = true - } - } - - result.ImpactAnalysis.DirectCallers += len(funcCG.Callers) - result.ImpactAnalysis.AffectedTests += len(funcCG.TestCoverage) - } - - // Compile affected packages - for pkg := range affectedPkgs { - result.ImpactAnalysis.AffectedPackages = append(result.ImpactAnalysis.AffectedPackages, pkg) - } - - return result, nil -} - -// analyzeFunction builds call graph info for a single function. -func (g *GoAnalyzer) analyzeFunction(cg *callgraph.Graph, prog *ssa.Program, modFunc ModifiedFunction, pkgs []*packages.Package) FunctionCallGraph { - fcg := FunctionCallGraph{ - Function: modFunc.Name, - File: modFunc.File, - Callers: []CallInfo{}, - Callees: []CallInfo{}, - TestCoverage: []TestCoverage{}, - } - - // Find the SSA function - targetFunc := g.findSSAFunction(prog, modFunc) - if targetFunc == nil { - return fcg - } - - // Get node in call graph - node := cg.Nodes[targetFunc] - if node == nil { - return fcg - } - - // Find callers (incoming edges) - for _, edge := range node.In { - if edge.Caller.Func == nil { - continue - } - caller := edge.Caller.Func - pos := prog.Fset.Position(edge.Site.Pos()) - - callInfo := CallInfo{ - Function: caller.String(), - File: pos.Filename, - Line: pos.Line, - } - - // Extract call site text if available - if edge.Site != nil { - callInfo.CallSite = edge.Site.String() - } - - fcg.Callers = append(fcg.Callers, callInfo) - - // Check if caller is a test function - if isTestFunction(caller.Name()) { - fcg.TestCoverage = append(fcg.TestCoverage, TestCoverage{ - TestFunction: caller.Name(), - File: pos.Filename, - Line: int(caller.Pos()), - }) - } - } - - // Find callees (outgoing edges) - for _, edge := range node.Out { - if edge.Callee.Func == nil { - continue - } - callee := edge.Callee.Func - pos := prog.Fset.Position(callee.Pos()) - - fcg.Callees = append(fcg.Callees, CallInfo{ - Function: callee.String(), - File: pos.Filename, - Line: pos.Line, - }) - } - - return fcg -} - -// findSSAFunction locates the SSA function matching the modified function. -func (g *GoAnalyzer) findSSAFunction(prog *ssa.Program, modFunc ModifiedFunction) *ssa.Function { - for fn := range prog.AllFunctions() { - if fn == nil { - continue - } - - // Match by name - fnName := fn.Name() - if modFunc.Receiver != "" { - // Method: check receiver type - if fn.Signature.Recv() != nil { - recvType := fn.Signature.Recv().Type().String() - if strings.Contains(recvType, modFunc.Receiver) && fnName == modFunc.Name { - return fn - } - } - } else { - // Function: match by package and name - if fnName == modFunc.Name { - pos := prog.Fset.Position(fn.Pos()) - if pos.Filename == modFunc.File || strings.HasSuffix(pos.Filename, modFunc.File) { - return fn - } - } - } - } - return nil -} - -// isTestFunction checks if a function name indicates a test function. -func isTestFunction(name string) bool { - return strings.HasPrefix(name, "Test") || - strings.HasPrefix(name, "Benchmark") || - strings.HasPrefix(name, "Example") -} - -// extractPackageFromFile extracts package name from file path. -func extractPackageFromFile(filePath string) string { - dir := filepath.Dir(filePath) - parts := strings.Split(dir, string(filepath.Separator)) - if len(parts) > 0 { - return parts[len(parts)-1] - } - return "" -} -``` - -**Step 2: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/callgraph/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/callgraph/golang.go -git commit -m "feat(ring:codereview): implement Go call graph analysis with CHA" -``` - ---- - -## Task 4: Add Transitive Caller Analysis for Go - -**Files:** -- Modify: `scripts/ring:codereview/internal/callgraph/golang.go` - -**Prerequisites:** -- Task 3 completed - -**Step 1: Add transitive caller method** - -Add before the closing brace of the file: - -```go -// findTransitiveCallers finds all callers up to a certain depth. -func (g *GoAnalyzer) findTransitiveCallers(cg *callgraph.Graph, node *callgraph.Node, maxDepth int) int { - if node == nil || maxDepth <= 0 { - return 0 - } - - visited := make(map[*callgraph.Node]bool) - count := 0 - - var visit func(n *callgraph.Node, depth int) - visit = func(n *callgraph.Node, depth int) { - if depth > maxDepth || visited[n] { - return - } - visited[n] = true - - for _, edge := range n.In { - if edge.Caller != nil && !visited[edge.Caller] { - count++ - visit(edge.Caller, depth+1) - } - } - } - - visit(node, 0) - return count -} -``` - -**Step 2: Update Analyze method to use transitive callers** - -Find this line in `Analyze`: -```go -result.ImpactAnalysis.DirectCallers += len(funcCG.Callers) -``` - -Replace with: -```go -result.ImpactAnalysis.DirectCallers += len(funcCG.Callers) - -// Count transitive callers -targetFunc := g.findSSAFunction(prog, modFunc) -if targetFunc != nil { - node := cg.Nodes[targetFunc] - result.ImpactAnalysis.TransitiveCallers += g.findTransitiveCallers(cg, node, 10) -} -``` - -**Step 3: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/callgraph/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/internal/callgraph/golang.go -git commit -m "feat(ring:codereview): add transitive caller analysis for Go" -``` - ---- - -## Task 5: Create internal/callgraph/typescript.go - -**Files:** -- Create: `scripts/ring:codereview/internal/callgraph/typescript.go` - -**Prerequisites:** -- Task 1 completed -- dependency-cruiser installed globally (`npm install -g dependency-cruiser`) - -**Step 1: Write the TypeScript analyzer** - -```go -package callgraph - -import ( - "context" - "encoding/json" - "fmt" - "os/exec" - "path/filepath" - "strings" - "time" -) - -// TypeScriptAnalyzer implements call graph analysis for TypeScript. -type TypeScriptAnalyzer struct { - workDir string -} - -// NewTypeScriptAnalyzer creates a new TypeScript call graph analyzer. -func NewTypeScriptAnalyzer(workDir string) *TypeScriptAnalyzer { - return &TypeScriptAnalyzer{workDir: workDir} -} - -// depCruiserOutput represents dependency-cruiser JSON output. -type depCruiserOutput struct { - Modules []depCruiserModule `json:"modules"` -} - -type depCruiserModule struct { - Source string `json:"source"` - Dependencies []depCruiserDependency `json:"dependencies"` -} - -type depCruiserDependency struct { - Resolved string `json:"resolved"` - ModuleType string `json:"moduleType"` -} - -// Analyze implements Analyzer interface for TypeScript. -func (t *TypeScriptAnalyzer) Analyze(modifiedFuncs []ModifiedFunction, timeBudgetSec int) (*CallGraphResult, error) { - result := &CallGraphResult{ - Language: "typescript", - ModifiedFunctions: []FunctionCallGraph{}, - ImpactAnalysis: ImpactAnalysis{}, - } - - if len(modifiedFuncs) == 0 { - return result, nil - } - - // Set up context with timeout - ctx := context.Background() - var cancel context.CancelFunc - if timeBudgetSec > 0 { - ctx, cancel = context.WithTimeout(ctx, time.Duration(timeBudgetSec)*time.Second) - defer cancel() - } - - // Get unique files from modified functions - files := make(map[string]bool) - for _, f := range modifiedFuncs { - files[f.File] = true - } - - // Run dependency-cruiser for each file - for file := range files { - deps, err := t.runDepCruiser(ctx, file) - if err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("dependency-cruiser error for %s: %v", file, err)) - continue - } - - // Find functions in this file from modifiedFuncs - for _, modFunc := range modifiedFuncs { - if modFunc.File != file { - continue - } - - fcg := FunctionCallGraph{ - Function: modFunc.Name, - File: modFunc.File, - Callers: t.findCallers(deps, file), - Callees: t.findCallees(deps, file), - TestCoverage: t.findTestCoverage(deps, file), - } - - result.ModifiedFunctions = append(result.ModifiedFunctions, fcg) - result.ImpactAnalysis.DirectCallers += len(fcg.Callers) - result.ImpactAnalysis.AffectedTests += len(fcg.TestCoverage) - } - } - - // Check if time exceeded - select { - case <-ctx.Done(): - result.TimeBudgetExceeded = true - result.PartialResults = true - default: - } - - return result, nil -} - -// runDepCruiser runs dependency-cruiser on a file and returns the output. -func (t *TypeScriptAnalyzer) runDepCruiser(ctx context.Context, file string) (*depCruiserOutput, error) { - // Run dependency-cruiser with JSON output - cmd := exec.CommandContext(ctx, "npx", "depcruise", - "--output-type", "json", - "--include-only", "^src", - file, - ) - cmd.Dir = t.workDir - - out, err := cmd.Output() - if err != nil { - // Try without npx - cmd = exec.CommandContext(ctx, "depcruise", - "--output-type", "json", - "--include-only", "^src", - file, - ) - cmd.Dir = t.workDir - out, err = cmd.Output() - if err != nil { - return nil, fmt.Errorf("running depcruise: %w", err) - } - } - - var result depCruiserOutput - if err := json.Unmarshal(out, &result); err != nil { - return nil, fmt.Errorf("parsing depcruise output: %w", err) - } - - return &result, nil -} - -// findCallers finds modules that import the given file. -func (t *TypeScriptAnalyzer) findCallers(deps *depCruiserOutput, targetFile string) []CallInfo { - var callers []CallInfo - - for _, mod := range deps.Modules { - for _, dep := range mod.Dependencies { - if dep.Resolved == targetFile || strings.HasSuffix(dep.Resolved, targetFile) { - callers = append(callers, CallInfo{ - Function: filepath.Base(mod.Source), - File: mod.Source, - Line: 0, // dependency-cruiser doesn't provide line numbers - }) - } - } - } - - return callers -} - -// findCallees finds modules that the given file imports. -func (t *TypeScriptAnalyzer) findCallees(deps *depCruiserOutput, sourceFile string) []CallInfo { - var callees []CallInfo - - for _, mod := range deps.Modules { - if mod.Source == sourceFile || strings.HasSuffix(mod.Source, sourceFile) { - for _, dep := range mod.Dependencies { - callees = append(callees, CallInfo{ - Function: filepath.Base(dep.Resolved), - File: dep.Resolved, - Line: 0, - }) - } - break - } - } - - return callees -} - -// findTestCoverage finds test files that import the given file. -func (t *TypeScriptAnalyzer) findTestCoverage(deps *depCruiserOutput, targetFile string) []TestCoverage { - var coverage []TestCoverage - - for _, mod := range deps.Modules { - // Check if this is a test file - if !isTypeScriptTestFile(mod.Source) { - continue - } - - // Check if it imports our target - for _, dep := range mod.Dependencies { - if dep.Resolved == targetFile || strings.HasSuffix(dep.Resolved, targetFile) { - coverage = append(coverage, TestCoverage{ - TestFunction: filepath.Base(mod.Source), - File: mod.Source, - Line: 0, - }) - break - } - } - } - - return coverage -} - -// isTypeScriptTestFile checks if a file is a test file. -func isTypeScriptTestFile(filename string) bool { - return strings.Contains(filename, ".test.") || - strings.Contains(filename, ".spec.") || - strings.Contains(filename, "__tests__") -} -``` - -**Step 2: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/callgraph/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/callgraph/typescript.go -git commit -m "feat(ring:codereview): add TypeScript call graph analyzer using dependency-cruiser" -``` - ---- - -## Task 6: Create TypeScript Function-Level Call Graph Helper - -**Files:** -- Create: `scripts/ring:codereview/ts/call-graph.ts` - -**Prerequisites:** -- Task 5 completed -- Node.js 18+ and npm available - -**Step 1: Create ts directory and package.json if not exists** - -Run: `mkdir -p scripts/ring:codereview/ts` - -**Step 2: Write package.json** - -Create `scripts/ring:codereview/ts/package.json`: - -```json -{ - "name": "ring:codereview-ts-helpers", - "version": "1.0.0", - "description": "TypeScript helpers for ring:codereview analysis", - "main": "call-graph.js", - "scripts": { - "build": "tsc", - "call-graph": "node call-graph.js" - }, - "dependencies": { - "typescript": "^5.0.0" - }, - "devDependencies": { - "@types/node": "^20.0.0" - } -} -``` - -**Step 3: Write call-graph.ts** - -Create `scripts/ring:codereview/ts/call-graph.ts`: - -```typescript -import * as ts from 'typescript'; -import * as fs from 'fs'; -import * as path from 'path'; - -interface CallSite { - caller: string; - callee: string; - file: string; - line: number; - column: number; -} - -interface FunctionInfo { - name: string; - file: string; - line: number; - calls: CallSite[]; - calledBy: CallSite[]; -} - -/** - * Analyzes TypeScript files to build a function-level call graph. - */ -function analyzeCallGraph(files: string[], targetFunctions: string[]): Map { - const result = new Map(); - const program = ts.createProgram(files, { - target: ts.ScriptTarget.ES2020, - module: ts.ModuleKind.CommonJS, - allowJs: true, - }); - - const checker = program.getTypeChecker(); - - for (const sourceFile of program.getSourceFiles()) { - if (sourceFile.isDeclarationFile) continue; - - visitNode(sourceFile, sourceFile, checker, result, targetFunctions); - } - - return result; -} - -function visitNode( - node: ts.Node, - sourceFile: ts.SourceFile, - checker: ts.TypeChecker, - result: Map, - targetFunctions: string[] -): void { - // Track function declarations - if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) { - const name = node.name?.getText(sourceFile) || ''; - const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); - - if (!result.has(name)) { - result.set(name, { - name, - file: sourceFile.fileName, - line: line + 1, - calls: [], - calledBy: [], - }); - } - } - - // Track call expressions - if (ts.isCallExpression(node)) { - const callee = getCalleeText(node, sourceFile); - const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); - - // Find the containing function - const containingFunc = findContainingFunction(node); - const caller = containingFunc?.name?.getText(sourceFile) || ''; - - // Update callee info - if (result.has(callee)) { - result.get(callee)!.calledBy.push({ - caller, - callee, - file: sourceFile.fileName, - line: line + 1, - column: character + 1, - }); - } - - // Update caller info - if (result.has(caller)) { - result.get(caller)!.calls.push({ - caller, - callee, - file: sourceFile.fileName, - line: line + 1, - column: character + 1, - }); - } - } - - ts.forEachChild(node, child => visitNode(child, sourceFile, checker, result, targetFunctions)); -} - -function getCalleeText(node: ts.CallExpression, sourceFile: ts.SourceFile): string { - const expr = node.expression; - if (ts.isIdentifier(expr)) { - return expr.getText(sourceFile); - } - if (ts.isPropertyAccessExpression(expr)) { - return expr.name.getText(sourceFile); - } - return ''; -} - -function findContainingFunction(node: ts.Node): ts.FunctionDeclaration | ts.MethodDeclaration | undefined { - let current: ts.Node | undefined = node.parent; - while (current) { - if (ts.isFunctionDeclaration(current) || ts.isMethodDeclaration(current)) { - return current; - } - current = current.parent; - } - return undefined; -} - -// CLI interface -if (require.main === module) { - const args = process.argv.slice(2); - if (args.length < 1) { - console.error('Usage: call-graph.ts [file2...] [--functions func1,func2]'); - process.exit(1); - } - - const funcIndex = args.indexOf('--functions'); - let targetFunctions: string[] = []; - let files: string[] = args; - - if (funcIndex !== -1) { - targetFunctions = args[funcIndex + 1]?.split(',') || []; - files = args.slice(0, funcIndex); - } - - const result = analyzeCallGraph(files, targetFunctions); - console.log(JSON.stringify(Object.fromEntries(result), null, 2)); -} - -export { analyzeCallGraph, FunctionInfo, CallSite }; -``` - -**Step 4: Create tsconfig.json** - -Create `scripts/ring:codereview/ts/tsconfig.json`: - -```json -{ - "compilerOptions": { - "target": "ES2020", - "module": "CommonJS", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "./dist", - "declaration": true - }, - "include": ["*.ts"], - "exclude": ["node_modules"] -} -``` - -**Step 5: Install dependencies and build** - -Run: `cd scripts/ring:codereview/ts && npm install && npm run build` - -**Expected output:** -``` -added X packages... -``` - -**Step 6: Commit** - -```bash -git add scripts/ring:codereview/ts/ -git commit -m "feat(ring:codereview): add TypeScript call graph helper script" -``` - ---- - -## Task 7: Integrate TypeScript Helper into Go Analyzer - -**Files:** -- Modify: `scripts/ring:codereview/internal/callgraph/typescript.go` - -**Prerequisites:** -- Task 6 completed - -**Step 1: Add function-level analysis using TypeScript helper** - -Add to `typescript.go` after the `findTestCoverage` method: - -```go -// analyzeWithTSHelper uses the TypeScript helper for function-level analysis. -func (t *TypeScriptAnalyzer) analyzeWithTSHelper(ctx context.Context, files []string, functions []string) (map[string]interface{}, error) { - args := append(files, "--functions", strings.Join(functions, ",")) - - cmd := exec.CommandContext(ctx, "node", - append([]string{filepath.Join(t.workDir, "scripts/ring:codereview/ts/dist/call-graph.js")}, args...)...) - cmd.Dir = t.workDir - - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("running ts call-graph helper: %w", err) - } - - var result map[string]interface{} - if err := json.Unmarshal(out, &result); err != nil { - return nil, fmt.Errorf("parsing ts helper output: %w", err) - } - - return result, nil -} -``` - -**Step 2: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/callgraph/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/callgraph/typescript.go -git commit -m "feat(ring:codereview): integrate TypeScript helper for function-level analysis" -``` - ---- - -## Task 8: Create Python Call Graph Script - -**Files:** -- Create: `scripts/ring:codereview/py/call_graph.py` - -**Prerequisites:** -- Python 3.10+ available -- pyan3 installed (`pip install pyan3`) - -**Step 1: Create py directory if not exists** - -Run: `mkdir -p scripts/ring:codereview/py` - -**Step 2: Write call_graph.py** - -```python -#!/usr/bin/env python3 -""" -Python call graph analyzer using AST. - -This module provides static call graph analysis for Python code, -identifying callers, callees, and test coverage for modified functions. -""" - -import ast -import json -import os -import sys -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional, Set - - -@dataclass -class CallSite: - """Represents a function call site.""" - caller: str - callee: str - file: str - line: int - call_site_text: str = "" - - -@dataclass -class FunctionInfo: - """Information about a function and its call relationships.""" - name: str - file: str - line: int - module: str = "" - callers: List[CallSite] = field(default_factory=list) - callees: List[CallSite] = field(default_factory=list) - test_coverage: List[Dict] = field(default_factory=list) - - -class CallGraphVisitor(ast.NodeVisitor): - """AST visitor that builds call graph information.""" - - def __init__(self, filename: str, module_name: str = ""): - self.filename = filename - self.module_name = module_name - self.current_function: Optional[str] = None - self.current_class: Optional[str] = None - self.functions: Dict[str, FunctionInfo] = {} - self.calls: List[CallSite] = [] - - def _get_full_name(self, name: str) -> str: - """Get fully qualified name including class if applicable.""" - if self.current_class: - return f"{self.current_class}.{name}" - if self.module_name: - return f"{self.module_name}.{name}" - return name - - def visit_ClassDef(self, node: ast.ClassDef): - """Track class context for method names.""" - old_class = self.current_class - self.current_class = node.name - self.generic_visit(node) - self.current_class = old_class - - def visit_FunctionDef(self, node: ast.FunctionDef): - """Record function definition and track current function.""" - full_name = self._get_full_name(node.name) - self.functions[full_name] = FunctionInfo( - name=node.name, - file=self.filename, - line=node.lineno, - module=self.module_name, - ) - - old_func = self.current_function - self.current_function = full_name - self.generic_visit(node) - self.current_function = old_func - - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef): - """Handle async functions the same as regular functions.""" - self.visit_FunctionDef(node) # type: ignore - - def visit_Call(self, node: ast.Call): - """Record function calls.""" - callee = self._get_callee_name(node) - if callee and self.current_function: - self.calls.append(CallSite( - caller=self.current_function, - callee=callee, - file=self.filename, - line=node.lineno, - call_site_text=ast.unparse(node) if hasattr(ast, 'unparse') else "", - )) - self.generic_visit(node) - - def _get_callee_name(self, node: ast.Call) -> Optional[str]: - """Extract the callee name from a Call node.""" - if isinstance(node.func, ast.Name): - return node.func.id - elif isinstance(node.func, ast.Attribute): - # Handle method calls like self.method() or obj.method() - return node.func.attr - return None - - -def analyze_file(filepath: str) -> tuple[Dict[str, FunctionInfo], List[CallSite]]: - """Analyze a single Python file for call graph information.""" - with open(filepath, 'r', encoding='utf-8') as f: - source = f.read() - - try: - tree = ast.parse(source, filename=filepath) - except SyntaxError as e: - print(f"Warning: Could not parse {filepath}: {e}", file=sys.stderr) - return {}, [] - - # Derive module name from file path - module_name = Path(filepath).stem - if "/" in filepath: - parts = filepath.replace("/", ".").replace(".py", "") - module_name = parts - - visitor = CallGraphVisitor(filepath, module_name) - visitor.visit(tree) - - return visitor.functions, visitor.calls - - -def build_call_graph(files: List[str], target_functions: List[str]) -> Dict[str, FunctionInfo]: - """Build call graph for the given files, focusing on target functions.""" - all_functions: Dict[str, FunctionInfo] = {} - all_calls: List[CallSite] = [] - - # First pass: collect all functions and calls - for filepath in files: - if not os.path.exists(filepath): - print(f"Warning: File not found: {filepath}", file=sys.stderr) - continue - functions, calls = analyze_file(filepath) - all_functions.update(functions) - all_calls.extend(calls) - - # Second pass: associate calls with functions - for call in all_calls: - # Add to caller's callees - if call.caller in all_functions: - all_functions[call.caller].callees.append(call) - - # Add to callee's callers (if callee is in our functions) - for func_name, func_info in all_functions.items(): - if call.callee == func_info.name or call.callee in func_name: - func_info.callers.append(call) - - # Third pass: find test coverage - for func_name, func_info in all_functions.items(): - for caller in func_info.callers: - if is_test_function(caller.caller): - func_info.test_coverage.append({ - "test_function": caller.caller, - "file": caller.file, - "line": caller.line, - }) - - # Filter to target functions if specified - if target_functions: - filtered = {} - for target in target_functions: - for func_name, func_info in all_functions.items(): - if target in func_name or func_name.endswith(target): - filtered[func_name] = func_info - return filtered - - return all_functions - - -def is_test_function(name: str) -> bool: - """Check if a function name indicates a test function.""" - return ( - name.startswith("test_") or - name.startswith("Test") or - "_test_" in name or - name.endswith("_test") - ) - - -def to_json_output(functions: Dict[str, FunctionInfo]) -> dict: - """Convert function info to JSON-serializable output.""" - result = { - "modified_functions": [], - "impact_analysis": { - "direct_callers": 0, - "transitive_callers": 0, - "affected_tests": 0, - "affected_modules": [], - } - } - - affected_modules: Set[str] = set() - - for func_name, func_info in functions.items(): - func_data = { - "function": func_name, - "file": func_info.file, - "callers": [ - { - "function": c.caller, - "file": c.file, - "line": c.line, - "call_site": c.call_site_text, - } - for c in func_info.callers - ], - "callees": [ - { - "function": c.callee, - "file": c.file, - "line": c.line, - } - for c in func_info.callees - ], - "test_coverage": func_info.test_coverage, - } - result["modified_functions"].append(func_data) - - result["impact_analysis"]["direct_callers"] += len(func_info.callers) - result["impact_analysis"]["affected_tests"] += len(func_info.test_coverage) - - for caller in func_info.callers: - module = caller.file.replace("/", ".").replace(".py", "") - affected_modules.add(module) - - result["impact_analysis"]["affected_modules"] = list(affected_modules) - - return result - - -def main(): - """CLI entry point.""" - import argparse - - parser = argparse.ArgumentParser(description="Python call graph analyzer") - parser.add_argument("files", nargs="+", help="Python files to analyze") - parser.add_argument("--functions", "-f", help="Comma-separated target function names") - parser.add_argument("--output", "-o", default="-", help="Output file (- for stdout)") - - args = parser.parse_args() - - target_functions = [] - if args.functions: - target_functions = [f.strip() for f in args.functions.split(",")] - - functions = build_call_graph(args.files, target_functions) - output = to_json_output(functions) - - json_str = json.dumps(output, indent=2) - - if args.output == "-": - print(json_str) - else: - with open(args.output, "w") as f: - f.write(json_str) - - -if __name__ == "__main__": - main() -``` - -**Step 3: Make executable** - -Run: `chmod +x scripts/ring:codereview/py/call_graph.py` - -**Step 4: Verify script works** - -Run: `python3 scripts/ring:codereview/py/call_graph.py scripts/ring:codereview/py/call_graph.py --functions main` - -**Expected output:** -```json -{ - "modified_functions": [...], - "impact_analysis": {...} -} -``` - -**Step 5: Commit** - -```bash -git add scripts/ring:codereview/py/call_graph.py -git commit -m "feat(ring:codereview): add Python call graph analyzer script" -``` - ---- - -## Task 9: Create internal/callgraph/python.go - -**Files:** -- Create: `scripts/ring:codereview/internal/callgraph/python.go` - -**Prerequisites:** -- Task 8 completed - -**Step 1: Write the Python analyzer wrapper** - -```go -package callgraph - -import ( - "context" - "encoding/json" - "fmt" - "os/exec" - "path/filepath" - "strings" - "time" -) - -// PythonAnalyzer implements call graph analysis for Python. -type PythonAnalyzer struct { - workDir string -} - -// NewPythonAnalyzer creates a new Python call graph analyzer. -func NewPythonAnalyzer(workDir string) *PythonAnalyzer { - return &PythonAnalyzer{workDir: workDir} -} - -// pythonCallGraphOutput represents the output from call_graph.py. -type pythonCallGraphOutput struct { - ModifiedFunctions []struct { - Function string `json:"function"` - File string `json:"file"` - Callers []struct { - Function string `json:"function"` - File string `json:"file"` - Line int `json:"line"` - CallSite string `json:"call_site"` - } `json:"callers"` - Callees []struct { - Function string `json:"function"` - File string `json:"file"` - Line int `json:"line"` - } `json:"callees"` - TestCoverage []struct { - TestFunction string `json:"test_function"` - File string `json:"file"` - Line int `json:"line"` - } `json:"test_coverage"` - } `json:"modified_functions"` - ImpactAnalysis struct { - DirectCallers int `json:"direct_callers"` - TransitiveCallers int `json:"transitive_callers"` - AffectedTests int `json:"affected_tests"` - AffectedModules []string `json:"affected_modules"` - } `json:"impact_analysis"` -} - -// Analyze implements Analyzer interface for Python. -func (p *PythonAnalyzer) Analyze(modifiedFuncs []ModifiedFunction, timeBudgetSec int) (*CallGraphResult, error) { - result := &CallGraphResult{ - Language: "python", - ModifiedFunctions: []FunctionCallGraph{}, - ImpactAnalysis: ImpactAnalysis{}, - } - - if len(modifiedFuncs) == 0 { - return result, nil - } - - // Set up context with timeout - ctx := context.Background() - var cancel context.CancelFunc - if timeBudgetSec > 0 { - ctx, cancel = context.WithTimeout(ctx, time.Duration(timeBudgetSec)*time.Second) - defer cancel() - } - - // Collect files and function names - files := make([]string, 0) - funcNames := make([]string, 0) - fileSet := make(map[string]bool) - - for _, f := range modifiedFuncs { - if !fileSet[f.File] { - files = append(files, f.File) - fileSet[f.File] = true - } - funcNames = append(funcNames, f.Name) - } - - // Try using our custom call_graph.py first - pyOutput, err := p.runCallGraphPy(ctx, files, funcNames) - if err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("call_graph.py error: %v", err)) - - // Fall back to pyan3 - pyOutput, err = p.runPyan3(ctx, files) - if err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("pyan3 error: %v", err)) - result.PartialResults = true - return result, nil - } - } - - // Convert output to result - result = p.convertOutput(pyOutput) - - // Check if time exceeded - select { - case <-ctx.Done(): - result.TimeBudgetExceeded = true - result.PartialResults = true - default: - } - - return result, nil -} - -// runCallGraphPy runs our custom Python call graph script. -func (p *PythonAnalyzer) runCallGraphPy(ctx context.Context, files []string, funcNames []string) (*pythonCallGraphOutput, error) { - scriptPath := filepath.Join(p.workDir, "scripts/ring:codereview/py/call_graph.py") - - args := []string{scriptPath} - args = append(args, files...) - if len(funcNames) > 0 { - args = append(args, "--functions", strings.Join(funcNames, ",")) - } - - cmd := exec.CommandContext(ctx, "python3", args...) - cmd.Dir = p.workDir - - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("running call_graph.py: %w", err) - } - - var result pythonCallGraphOutput - if err := json.Unmarshal(out, &result); err != nil { - return nil, fmt.Errorf("parsing call_graph.py output: %w", err) - } - - return &result, nil -} - -// runPyan3 runs pyan3 as a fallback for call graph generation. -func (p *PythonAnalyzer) runPyan3(ctx context.Context, files []string) (*pythonCallGraphOutput, error) { - args := []string{"-m", "pyan3", "--dot"} - args = append(args, files...) - - cmd := exec.CommandContext(ctx, "python3", args...) - cmd.Dir = p.workDir - - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("running pyan3: %w", err) - } - - // pyan3 outputs DOT format, we need to parse it - // For now, return empty result - full implementation would parse DOT - _ = out - return &pythonCallGraphOutput{}, nil -} - -// convertOutput converts Python output to CallGraphResult. -func (p *PythonAnalyzer) convertOutput(pyOutput *pythonCallGraphOutput) *CallGraphResult { - result := &CallGraphResult{ - Language: "python", - ModifiedFunctions: []FunctionCallGraph{}, - ImpactAnalysis: ImpactAnalysis{ - DirectCallers: pyOutput.ImpactAnalysis.DirectCallers, - TransitiveCallers: pyOutput.ImpactAnalysis.TransitiveCallers, - AffectedTests: pyOutput.ImpactAnalysis.AffectedTests, - AffectedPackages: pyOutput.ImpactAnalysis.AffectedModules, - }, - } - - for _, f := range pyOutput.ModifiedFunctions { - fcg := FunctionCallGraph{ - Function: f.Function, - File: f.File, - Callers: make([]CallInfo, len(f.Callers)), - Callees: make([]CallInfo, len(f.Callees)), - TestCoverage: make([]TestCoverage, len(f.TestCoverage)), - } - - for i, c := range f.Callers { - fcg.Callers[i] = CallInfo{ - Function: c.Function, - File: c.File, - Line: c.Line, - CallSite: c.CallSite, - } - } - - for i, c := range f.Callees { - fcg.Callees[i] = CallInfo{ - Function: c.Function, - File: c.File, - Line: c.Line, - } - } - - for i, t := range f.TestCoverage { - fcg.TestCoverage[i] = TestCoverage{ - TestFunction: t.TestFunction, - File: t.File, - Line: t.Line, - } - } - - result.ModifiedFunctions = append(result.ModifiedFunctions, fcg) - } - - return result -} -``` - -**Step 2: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/callgraph/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/callgraph/python.go -git commit -m "feat(ring:codereview): add Python call graph analyzer wrapper" -``` - ---- - -## Task 10: Add requirements.txt for Python Dependencies - -**Files:** -- Create: `scripts/ring:codereview/py/requirements.txt` - -**Prerequisites:** -- Task 8 completed - -**Step 1: Create requirements.txt** - -```text -# Python dependencies for ring:codereview analysis -# Note: ast module is stdlib, no external deps needed for call_graph.py - -# Optional: pyan3 for fallback call graph generation -pyan3>=1.2.0 -``` - -**Step 2: Commit** - -```bash -git add scripts/ring:codereview/py/requirements.txt -git commit -m "feat(ring:codereview): add Python requirements for call graph analysis" -``` - ---- - -## Task 11: Create Analyzer Factory - -**Files:** -- Create: `scripts/ring:codereview/internal/callgraph/factory.go` - -**Prerequisites:** -- Tasks 3, 5, 9 completed - -**Step 1: Write the factory** - -```go -package callgraph - -import ( - "fmt" -) - -// NewAnalyzer creates the appropriate analyzer for the given language. -func NewAnalyzer(language, workDir string) (Analyzer, error) { - switch language { - case "go", "golang": - return NewGoAnalyzer(workDir), nil - case "typescript", "ts": - return NewTypeScriptAnalyzer(workDir), nil - case "python", "py": - return NewPythonAnalyzer(workDir), nil - default: - return nil, fmt.Errorf("unsupported language: %s", language) - } -} - -// SupportedLanguages returns the list of supported languages. -func SupportedLanguages() []string { - return []string{"go", "typescript", "python"} -} -``` - -**Step 2: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/callgraph/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/callgraph/factory.go -git commit -m "feat(ring:codereview): add call graph analyzer factory" -``` - ---- - -## Task 12: Create internal/output/markdown.go (Impact Summary) - -**Files:** -- Modify: `scripts/ring:codereview/internal/output/markdown.go` (or create if not exists) - -**Prerequisites:** -- Task 1 completed (types available) - -**Step 1: Create output directory if needed** - -Run: `mkdir -p scripts/ring:codereview/internal/output` - -**Step 2: Write the impact summary renderer** - -```go -package output - -import ( - "fmt" - "strings" - - "ring/scripts/ring:codereview/internal/callgraph" -) - -// RenderImpactSummary generates markdown for the impact analysis. -func RenderImpactSummary(result *callgraph.CallGraphResult) string { - var sb strings.Builder - - sb.WriteString("# Impact Analysis\n\n") - - if result.TimeBudgetExceeded { - sb.WriteString("⚠️ **Warning:** Time budget exceeded. Results may be incomplete.\n\n") - } - - if result.PartialResults { - sb.WriteString("⚠️ **Warning:** Some analysis failed. Partial results shown.\n\n") - } - - // Warnings - if len(result.Warnings) > 0 { - sb.WriteString("## Warnings\n\n") - for _, w := range result.Warnings { - sb.WriteString(fmt.Sprintf("- %s\n", w)) - } - sb.WriteString("\n") - } - - // High impact changes - highImpact := filterHighImpact(result.ModifiedFunctions) - if len(highImpact) > 0 { - sb.WriteString("## High Impact Changes\n\n") - for _, fcg := range highImpact { - sb.WriteString(renderFunctionImpact(fcg, result.Language, "HIGH")) - } - } - - // Medium impact changes - mediumImpact := filterMediumImpact(result.ModifiedFunctions) - if len(mediumImpact) > 0 { - sb.WriteString("## Medium Impact Changes\n\n") - for _, fcg := range mediumImpact { - sb.WriteString(renderFunctionImpact(fcg, result.Language, "MEDIUM")) - } - } - - // Low impact changes - lowImpact := filterLowImpact(result.ModifiedFunctions) - if len(lowImpact) > 0 { - sb.WriteString("## Low Impact Changes\n\n") - for _, fcg := range lowImpact { - sb.WriteString(renderFunctionImpact(fcg, result.Language, "LOW")) - } - } - - // Summary table - sb.WriteString("## Summary\n\n") - sb.WriteString("| Metric | Count |\n") - sb.WriteString("|--------|-------|\n") - sb.WriteString(fmt.Sprintf("| Direct Callers | %d |\n", result.ImpactAnalysis.DirectCallers)) - sb.WriteString(fmt.Sprintf("| Transitive Callers | %d |\n", result.ImpactAnalysis.TransitiveCallers)) - sb.WriteString(fmt.Sprintf("| Affected Tests | %d |\n", result.ImpactAnalysis.AffectedTests)) - sb.WriteString(fmt.Sprintf("| Affected Packages | %d |\n", len(result.ImpactAnalysis.AffectedPackages))) - - if len(result.ImpactAnalysis.AffectedPackages) > 0 { - sb.WriteString("\n**Affected Packages:**\n") - for _, pkg := range result.ImpactAnalysis.AffectedPackages { - sb.WriteString(fmt.Sprintf("- `%s`\n", pkg)) - } - } - - return sb.String() -} - -func renderFunctionImpact(fcg callgraph.FunctionCallGraph, language, riskLevel string) string { - var sb strings.Builder - - langLabel := strings.Title(language) - callerCount := len(fcg.Callers) - - sb.WriteString(fmt.Sprintf("### `%s` (%s)\n", fcg.Function, langLabel)) - sb.WriteString(fmt.Sprintf("**Risk Level:** %s (%d direct callers)\n\n", riskLevel, callerCount)) - - // Direct callers - if len(fcg.Callers) > 0 { - sb.WriteString("**Direct Callers (signature change affects these):**\n") - for i, caller := range fcg.Callers { - if i >= 10 { - sb.WriteString(fmt.Sprintf("- ... and %d more\n", len(fcg.Callers)-10)) - break - } - location := fmt.Sprintf("`%s:%d`", caller.File, caller.Line) - sb.WriteString(fmt.Sprintf("%d. `%s` - %s\n", i+1, caller.Function, location)) - } - sb.WriteString("\n") - } - - // Callees (what this function depends on) - if len(fcg.Callees) > 0 { - sb.WriteString("**Callees (this function depends on):**\n") - for i, callee := range fcg.Callees { - if i >= 5 { - sb.WriteString(fmt.Sprintf("- ... and %d more\n", len(fcg.Callees)-5)) - break - } - sb.WriteString(fmt.Sprintf("%d. `%s`\n", i+1, callee.Function)) - } - sb.WriteString("\n") - } - - // Test coverage - sb.WriteString("**Test Coverage:**\n") - if len(fcg.TestCoverage) == 0 { - sb.WriteString("- ⚠️ No tests found covering this function\n") - } else { - for _, test := range fcg.TestCoverage { - location := fmt.Sprintf("`%s:%d`", test.File, test.Line) - sb.WriteString(fmt.Sprintf("- ✅ `%s` - %s\n", test.TestFunction, location)) - } - } - - sb.WriteString("\n---\n\n") - return sb.String() -} - -func filterHighImpact(funcs []callgraph.FunctionCallGraph) []callgraph.FunctionCallGraph { - var result []callgraph.FunctionCallGraph - for _, f := range funcs { - if len(f.Callers) >= 3 { - result = append(result, f) - } - } - return result -} - -func filterMediumImpact(funcs []callgraph.FunctionCallGraph) []callgraph.FunctionCallGraph { - var result []callgraph.FunctionCallGraph - for _, f := range funcs { - if len(f.Callers) >= 1 && len(f.Callers) < 3 { - result = append(result, f) - } - } - return result -} - -func filterLowImpact(funcs []callgraph.FunctionCallGraph) []callgraph.FunctionCallGraph { - var result []callgraph.FunctionCallGraph - for _, f := range funcs { - if len(f.Callers) == 0 { - result = append(result, f) - } - } - return result -} -``` - -**Step 3: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/output/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/internal/output/markdown.go -git commit -m "feat(ring:codereview): add impact summary markdown renderer" -``` - ---- - -## Task 13: Add JSON Output Writer - -**Files:** -- Create: `scripts/ring:codereview/internal/output/json.go` - -**Prerequisites:** -- Task 1 completed - -**Step 1: Write JSON output functions** - -```go -package output - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "ring/scripts/ring:codereview/internal/callgraph" -) - -// WriteJSON writes the call graph result to a JSON file. -func WriteJSON(result *callgraph.CallGraphResult, outputDir string) error { - filename := fmt.Sprintf("%s-calls.json", result.Language) - outputPath := filepath.Join(outputDir, filename) - - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("marshaling JSON: %w", err) - } - - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating output directory: %w", err) - } - - if err := os.WriteFile(outputPath, data, 0644); err != nil { - return fmt.Errorf("writing JSON file: %w", err) - } - - return nil -} - -// WriteImpactSummary writes the impact summary markdown to a file. -func WriteImpactSummary(result *callgraph.CallGraphResult, outputDir string) error { - markdown := RenderImpactSummary(result) - outputPath := filepath.Join(outputDir, "impact-summary.md") - - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating output directory: %w", err) - } - - if err := os.WriteFile(outputPath, []byte(markdown), 0644); err != nil { - return fmt.Errorf("writing markdown file: %w", err) - } - - return nil -} -``` - -**Step 2: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./internal/output/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/output/json.go -git commit -m "feat(ring:codereview): add JSON and markdown output writers" -``` - ---- - -## Task 14: Fix Module Path in Output Package - -**Files:** -- Modify: `scripts/ring:codereview/internal/output/markdown.go` -- Modify: `scripts/ring:codereview/internal/output/json.go` - -**Prerequisites:** -- Tasks 12, 13 completed - -**Step 1: Check go.mod module path** - -Run: `head -1 scripts/ring:codereview/go.mod` - -**Expected output:** Shows the module name (e.g., `module ring/scripts/ring:codereview` or similar) - -**Step 2: Update imports to match module path** - -If the module is named differently, update the import in both files. For example, if `go.mod` says: - -``` -module github.com/lerianstudio/ring/scripts/ring:codereview -``` - -Then change the import from: -```go -"ring/scripts/ring:codereview/internal/callgraph" -``` - -To: -```go -"github.com/lerianstudio/ring/scripts/ring:codereview/internal/callgraph" -``` - -**Step 3: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/internal/output/ -git commit -m "fix(ring:codereview): fix module path in output package" -``` - ---- - -## Task 15: Create cmd/call-graph/main.go - -**Files:** -- Create: `scripts/ring:codereview/cmd/call-graph/main.go` - -**Prerequisites:** -- Tasks 1-14 completed - -**Step 1: Create cmd directory** - -Run: `mkdir -p scripts/ring:codereview/cmd/call-graph` - -**Step 2: Write the CLI orchestrator** - -```go -package main - -import ( - "encoding/json" - "flag" - "fmt" - "log" - "os" - "path/filepath" - "strings" - - "ring/scripts/ring:codereview/internal/callgraph" - "ring/scripts/ring:codereview/internal/output" -) - -// ASTInput represents the input from Phase 2 AST extraction. -type ASTInput struct { - Functions struct { - Modified []struct { - Name string `json:"name"` - File string `json:"file"` - Package string `json:"package"` - Receiver string `json:"receiver,omitempty"` - } `json:"modified"` - Added []struct { - Name string `json:"name"` - File string `json:"file"` - Package string `json:"package"` - } `json:"added"` - } `json:"functions"` -} - -func main() { - // CLI flags - scopeFile := flag.String("scope", "", "Path to scope.json from Phase 0") - astFile := flag.String("ast", "", "Path to {lang}-ast.json from Phase 2") - outputDir := flag.String("output", ".ring/ring:codereview", "Output directory") - timeBudget := flag.Int("timeout", 30, "Time budget in seconds (0 = no limit)") - language := flag.String("lang", "", "Language (go, typescript, python) - auto-detected from AST if not specified") - verbose := flag.Bool("verbose", false, "Enable verbose output") - - flag.Parse() - - // Validate inputs - if *astFile == "" { - log.Fatal("--ast flag is required") - } - - // Read AST input - astData, err := os.ReadFile(*astFile) - if err != nil { - log.Fatalf("Reading AST file: %v", err) - } - - var ast ASTInput - if err := json.Unmarshal(astData, &ast); err != nil { - log.Fatalf("Parsing AST file: %v", err) - } - - // Detect language from filename if not specified - lang := *language - if lang == "" { - lang = detectLanguage(*astFile) - } - if lang == "" { - log.Fatal("Could not detect language. Please specify --lang") - } - - if *verbose { - log.Printf("Language: %s", lang) - log.Printf("AST file: %s", *astFile) - log.Printf("Output directory: %s", *outputDir) - log.Printf("Time budget: %d seconds", *timeBudget) - } - - // Get working directory - workDir, err := os.Getwd() - if err != nil { - log.Fatalf("Getting working directory: %v", err) - } - - // If scope file provided, use its directory as workDir - if *scopeFile != "" { - workDir = filepath.Dir(*scopeFile) - } - - // Build modified functions list - var modifiedFuncs []callgraph.ModifiedFunction - - for _, f := range ast.Functions.Modified { - modifiedFuncs = append(modifiedFuncs, callgraph.ModifiedFunction{ - Name: f.Name, - File: f.File, - Package: f.Package, - Receiver: f.Receiver, - }) - } - - for _, f := range ast.Functions.Added { - modifiedFuncs = append(modifiedFuncs, callgraph.ModifiedFunction{ - Name: f.Name, - File: f.File, - Package: f.Package, - }) - } - - if len(modifiedFuncs) == 0 { - if *verbose { - log.Println("No modified functions found in AST. Generating empty output.") - } - // Generate empty output - result := &callgraph.CallGraphResult{ - Language: lang, - ModifiedFunctions: []callgraph.FunctionCallGraph{}, - ImpactAnalysis: callgraph.ImpactAnalysis{}, - } - writeOutput(result, *outputDir) - return - } - - if *verbose { - log.Printf("Found %d modified functions", len(modifiedFuncs)) - } - - // Create analyzer - analyzer, err := callgraph.NewAnalyzer(lang, workDir) - if err != nil { - log.Fatalf("Creating analyzer: %v", err) - } - - // Run analysis - result, err := analyzer.Analyze(modifiedFuncs, *timeBudget) - if err != nil { - log.Printf("Warning: Analysis error: %v", err) - // Continue with partial results - } - - // Write output - writeOutput(result, *outputDir) - - if *verbose { - log.Printf("Output written to %s", *outputDir) - log.Printf(" - %s-calls.json", lang) - log.Printf(" - impact-summary.md") - } -} - -func detectLanguage(filename string) string { - base := filepath.Base(filename) - if strings.HasPrefix(base, "go-") { - return "go" - } - if strings.HasPrefix(base, "ts-") { - return "typescript" - } - if strings.HasPrefix(base, "py-") { - return "python" - } - return "" -} - -func writeOutput(result *callgraph.CallGraphResult, outputDir string) { - // Write JSON - if err := output.WriteJSON(result, outputDir); err != nil { - log.Printf("Warning: Could not write JSON: %v", err) - } - - // Write impact summary markdown - if err := output.WriteImpactSummary(result, outputDir); err != nil { - log.Printf("Warning: Could not write impact summary: %v", err) - } - - // Print summary to stdout - fmt.Printf("Call graph analysis complete:\n") - fmt.Printf(" Language: %s\n", result.Language) - fmt.Printf(" Modified functions: %d\n", len(result.ModifiedFunctions)) - fmt.Printf(" Direct callers: %d\n", result.ImpactAnalysis.DirectCallers) - fmt.Printf(" Transitive callers: %d\n", result.ImpactAnalysis.TransitiveCallers) - fmt.Printf(" Affected tests: %d\n", result.ImpactAnalysis.AffectedTests) - - if result.TimeBudgetExceeded { - fmt.Println(" ⚠️ Time budget exceeded (partial results)") - } - if result.PartialResults { - fmt.Println(" ⚠️ Some analysis failed (partial results)") - } -} -``` - -**Step 3: Verify file compiles** - -Run: `cd scripts/ring:codereview && go build ./cmd/call-graph/...` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/cmd/call-graph/ -git commit -m "feat(ring:codereview): add call-graph CLI orchestrator" -``` - ---- - -## Task 16: Fix Module Path in CLI - -**Files:** -- Modify: `scripts/ring:codereview/cmd/call-graph/main.go` - -**Prerequisites:** -- Task 15 completed - -**Step 1: Check and update module path** - -Run: `head -1 scripts/ring:codereview/go.mod` - -Update the imports in `main.go` to match the actual module path. For example, if the module is `github.com/lerianstudio/ring/scripts/ring:codereview`: - -```go -import ( - // ... standard library imports ... - - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/callgraph" - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/output" -) -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build -o bin/call-graph ./cmd/call-graph/` - -**Expected output:** -``` -(no output - successful build) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/cmd/call-graph/main.go -git commit -m "fix(ring:codereview): fix module path in call-graph CLI" -``` - ---- - -## Task 17: Build Binary and Verify - -**Files:** -- Build: `scripts/ring:codereview/bin/call-graph` - -**Prerequisites:** -- Task 16 completed - -**Step 1: Build the binary** - -Run: `cd scripts/ring:codereview && go build -o bin/call-graph ./cmd/call-graph/` - -**Expected output:** -``` -(no output - successful build) -``` - -**Step 2: Verify binary runs** - -Run: `scripts/ring:codereview/bin/call-graph --help` - -**Expected output:** -``` -Usage of call-graph: - -ast string - Path to {lang}-ast.json from Phase 2 - -lang string - Language (go, typescript, python) - auto-detected from AST if not specified - -output string - Output directory (default ".ring/ring:codereview") - -scope string - Path to scope.json from Phase 0 - -timeout int - Time budget in seconds (0 = no limit) (default 30) - -verbose - Enable verbose output -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/bin/ -echo "scripts/ring:codereview/bin/" >> .gitignore -git add .gitignore -git commit -m "feat(ring:codereview): build call-graph binary" -``` - ---- - -## Task 18: Create Unit Tests for Go Analyzer - -**Files:** -- Create: `scripts/ring:codereview/internal/callgraph/golang_test.go` - -**Prerequisites:** -- Task 4 completed - -**Step 1: Write the test file** - -```go -package callgraph - -import ( - "testing" -) - -func TestGetAffectedPackages(t *testing.T) { - tests := []struct { - name string - funcs []ModifiedFunction - expected int // minimum expected packages - }{ - { - name: "empty input", - funcs: []ModifiedFunction{}, - expected: 0, - }, - { - name: "single package", - funcs: []ModifiedFunction{ - {Name: "Foo", File: "internal/handler/user.go", Package: "handler"}, - }, - expected: 1, - }, - { - name: "multiple packages", - funcs: []ModifiedFunction{ - {Name: "Foo", File: "internal/handler/user.go", Package: "handler"}, - {Name: "Bar", File: "internal/repo/user.go", Package: "repo"}, - }, - expected: 2, - }, - { - name: "duplicate packages", - funcs: []ModifiedFunction{ - {Name: "Foo", File: "internal/handler/user.go", Package: "handler"}, - {Name: "Bar", File: "internal/handler/admin.go", Package: "handler"}, - }, - expected: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getAffectedPackages(tt.funcs) - if len(result) < tt.expected { - t.Errorf("got %d packages, expected at least %d", len(result), tt.expected) - } - }) - } -} - -func TestIsTestFunction(t *testing.T) { - tests := []struct { - name string - funcName string - expected bool - }{ - {"Test prefix", "TestCreateUser", true}, - {"Benchmark prefix", "BenchmarkSort", true}, - {"Example prefix", "ExampleHandler", true}, - {"Regular function", "CreateUser", false}, - {"Helper function", "setupTest", false}, - {"Internal function", "doTest", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isTestFunction(tt.funcName) - if result != tt.expected { - t.Errorf("isTestFunction(%q) = %v, expected %v", tt.funcName, result, tt.expected) - } - }) - } -} - -func TestExtractPackageFromFile(t *testing.T) { - tests := []struct { - name string - filePath string - expected string - }{ - {"simple path", "internal/handler/user.go", "handler"}, - {"nested path", "pkg/api/v1/handler/user.go", "handler"}, - {"root file", "main.go", "."}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractPackageFromFile(tt.filePath) - if result != tt.expected { - t.Errorf("extractPackageFromFile(%q) = %q, expected %q", tt.filePath, result, tt.expected) - } - }) - } -} - -func TestNewGoAnalyzer(t *testing.T) { - analyzer := NewGoAnalyzer("/tmp/test") - if analyzer == nil { - t.Error("NewGoAnalyzer returned nil") - } - if analyzer.workDir != "/tmp/test" { - t.Errorf("workDir = %q, expected %q", analyzer.workDir, "/tmp/test") - } -} -``` - -**Step 2: Run tests** - -Run: `cd scripts/ring:codereview && go test -v ./internal/callgraph/...` - -**Expected output:** -``` -=== RUN TestGetAffectedPackages -... ---- PASS: TestGetAffectedPackages (X.XXs) -=== RUN TestIsTestFunction -... ---- PASS: TestIsTestFunction (X.XXs) -=== RUN TestExtractPackageFromFile -... ---- PASS: TestExtractPackageFromFile (X.XXs) -=== RUN TestNewGoAnalyzer ---- PASS: TestNewGoAnalyzer (X.XXs) -PASS -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/callgraph/golang_test.go -git commit -m "test(ring:codereview): add unit tests for Go call graph analyzer" -``` - ---- - -## Task 19: Create Unit Tests for Python Script - -**Files:** -- Create: `scripts/ring:codereview/py/test_call_graph.py` - -**Prerequisites:** -- Task 8 completed - -**Step 1: Write the test file** - -```python -#!/usr/bin/env python3 -"""Unit tests for call_graph.py""" - -import ast -import tempfile -import os -import sys -import unittest -from pathlib import Path - -# Add parent directory to path for import -sys.path.insert(0, str(Path(__file__).parent)) - -from call_graph import ( - CallGraphVisitor, - analyze_file, - build_call_graph, - is_test_function, - to_json_output, -) - - -class TestIsTestFunction(unittest.TestCase): - """Tests for is_test_function()""" - - def test_test_prefix(self): - self.assertTrue(is_test_function("test_create_user")) - - def test_Test_prefix(self): - self.assertTrue(is_test_function("TestCreateUser")) - - def test_contains_test(self): - self.assertTrue(is_test_function("integration_test_user")) - - def test_ends_with_test(self): - self.assertTrue(is_test_function("user_test")) - - def test_regular_function(self): - self.assertFalse(is_test_function("create_user")) - - def test_helper_function(self): - self.assertFalse(is_test_function("setup_database")) - - -class TestCallGraphVisitor(unittest.TestCase): - """Tests for CallGraphVisitor""" - - def test_simple_function(self): - source = """ -def foo(): - pass -""" - tree = ast.parse(source) - visitor = CallGraphVisitor("test.py", "test") - visitor.visit(tree) - - self.assertIn("foo", visitor.functions) - self.assertEqual(visitor.functions["foo"].name, "foo") - - def test_function_call(self): - source = """ -def caller(): - callee() - -def callee(): - pass -""" - tree = ast.parse(source) - visitor = CallGraphVisitor("test.py", "test") - visitor.visit(tree) - - self.assertEqual(len(visitor.calls), 1) - self.assertEqual(visitor.calls[0].caller, "caller") - self.assertEqual(visitor.calls[0].callee, "callee") - - def test_method_in_class(self): - source = """ -class MyClass: - def method(self): - pass -""" - tree = ast.parse(source) - visitor = CallGraphVisitor("test.py", "test") - visitor.visit(tree) - - self.assertIn("MyClass.method", visitor.functions) - - -class TestAnalyzeFile(unittest.TestCase): - """Tests for analyze_file()""" - - def test_analyze_valid_file(self): - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: - f.write(""" -def foo(): - bar() - -def bar(): - pass -""") - f.flush() - - try: - functions, calls = analyze_file(f.name) - self.assertIn("foo", functions) - self.assertIn("bar", functions) - self.assertEqual(len(calls), 1) - finally: - os.unlink(f.name) - - def test_analyze_syntax_error(self): - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: - f.write("def broken(:\n pass") - f.flush() - - try: - functions, calls = analyze_file(f.name) - # Should return empty on syntax error - self.assertEqual(len(functions), 0) - self.assertEqual(len(calls), 0) - finally: - os.unlink(f.name) - - -class TestToJsonOutput(unittest.TestCase): - """Tests for to_json_output()""" - - def test_empty_input(self): - result = to_json_output({}) - self.assertEqual(result["modified_functions"], []) - self.assertEqual(result["impact_analysis"]["direct_callers"], 0) - - def test_with_functions(self): - from call_graph import FunctionInfo, CallSite - - functions = { - "test.foo": FunctionInfo( - name="foo", - file="test.py", - line=1, - module="test", - callers=[CallSite("bar", "foo", "test.py", 10, "")], - callees=[], - test_coverage=[], - ) - } - - result = to_json_output(functions) - self.assertEqual(len(result["modified_functions"]), 1) - self.assertEqual(result["modified_functions"][0]["function"], "test.foo") - self.assertEqual(result["impact_analysis"]["direct_callers"], 1) - - -if __name__ == "__main__": - unittest.main() -``` - -**Step 2: Run tests** - -Run: `cd scripts/ring:codereview/py && python3 -m pytest test_call_graph.py -v` - -Or without pytest: - -Run: `cd scripts/ring:codereview/py && python3 test_call_graph.py -v` - -**Expected output:** -``` -test_test_prefix ... ok -test_Test_prefix ... ok -... ----------------------------------------------------------------------- -Ran X tests in Y.YYYs - -OK -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/py/test_call_graph.py -git commit -m "test(ring:codereview): add unit tests for Python call graph script" -``` - ---- - -## Task 20: Create Unit Tests for Output Package - -**Files:** -- Create: `scripts/ring:codereview/internal/output/markdown_test.go` - -**Prerequisites:** -- Task 12 completed - -**Step 1: Write the test file** - -```go -package output - -import ( - "strings" - "testing" - - "ring/scripts/ring:codereview/internal/callgraph" -) - -func TestRenderImpactSummary_Empty(t *testing.T) { - result := &callgraph.CallGraphResult{ - Language: "go", - ModifiedFunctions: []callgraph.FunctionCallGraph{}, - ImpactAnalysis: callgraph.ImpactAnalysis{}, - } - - markdown := RenderImpactSummary(result) - - if !strings.Contains(markdown, "# Impact Analysis") { - t.Error("Missing header") - } - if !strings.Contains(markdown, "## Summary") { - t.Error("Missing summary section") - } -} - -func TestRenderImpactSummary_WithWarnings(t *testing.T) { - result := &callgraph.CallGraphResult{ - Language: "go", - ModifiedFunctions: []callgraph.FunctionCallGraph{}, - ImpactAnalysis: callgraph.ImpactAnalysis{}, - TimeBudgetExceeded: true, - Warnings: []string{"test warning"}, - } - - markdown := RenderImpactSummary(result) - - if !strings.Contains(markdown, "⚠️") { - t.Error("Missing warning indicator") - } - if !strings.Contains(markdown, "test warning") { - t.Error("Missing warning text") - } -} - -func TestRenderImpactSummary_HighImpact(t *testing.T) { - result := &callgraph.CallGraphResult{ - Language: "go", - ModifiedFunctions: []callgraph.FunctionCallGraph{ - { - Function: "CreateUser", - File: "handler/user.go", - Callers: []callgraph.CallInfo{ - {Function: "Caller1", File: "a.go", Line: 1}, - {Function: "Caller2", File: "b.go", Line: 2}, - {Function: "Caller3", File: "c.go", Line: 3}, - }, - Callees: []callgraph.CallInfo{ - {Function: "Save", File: "repo.go", Line: 10}, - }, - TestCoverage: []callgraph.TestCoverage{ - {TestFunction: "TestCreateUser", File: "user_test.go", Line: 5}, - }, - }, - }, - ImpactAnalysis: callgraph.ImpactAnalysis{ - DirectCallers: 3, - }, - } - - markdown := RenderImpactSummary(result) - - if !strings.Contains(markdown, "## High Impact Changes") { - t.Error("Missing high impact section") - } - if !strings.Contains(markdown, "CreateUser") { - t.Error("Missing function name") - } - if !strings.Contains(markdown, "HIGH") { - t.Error("Missing risk level") - } -} - -func TestFilterHighImpact(t *testing.T) { - funcs := []callgraph.FunctionCallGraph{ - {Function: "High", Callers: make([]callgraph.CallInfo, 5)}, - {Function: "Medium", Callers: make([]callgraph.CallInfo, 2)}, - {Function: "Low", Callers: make([]callgraph.CallInfo, 0)}, - } - - high := filterHighImpact(funcs) - if len(high) != 1 { - t.Errorf("Expected 1 high impact, got %d", len(high)) - } - if high[0].Function != "High" { - t.Errorf("Expected 'High', got %q", high[0].Function) - } -} - -func TestFilterMediumImpact(t *testing.T) { - funcs := []callgraph.FunctionCallGraph{ - {Function: "High", Callers: make([]callgraph.CallInfo, 5)}, - {Function: "Medium", Callers: make([]callgraph.CallInfo, 2)}, - {Function: "Low", Callers: make([]callgraph.CallInfo, 0)}, - } - - medium := filterMediumImpact(funcs) - if len(medium) != 1 { - t.Errorf("Expected 1 medium impact, got %d", len(medium)) - } - if medium[0].Function != "Medium" { - t.Errorf("Expected 'Medium', got %q", medium[0].Function) - } -} - -func TestFilterLowImpact(t *testing.T) { - funcs := []callgraph.FunctionCallGraph{ - {Function: "High", Callers: make([]callgraph.CallInfo, 5)}, - {Function: "Medium", Callers: make([]callgraph.CallInfo, 2)}, - {Function: "Low", Callers: make([]callgraph.CallInfo, 0)}, - } - - low := filterLowImpact(funcs) - if len(low) != 1 { - t.Errorf("Expected 1 low impact, got %d", len(low)) - } - if low[0].Function != "Low" { - t.Errorf("Expected 'Low', got %q", low[0].Function) - } -} -``` - -**Step 2: Fix module path in test file** - -Update the import to match your actual module path (same as Task 14). - -**Step 3: Run tests** - -Run: `cd scripts/ring:codereview && go test -v ./internal/output/...` - -**Expected output:** -``` -=== RUN TestRenderImpactSummary_Empty ---- PASS: TestRenderImpactSummary_Empty (X.XXs) -=== RUN TestRenderImpactSummary_WithWarnings ---- PASS: TestRenderImpactSummary_WithWarnings (X.XXs) -... -PASS -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/internal/output/markdown_test.go -git commit -m "test(ring:codereview): add unit tests for markdown output renderer" -``` - ---- - -## Task 21: Integration Test - -**Files:** -- Create: `scripts/ring:codereview/test/integration_test.sh` - -**Prerequisites:** -- All previous tasks completed - -**Step 1: Create test directory** - -Run: `mkdir -p scripts/ring:codereview/test` - -**Step 2: Write integration test script** - -```bash -#!/bin/bash -# Integration test for call-graph binary - -set -e - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -BIN="$ROOT_DIR/bin/call-graph" -TEST_OUTPUT="$SCRIPT_DIR/test-output" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -NC='\033[0m' # No Color - -echo "=== Call Graph Integration Test ===" -echo "" - -# Cleanup -rm -rf "$TEST_OUTPUT" -mkdir -p "$TEST_OUTPUT" - -# Create test AST input (simulating Phase 2 output) -cat > "$TEST_OUTPUT/go-ast.json" << 'EOF' -{ - "functions": { - "modified": [ - { - "name": "CreateUser", - "file": "internal/handler/user.go", - "package": "handler", - "receiver": "*UserHandler" - } - ], - "added": [] - }, - "types": { - "modified": [], - "added": [] - } -} -EOF - -# Test 1: Help output -echo "Test 1: Help output..." -if $BIN --help 2>&1 | grep -q "Usage"; then - echo -e "${GREEN}PASS${NC}: Help output works" -else - echo -e "${RED}FAIL${NC}: Help output missing" - exit 1 -fi - -# Test 2: Missing required flag -echo "Test 2: Missing required flag..." -if $BIN 2>&1 | grep -q "required"; then - echo -e "${GREEN}PASS${NC}: Missing flag error works" -else - echo -e "${RED}FAIL${NC}: Missing flag error not shown" - exit 1 -fi - -# Test 3: Process test AST input -echo "Test 3: Process AST input..." -if $BIN --ast "$TEST_OUTPUT/go-ast.json" --output "$TEST_OUTPUT" --lang go --verbose 2>&1; then - echo -e "${GREEN}PASS${NC}: AST processing completed" -else - echo -e "${RED}FAIL${NC}: AST processing failed" - exit 1 -fi - -# Test 4: Verify JSON output exists -echo "Test 4: Verify JSON output..." -if [ -f "$TEST_OUTPUT/go-calls.json" ]; then - echo -e "${GREEN}PASS${NC}: JSON output created" -else - echo -e "${RED}FAIL${NC}: JSON output missing" - exit 1 -fi - -# Test 5: Verify markdown output exists -echo "Test 5: Verify markdown output..." -if [ -f "$TEST_OUTPUT/impact-summary.md" ]; then - echo -e "${GREEN}PASS${NC}: Markdown output created" -else - echo -e "${RED}FAIL${NC}: Markdown output missing" - exit 1 -fi - -# Test 6: Verify JSON structure -echo "Test 6: Verify JSON structure..." -if cat "$TEST_OUTPUT/go-calls.json" | python3 -c "import sys,json; d=json.load(sys.stdin); assert 'language' in d; assert 'modified_functions' in d"; then - echo -e "${GREEN}PASS${NC}: JSON structure valid" -else - echo -e "${RED}FAIL${NC}: JSON structure invalid" - exit 1 -fi - -# Cleanup -rm -rf "$TEST_OUTPUT" - -echo "" -echo "=== All integration tests passed! ===" -``` - -**Step 3: Make executable** - -Run: `chmod +x scripts/ring:codereview/test/integration_test.sh` - -**Step 4: Run integration test** - -Run: `./scripts/ring:codereview/test/integration_test.sh` - -**Expected output:** -``` -=== Call Graph Integration Test === - -Test 1: Help output... -PASS: Help output works -Test 2: Missing required flag... -PASS: Missing flag error works -Test 3: Process AST input... -PASS: AST processing completed -Test 4: Verify JSON output... -PASS: JSON output created -Test 5: Verify markdown output... -PASS: Markdown output created -Test 6: Verify JSON structure... -PASS: JSON structure valid - -=== All integration tests passed! === -``` - -**Step 5: Commit** - -```bash -git add scripts/ring:codereview/test/integration_test.sh -git commit -m "test(ring:codereview): add integration test for call-graph binary" -``` - ---- - -## Task 22: Code Review Checkpoint - -**Prerequisites:** -- All previous tasks completed - -**Step 1: Dispatch all 5 reviewers in parallel** - -> **REQUIRED SUB-SKILL:** Use `ring:requesting-code-review` - -All reviewers run simultaneously: -- ring:code-reviewer -- ring:business-logic-reviewer -- ring:security-reviewer -- ring:test-reviewer -- ring:nil-safety-reviewer - -**Step 2: Handle findings by severity** - -**Critical/High/Medium Issues:** -- Fix immediately (do NOT add TODO comments for these severities) -- Re-run all 5 reviewers in parallel after fixes -- Repeat until zero Critical/High/Medium issues remain - -**Low Issues:** -- Add `TODO(review):` comments in code at the relevant location -- Format: `TODO(review): [Issue description] (reported by [reviewer] on [date], severity: Low)` - -**Cosmetic/Nitpick Issues:** -- Add `FIXME(nitpick):` comments in code at the relevant location -- Format: `FIXME(nitpick): [Issue description] (reported by [reviewer] on [date], severity: Cosmetic)` - -**Step 3: Proceed only when:** -- Zero Critical/High/Medium issues remain -- All Low issues have TODO(review): comments added -- All Cosmetic issues have FIXME(nitpick): comments added - ---- - -## If Task Fails - -**General recovery steps:** - -1. **Compilation fails:** - - Check: Module path matches `go.mod` - - Fix: Update imports to match actual module path - - Rollback: `git checkout -- .` - -2. **Tests fail:** - - Run: Individual test to see detailed error - - Check: Expected behavior vs actual - - Fix: Update test or implementation - -3. **Binary won't build:** - - Run: `go mod tidy` to fix dependencies - - Check: All imports resolve correctly - - Fix: Missing dependencies with `go get` - -4. **Integration test fails:** - - Check: Binary exists at expected path - - Check: Required tools installed (dependency-cruiser, pyan3) - - Run: Individual test step manually - -5. **Can't recover:** - - Document: What failed and why - - Stop: Return to human partner - - Don't: Try to fix without understanding - ---- - -## Verification Checklist - -Before marking Phase 3 complete: - -- [ ] `scripts/ring:codereview/internal/callgraph/types.go` exists with all types -- [ ] `scripts/ring:codereview/internal/callgraph/golang.go` compiles and has tests -- [ ] `scripts/ring:codereview/internal/callgraph/typescript.go` compiles -- [ ] `scripts/ring:codereview/internal/callgraph/python.go` compiles -- [ ] `scripts/ring:codereview/internal/callgraph/factory.go` compiles -- [ ] `scripts/ring:codereview/py/call_graph.py` runs and has tests -- [ ] `scripts/ring:codereview/ts/call-graph.ts` compiles -- [ ] `scripts/ring:codereview/internal/output/markdown.go` compiles and has tests -- [ ] `scripts/ring:codereview/internal/output/json.go` compiles -- [ ] `scripts/ring:codereview/cmd/call-graph/main.go` builds to `bin/call-graph` -- [ ] `bin/call-graph --help` shows usage -- [ ] Unit tests pass: `go test ./internal/...` -- [ ] Python tests pass: `python3 py/test_call_graph.py` -- [ ] Integration test passes: `./test/integration_test.sh` -- [ ] Code review completed with no Critical/High/Medium issues - ---- - -## CLI Interface Summary -## CLI Summary - -After implementation, the binary can be invoked as: - -```bash -# Basic usage -call-graph --ast .ring/ring:codereview/go-ast.json --output .ring/ring:codereview/ - -# With all options -call-graph \ - --scope .ring/ring:codereview/scope.json \ - --ast .ring/ring:codereview/go-ast.json \ - --output .ring/ring:codereview/ \ - --lang go \ - --timeout 30 \ - --verbose -``` - -**Outputs:** -- `{output}/go-calls.json` (or `ts-calls.json`, `py-calls.json`) -- `{output}/impact-summary.md` - ---- - -## Next Phase - -After Phase 3 is complete, proceed to **Phase 4: Data Flow Analysis** which will: -- Track data from sources (HTTP input, env vars) to sinks (DB, responses) -- Identify sanitization points -- Generate security-focused analysis -- Build on the call graph to trace data through function calls diff --git a/docs/plans/2026-01-13-codereview-phase4-data-flow.md b/docs/plans/2026-01-13-codereview-phase4-data-flow.md deleted file mode 100644 index 5146200e..00000000 --- a/docs/plans/2026-01-13-codereview-phase4-data-flow.md +++ /dev/null @@ -1,2599 +0,0 @@ -# Phase 4: Data Flow Analysis Implementation Plan - -## Overview - -Track data from untrusted sources (HTTP, env, files) to sinks (DB, exec, responses). -Identify unsanitized flows and nil/null safety risks. - -## Input/Output - -- **Input:** `scope.json` from Phase 0 -- **Output:** `{lang}-flow.json`, `security-summary.md` - -## Directory Structure - -``` -scripts/ring:codereview/ -├── cmd/data-flow/main.go -├── internal/dataflow/ -│ ├── types.go -│ ├── golang.go -│ ├── typescript.go -│ └── python.go -├── py/ -│ └── data_flow.py -``` - ---- - -## Tasks - -### Task 1: Create dataflow package structure (2 min) - -**Description:** Create the directory structure for the dataflow package. - -**Commands:** -```bash -mkdir -p scripts/ring:codereview/cmd/data-flow -mkdir -p scripts/ring:codereview/internal/dataflow -mkdir -p scripts/ring:codereview/py -``` - -**Verification:** -```bash -ls -la scripts/ring:codereview/cmd/data-flow -ls -la scripts/ring:codereview/internal/dataflow -ls -la scripts/ring:codereview/py -``` - ---- - -### Task 2: Define types (Flow, Source, Sink, NilSource) (3 min) - -**Description:** Define the core data structures for tracking data flows, sources, sinks, and nil safety. - -**File:** `scripts/ring:codereview/internal/dataflow/types.go` - -```go -package dataflow - -// SourceType categorizes where untrusted data originates -type SourceType string - -const ( - SourceHTTPBody SourceType = "http_body" - SourceHTTPQuery SourceType = "http_query" - SourceHTTPHeader SourceType = "http_header" - SourceHTTPPath SourceType = "http_path" - SourceEnvVar SourceType = "env_var" - SourceFile SourceType = "file_read" - SourceDatabase SourceType = "database" - SourceUserInput SourceType = "user_input" - SourceExternal SourceType = "external_api" -) - -// SinkType categorizes where data flows to -type SinkType string - -const ( - SinkDatabase SinkType = "database" - SinkExec SinkType = "command_exec" - SinkResponse SinkType = "http_response" - SinkLog SinkType = "logging" - SinkFile SinkType = "file_write" - SinkTemplate SinkType = "template" - SinkRedirect SinkType = "redirect" -) - -// RiskLevel indicates severity of a flow -type RiskLevel string - -const ( - RiskCritical RiskLevel = "critical" - RiskHigh RiskLevel = "high" - RiskMedium RiskLevel = "medium" - RiskLow RiskLevel = "low" - RiskInfo RiskLevel = "info" -) - -// Source represents an untrusted data source -type Source struct { - Type SourceType `json:"type"` - File string `json:"file"` - Line int `json:"line"` - Column int `json:"column,omitempty"` - Variable string `json:"variable"` - Pattern string `json:"pattern"` - Context string `json:"context,omitempty"` -} - -// Sink represents a data destination -type Sink struct { - Type SinkType `json:"type"` - File string `json:"file"` - Line int `json:"line"` - Column int `json:"column,omitempty"` - Function string `json:"function"` - Pattern string `json:"pattern"` - Context string `json:"context,omitempty"` -} - -// Flow represents a data path from source to sink -type Flow struct { - ID string `json:"id"` - Source Source `json:"source"` - Sink Sink `json:"sink"` - Path []string `json:"path"` - Sanitized bool `json:"sanitized"` - Sanitizers []string `json:"sanitizers,omitempty"` - Risk RiskLevel `json:"risk"` - Description string `json:"description"` -} - -// NilSource tracks variables that may be nil/null -type NilSource struct { - File string `json:"file"` - Line int `json:"line"` - Variable string `json:"variable"` - Origin string `json:"origin"` - IsChecked bool `json:"is_checked"` - CheckLine int `json:"check_line,omitempty"` - UsageLine int `json:"usage_line,omitempty"` - Risk RiskLevel `json:"risk"` -} - -// FlowAnalysis contains all analysis results for a language -type FlowAnalysis struct { - Language string `json:"language"` - Sources []Source `json:"sources"` - Sinks []Sink `json:"sinks"` - Flows []Flow `json:"flows"` - NilSources []NilSource `json:"nil_sources"` - Statistics Stats `json:"statistics"` -} - -// Stats provides summary statistics -type Stats struct { - TotalSources int `json:"total_sources"` - TotalSinks int `json:"total_sinks"` - TotalFlows int `json:"total_flows"` - UnsanitizedFlows int `json:"unsanitized_flows"` - CriticalFlows int `json:"critical_flows"` - HighRiskFlows int `json:"high_risk_flows"` - NilRisks int `json:"nil_risks"` - UncheckedNilRisks int `json:"unchecked_nil_risks"` -} - -// SecuritySummary aggregates results across languages -type SecuritySummary struct { - Timestamp string `json:"timestamp"` - Languages []string `json:"languages"` - Analyses map[string]FlowAnalysis `json:"analyses"` - TotalStats Stats `json:"total_stats"` - TopRisks []Flow `json:"top_risks"` -} - -// Analyzer interface for language-specific implementations -type Analyzer interface { - Language() string - DetectSources(files []string) ([]Source, error) - DetectSinks(files []string) ([]Sink, error) - TrackFlows(sources []Source, sinks []Sink, files []string) ([]Flow, error) - DetectNilSources(files []string) ([]NilSource, error) - Analyze(files []string) (*FlowAnalysis, error) -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/dataflow/... -``` - ---- - -### Task 3: Implement Go source detection (5 min) - -**Description:** Detect untrusted data sources in Go code including HTTP requests, environment variables, database queries, and file reads. - -**File:** `scripts/ring:codereview/internal/dataflow/golang.go` - -```go -package dataflow - -import ( - "bufio" - "crypto/sha256" - "encoding/hex" - "fmt" - "os" - "regexp" - "strings" -) - -// GoAnalyzer implements Analyzer for Go code -type GoAnalyzer struct{} - -// NewGoAnalyzer creates a new Go analyzer -func NewGoAnalyzer() *GoAnalyzer { - return &GoAnalyzer{} -} - -// Language returns the language identifier -func (g *GoAnalyzer) Language() string { - return "go" -} - -// sourcePatterns maps patterns to source types -var goSourcePatterns = map[SourceType]*regexp.Regexp{ - SourceHTTPBody: regexp.MustCompile(`(?:r|req|request)\.Body`), - SourceHTTPQuery: regexp.MustCompile(`(?:r|req|request)\.(?:URL\.Query\(\)|FormValue\(|PostFormValue\(|Form\.Get\()`), - SourceHTTPHeader: regexp.MustCompile(`(?:r|req|request)\.Header\.(?:Get\(|Values\()`), - SourceHTTPPath: regexp.MustCompile(`(?:mux\.Vars\(|chi\.URLParam\(|c\.Param\(|params\.ByName\()`), - SourceEnvVar: regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(`), - SourceFile: regexp.MustCompile(`(?:os\.(?:Open|ReadFile)|ioutil\.ReadFile|io\.ReadAll)\(`), - SourceDatabase: regexp.MustCompile(`\.(?:Query|QueryRow|QueryContext|QueryRowContext)\(`), - SourceExternal: regexp.MustCompile(`(?:http\.(?:Get|Post|Do)|client\.(?:Get|Post|Do))\(`), -} - -// DetectSources finds all untrusted data sources in Go files -func (g *GoAnalyzer) DetectSources(files []string) ([]Source, error) { - var sources []Source - - for _, file := range files { - if !strings.HasSuffix(file, ".go") { - continue - } - - fileSources, err := g.detectSourcesInFile(file) - if err != nil { - continue // Skip files that can't be read - } - sources = append(sources, fileSources...) - } - - return sources, nil -} - -func (g *GoAnalyzer) detectSourcesInFile(filepath string) ([]Source, error) { - var sources []Source - - f, err := os.Open(filepath) - if err != nil { - return nil, err - } - defer f.Close() - - scanner := bufio.NewScanner(f) - lineNum := 0 - - for scanner.Scan() { - lineNum++ - line := scanner.Text() - - for sourceType, pattern := range goSourcePatterns { - matches := pattern.FindAllStringIndex(line, -1) - for _, match := range matches { - variable := extractVariable(line, match[0]) - sources = append(sources, Source{ - Type: sourceType, - File: filepath, - Line: lineNum, - Column: match[0] + 1, - Variable: variable, - Pattern: pattern.String(), - Context: strings.TrimSpace(line), - }) - } - } - } - - return sources, scanner.Err() -} - -// extractVariable attempts to extract the variable name from an assignment -func extractVariable(line string, matchStart int) string { - // Look for assignment pattern: varName := or varName = - assignPattern := regexp.MustCompile(`(\w+)\s*:?=\s*`) - if match := assignPattern.FindStringSubmatch(line[:matchStart]); len(match) > 1 { - return match[1] - } - - // Look for variable in function argument context - argPattern := regexp.MustCompile(`(\w+)\s*$`) - prefix := strings.TrimSpace(line[:matchStart]) - if match := argPattern.FindStringSubmatch(prefix); len(match) > 1 { - return match[1] - } - - return "unknown" -} - -// sinkPatterns maps patterns to sink types -var goSinkPatterns = map[SinkType]*regexp.Regexp{ - SinkDatabase: regexp.MustCompile(`\.(?:Exec|ExecContext|Prepare|PrepareContext)\(`), - SinkExec: regexp.MustCompile(`exec\.(?:Command|CommandContext)\(`), - SinkResponse: regexp.MustCompile(`(?:w|rw|writer|resp|response)\.(?:Write|WriteHeader|WriteString)\(`), - SinkLog: regexp.MustCompile(`(?:log|logger|slog)\.(?:Print|Printf|Println|Info|Warn|Error|Debug|Fatal)\(`), - SinkFile: regexp.MustCompile(`(?:os\.(?:WriteFile|Create)|ioutil\.WriteFile|f\.Write)\(`), - SinkTemplate: regexp.MustCompile(`(?:template\.(?:Execute|ExecuteTemplate)|tmpl\.Execute)\(`), - SinkRedirect: regexp.MustCompile(`http\.Redirect\(`), -} - -// DetectSinks finds all data sinks in Go files -func (g *GoAnalyzer) DetectSinks(files []string) ([]Sink, error) { - var sinks []Sink - - for _, file := range files { - if !strings.HasSuffix(file, ".go") { - continue - } - - fileSinks, err := g.detectSinksInFile(file) - if err != nil { - continue - } - sinks = append(sinks, fileSinks...) - } - - return sinks, nil -} - -func (g *GoAnalyzer) detectSinksInFile(filepath string) ([]Sink, error) { - var sinks []Sink - - f, err := os.Open(filepath) - if err != nil { - return nil, err - } - defer f.Close() - - scanner := bufio.NewScanner(f) - lineNum := 0 - - for scanner.Scan() { - lineNum++ - line := scanner.Text() - - for sinkType, pattern := range goSinkPatterns { - matches := pattern.FindAllStringIndex(line, -1) - for _, match := range matches { - funcName := extractFunctionName(line, match[0], match[1]) - sinks = append(sinks, Sink{ - Type: sinkType, - File: filepath, - Line: lineNum, - Column: match[0] + 1, - Function: funcName, - Pattern: pattern.String(), - Context: strings.TrimSpace(line), - }) - } - } - } - - return sinks, scanner.Err() -} - -func extractFunctionName(line string, start, end int) string { - if end > len(line) { - end = len(line) - } - match := line[start:end] - // Remove trailing parenthesis - match = strings.TrimSuffix(match, "(") - // Extract just the function name - parts := strings.Split(match, ".") - if len(parts) > 0 { - return parts[len(parts)-1] - } - return match -} - -// TrackFlows connects sources to sinks through variable tracking -func (g *GoAnalyzer) TrackFlows(sources []Source, sinks []Sink, files []string) ([]Flow, error) { - var flows []Flow - - // Build variable tracking map per file - fileVarMap := make(map[string]map[string][]Source) - for _, source := range sources { - if fileVarMap[source.File] == nil { - fileVarMap[source.File] = make(map[string][]Source) - } - fileVarMap[source.File][source.Variable] = append(fileVarMap[source.File][source.Variable], source) - } - - // For each sink, check if any source variable flows into it - for _, sink := range sinks { - // Check same-file flows - if varMap, ok := fileVarMap[sink.File]; ok { - for varName, varSources := range varMap { - if strings.Contains(sink.Context, varName) { - for _, source := range varSources { - flow := g.createFlow(source, sink) - flows = append(flows, flow) - } - } - } - } - - // Check for direct inline flows (source used directly in sink) - for sourceType, pattern := range goSourcePatterns { - if pattern.MatchString(sink.Context) { - directSource := Source{ - Type: sourceType, - File: sink.File, - Line: sink.Line, - Variable: "inline", - Pattern: pattern.String(), - Context: sink.Context, - } - flow := g.createFlow(directSource, sink) - flows = append(flows, flow) - } - } - } - - return flows, nil -} - -func (g *GoAnalyzer) createFlow(source Source, sink Sink) Flow { - risk := g.calculateRisk(source, sink) - sanitized, sanitizers := g.checkSanitization(source, sink) - - id := generateFlowID(source, sink) - - return Flow{ - ID: id, - Source: source, - Sink: sink, - Path: []string{source.Variable}, - Sanitized: sanitized, - Sanitizers: sanitizers, - Risk: risk, - Description: g.describeFlow(source, sink, risk), - } -} - -func generateFlowID(source Source, sink Sink) string { - data := fmt.Sprintf("%s:%d:%s:%d", source.File, source.Line, sink.File, sink.Line) - hash := sha256.Sum256([]byte(data)) - return hex.EncodeToString(hash[:8]) -} - -func (g *GoAnalyzer) calculateRisk(source Source, sink Sink) RiskLevel { - // Critical: User input to command execution or raw SQL - if sink.Type == SinkExec { - return RiskCritical - } - if sink.Type == SinkDatabase && (source.Type == SourceHTTPBody || source.Type == SourceHTTPQuery) { - return RiskCritical - } - - // High: User input to response (XSS) or template - if sink.Type == SinkResponse && (source.Type == SourceHTTPBody || source.Type == SourceHTTPQuery) { - return RiskHigh - } - if sink.Type == SinkTemplate { - return RiskHigh - } - if sink.Type == SinkRedirect && source.Type == SourceHTTPQuery { - return RiskHigh - } - - // Medium: Env vars to sensitive sinks, file operations - if source.Type == SourceEnvVar && (sink.Type == SinkDatabase || sink.Type == SinkExec) { - return RiskMedium - } - if sink.Type == SinkFile { - return RiskMedium - } - - // Low: Logging user data (info disclosure) - if sink.Type == SinkLog { - return RiskLow - } - - return RiskInfo -} - -// checkSanitization looks for common sanitization patterns -func (g *GoAnalyzer) checkSanitization(source Source, sink Sink) (bool, []string) { - var sanitizers []string - - // Common Go sanitization patterns - sanitizationPatterns := map[string]*regexp.Regexp{ - "html.EscapeString": regexp.MustCompile(`html\.EscapeString`), - "url.QueryEscape": regexp.MustCompile(`url\.QueryEscape`), - "strconv.Atoi": regexp.MustCompile(`strconv\.(?:Atoi|ParseInt|ParseFloat)`), - "prepared_statement": regexp.MustCompile(`\?\s*,|\$\d+`), - "filepath.Clean": regexp.MustCompile(`filepath\.(?:Clean|Base)`), - "regexp.MustCompile": regexp.MustCompile(`regexp\.(?:MustCompile|MatchString)`), - "validator": regexp.MustCompile(`(?:validate|validator|Validate)`), - } - - // Check if sink context contains sanitization - for name, pattern := range sanitizationPatterns { - if pattern.MatchString(sink.Context) { - sanitizers = append(sanitizers, name) - } - } - - return len(sanitizers) > 0, sanitizers -} - -func (g *GoAnalyzer) describeFlow(source Source, sink Sink, risk RiskLevel) string { - sourceDesc := map[SourceType]string{ - SourceHTTPBody: "HTTP request body", - SourceHTTPQuery: "HTTP query parameter", - SourceHTTPHeader: "HTTP header", - SourceHTTPPath: "URL path parameter", - SourceEnvVar: "environment variable", - SourceFile: "file content", - SourceDatabase: "database query result", - SourceExternal: "external API response", - } - - sinkDesc := map[SinkType]string{ - SinkDatabase: "database query", - SinkExec: "command execution", - SinkResponse: "HTTP response", - SinkLog: "log output", - SinkFile: "file write", - SinkTemplate: "template rendering", - SinkRedirect: "HTTP redirect", - } - - return fmt.Sprintf("%s flows from %s to %s", - strings.ToUpper(string(risk)), - sourceDesc[source.Type], - sinkDesc[sink.Type]) -} - -// nilPatterns for detecting potential nil sources -var goNilPatterns = []struct { - pattern *regexp.Regexp - origin string -}{ - {regexp.MustCompile(`(\w+)\s*,\s*(?:err|ok)\s*:?=.*\.(?:Get|Load|Lookup|Find)\(`), "map/cache lookup"}, - {regexp.MustCompile(`(\w+)\s*:?=.*\.(?:QueryRow|Get|First|Find)\(`), "database query"}, - {regexp.MustCompile(`(\w+)\s*,\s*ok\s*:?=.*\.\(`), "type assertion"}, - {regexp.MustCompile(`(\w+)\s*:?=.*json\.Unmarshal`), "JSON unmarshal"}, - {regexp.MustCompile(`(\w+)\s*:?=.*\.(?:Decode|Unmarshal)\(`), "decoding"}, - {regexp.MustCompile(`var\s+(\w+)\s+\*\w+`), "nil pointer declaration"}, - {regexp.MustCompile(`(\w+)\s*:?=\s*\(\*\w+\)\(nil\)`), "explicit nil"}, -} - -// nilCheckPatterns detect nil checks -var goNilCheckPatterns = []*regexp.Regexp{ - regexp.MustCompile(`if\s+(\w+)\s*[!=]=\s*nil`), - regexp.MustCompile(`if\s+nil\s*[!=]=\s*(\w+)`), - regexp.MustCompile(`if\s+(\w+)\s*!=\s*nil\s*\{`), - regexp.MustCompile(`(\w+)\s*!=\s*nil\s*&&`), - regexp.MustCompile(`(\w+)\s*==\s*nil\s*\|\|`), -} - -// DetectNilSources finds variables that may be nil -func (g *GoAnalyzer) DetectNilSources(files []string) ([]NilSource, error) { - var nilSources []NilSource - - for _, file := range files { - if !strings.HasSuffix(file, ".go") { - continue - } - - fileNils, err := g.detectNilSourcesInFile(file) - if err != nil { - continue - } - nilSources = append(nilSources, fileNils...) - } - - return nilSources, nil -} - -func (g *GoAnalyzer) detectNilSourcesInFile(filepath string) ([]NilSource, error) { - var nilSources []NilSource - - content, err := os.ReadFile(filepath) - if err != nil { - return nil, err - } - lines := strings.Split(string(content), "\n") - - // Track variables and their nil checks - varNilChecks := make(map[string]int) // variable -> line where checked - - // First pass: find nil checks - for lineNum, line := range lines { - for _, checkPattern := range goNilCheckPatterns { - if matches := checkPattern.FindStringSubmatch(line); len(matches) > 1 { - varNilChecks[matches[1]] = lineNum + 1 - } - } - } - - // Second pass: find nil sources - for lineNum, line := range lines { - for _, np := range goNilPatterns { - if matches := np.pattern.FindStringSubmatch(line); len(matches) > 1 { - varName := matches[1] - checkLine, isChecked := varNilChecks[varName] - - risk := RiskHigh - if isChecked { - risk = RiskLow - } - - nilSources = append(nilSources, NilSource{ - File: filepath, - Line: lineNum + 1, - Variable: varName, - Origin: np.origin, - IsChecked: isChecked, - CheckLine: checkLine, - Risk: risk, - }) - } - } - } - - return nilSources, nil -} - -// Analyze performs complete analysis on Go files -func (g *GoAnalyzer) Analyze(files []string) (*FlowAnalysis, error) { - sources, err := g.DetectSources(files) - if err != nil { - return nil, fmt.Errorf("detecting sources: %w", err) - } - - sinks, err := g.DetectSinks(files) - if err != nil { - return nil, fmt.Errorf("detecting sinks: %w", err) - } - - flows, err := g.TrackFlows(sources, sinks, files) - if err != nil { - return nil, fmt.Errorf("tracking flows: %w", err) - } - - nilSources, err := g.DetectNilSources(files) - if err != nil { - return nil, fmt.Errorf("detecting nil sources: %w", err) - } - - // Calculate statistics - stats := Stats{ - TotalSources: len(sources), - TotalSinks: len(sinks), - TotalFlows: len(flows), - } - - for _, flow := range flows { - if !flow.Sanitized { - stats.UnsanitizedFlows++ - } - switch flow.Risk { - case RiskCritical: - stats.CriticalFlows++ - case RiskHigh: - stats.HighRiskFlows++ - } - } - - stats.NilRisks = len(nilSources) - for _, ns := range nilSources { - if !ns.IsChecked { - stats.UncheckedNilRisks++ - } - } - - return &FlowAnalysis{ - Language: "go", - Sources: sources, - Sinks: sinks, - Flows: flows, - NilSources: nilSources, - Statistics: stats, - }, nil -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/dataflow/... -``` - ---- - -### Task 4: Implement Go sink detection (5 min) - -**Description:** Already included in Task 3 (`DetectSinks` method in `golang.go`). The sink detection includes: -- Database operations: `Exec`, `ExecContext`, `Prepare` -- Command execution: `exec.Command`, `exec.CommandContext` -- HTTP responses: `Write`, `WriteHeader`, `WriteString` -- Logging: `log.Print*`, `slog.*`, `logger.*` -- File writes: `os.WriteFile`, `os.Create`, `f.Write` -- Templates: `template.Execute*` -- Redirects: `http.Redirect` - -**Verification:** -```bash -cd scripts/ring:codereview && go test ./internal/dataflow/... -run TestDetectSinks -v -``` - ---- - -### Task 5: Implement flow tracking (5 min) - -**Description:** Already included in Task 3 (`TrackFlows` method in `golang.go`). The flow tracking: -- Builds variable map per file -- Tracks variables from source to sink -- Detects inline flows (source used directly in sink call) -- Calculates risk level based on source-sink combination -- Checks for sanitization patterns - -**Verification:** -```bash -cd scripts/ring:codereview && go test ./internal/dataflow/... -run TestTrackFlows -v -``` - ---- - -### Task 6: Implement nil source tracking (4 min) - -**Description:** Already included in Task 3 (`DetectNilSources` method in `golang.go`). The nil tracking: -- Detects map/cache lookups that may return nil -- Detects database queries that may return nil -- Detects type assertions -- Detects JSON/decode operations -- Tracks whether nil checks exist for each variable - -**Verification:** -```bash -cd scripts/ring:codereview && go test ./internal/dataflow/... -run TestNilSources -v -``` - ---- - -### Task 7: Create Python data_flow.py (5 min) - -**Description:** Python script for analyzing Python/TypeScript code with framework detection for Flask, Django, FastAPI, and Express. - -**File:** `scripts/ring:codereview/py/data_flow.py` - -```python -#!/usr/bin/env python3 -""" -Data flow analysis for Python and TypeScript projects. -Detects sources, sinks, and flows in common web frameworks. -""" - -import json -import re -import sys -from pathlib import Path -from typing import Any -from dataclasses import dataclass, asdict - - -@dataclass -class Source: - type: str - file: str - line: int - column: int - variable: str - pattern: str - context: str - - -@dataclass -class Sink: - type: str - file: str - line: int - column: int - function: str - pattern: str - context: str - - -@dataclass -class Flow: - id: str - source: dict - sink: dict - path: list - sanitized: bool - sanitizers: list - risk: str - description: str - - -@dataclass -class NilSource: - file: str - line: int - variable: str - origin: str - is_checked: bool - check_line: int - usage_line: int - risk: str - - -# Python source patterns by framework -PYTHON_SOURCE_PATTERNS = { - # Flask - "http_body": [ - r"request\.(?:get_json|json|data|form)", - r"request\.files", - ], - "http_query": [ - r"request\.args\.get\(", - r"request\.args\[", - r"request\.values", - ], - "http_header": [ - r"request\.headers\.get\(", - r"request\.headers\[", - ], - "http_path": [ - r"@app\.route.*<(\w+)>", - r"@router\.(?:get|post|put|delete).*\{(\w+)\}", - ], - # Django - "http_body_django": [ - r"request\.POST\.get\(", - r"request\.POST\[", - r"request\.body", - ], - "http_query_django": [ - r"request\.GET\.get\(", - r"request\.GET\[", - ], - # FastAPI - "http_body_fastapi": [ - r"Body\(", - r"Form\(", - ], - "http_query_fastapi": [ - r"Query\(", - ], - # Common - "env_var": [ - r"os\.(?:getenv|environ\.get)\(", - r"os\.environ\[", - ], - "file_read": [ - r"open\([^)]+\)\.read", - r"Path\([^)]+\)\.read_text", - ], - "database": [ - r"\.(?:execute|fetchone|fetchall|fetchmany)\(", - r"cursor\.\w+\(", - ], - "external_api": [ - r"requests\.(?:get|post|put|delete|patch)\(", - r"httpx\.(?:get|post|put|delete|patch)\(", - r"aiohttp\.\w+", - ], -} - -PYTHON_SINK_PATTERNS = { - "database": [ - r"cursor\.execute\(", - r"\.execute\([^)]*%", - r"\.execute\([^)]*\.format", - r"\.raw\(", - ], - "command_exec": [ - r"subprocess\.(?:run|call|Popen|check_output)\(", - r"os\.(?:system|popen|exec\w*)\(", - r"eval\(", - r"exec\(", - ], - "http_response": [ - r"return\s+(?:jsonify|render_template|Response)\(", - r"HttpResponse\(", - r"JsonResponse\(", - r"return\s+\{", - ], - "logging": [ - r"(?:logging|logger)\.\w+\(", - r"print\(", - ], - "file_write": [ - r"open\([^)]+,\s*['\"]w", - r"\.write\(", - r"Path\([^)]+\)\.write_text", - ], - "template": [ - r"render_template\(", - r"render\(", - r"Template\(", - ], - "redirect": [ - r"redirect\(", - r"HttpResponseRedirect\(", - ], -} - -# TypeScript/JavaScript patterns -TS_SOURCE_PATTERNS = { - "http_body": [ - r"req\.body", - r"request\.body", - r"ctx\.request\.body", - ], - "http_query": [ - r"req\.query", - r"req\.params", - r"request\.query", - r"ctx\.query", - r"searchParams\.get\(", - ], - "http_header": [ - r"req\.headers", - r"request\.headers", - r"ctx\.headers", - ], - "http_path": [ - r"req\.params\.", - r":(\w+)", - ], - "env_var": [ - r"process\.env\.", - r"Deno\.env\.get\(", - ], - "file_read": [ - r"fs\.readFile", - r"readFileSync\(", - r"Deno\.readTextFile", - ], - "database": [ - r"\.query\(", - r"\.findOne\(", - r"\.find\(", - r"\.aggregate\(", - ], - "external_api": [ - r"fetch\(", - r"axios\.\w+\(", - r"got\.\w+\(", - ], - "user_input": [ - r"prompt\(", - r"readline\.", - ], -} - -TS_SINK_PATTERNS = { - "database": [ - r"\.query\([^)]*\$\{", - r"\.query\([^)]*\+", - r"\.exec\(", - r"\.raw\(", - ], - "command_exec": [ - r"exec\(", - r"execSync\(", - r"spawn\(", - r"eval\(", - r"Function\(", - r"new\s+Function\(", - ], - "http_response": [ - r"res\.(?:send|json|write)\(", - r"response\.(?:send|json|write)\(", - r"ctx\.body\s*=", - r"return\s+Response\.", - ], - "logging": [ - r"console\.\w+\(", - r"logger\.\w+\(", - ], - "file_write": [ - r"fs\.writeFile", - r"writeFileSync\(", - r"Deno\.writeTextFile", - ], - "template": [ - r"\.render\(", - r"dangerouslySetInnerHTML", - r"innerHTML\s*=", - ], - "redirect": [ - r"res\.redirect\(", - r"response\.redirect\(", - r"window\.location", - ], -} - -# Null/undefined patterns for TypeScript -TS_NULL_PATTERNS = [ - (r"(\w+)\s*=\s*await\s+\w+\.(?:findOne|findFirst|get)\(", "database query"), - (r"(\w+)\s*=\s*\w+\.get\(", "map/cache lookup"), - (r"(\w+)\s*=\s*JSON\.parse\(", "JSON parse"), - (r"(\w+)\?\.", "optional chaining usage"), - (r"(\w+)\s+as\s+\w+", "type assertion"), - (r"let\s+(\w+):\s*\w+\s*\|?\s*(?:null|undefined)", "nullable declaration"), -] - -TS_NULL_CHECK_PATTERNS = [ - r"if\s*\(\s*(\w+)\s*(?:!==?|===?)\s*(?:null|undefined)", - r"if\s*\(\s*!(\w+)\s*\)", - r"if\s*\(\s*(\w+)\s*\)", - r"(\w+)\s*\?\?", - r"(\w+)\s*&&\s*(\w+)\.", -] - - -def hash_flow(source: Source, sink: Sink) -> str: - """Generate unique ID for a flow.""" - import hashlib - data = f"{source.file}:{source.line}:{sink.file}:{sink.line}" - return hashlib.sha256(data.encode()).hexdigest()[:16] - - -def detect_sources(files: list[str], language: str) -> list[Source]: - """Detect untrusted data sources in files.""" - sources = [] - patterns = PYTHON_SOURCE_PATTERNS if language == "python" else TS_SOURCE_PATTERNS - - for filepath in files: - try: - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: - lines = f.readlines() - except (IOError, OSError): - continue - - for line_num, line in enumerate(lines, 1): - for source_type, pattern_list in patterns.items(): - for pattern in pattern_list: - for match in re.finditer(pattern, line): - variable = extract_variable(line, match.start()) - sources.append(Source( - type=source_type.replace("_django", "").replace("_fastapi", ""), - file=filepath, - line=line_num, - column=match.start() + 1, - variable=variable, - pattern=pattern, - context=line.strip() - )) - - return sources - - -def detect_sinks(files: list[str], language: str) -> list[Sink]: - """Detect data sinks in files.""" - sinks = [] - patterns = PYTHON_SINK_PATTERNS if language == "python" else TS_SINK_PATTERNS - - for filepath in files: - try: - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: - lines = f.readlines() - except (IOError, OSError): - continue - - for line_num, line in enumerate(lines, 1): - for sink_type, pattern_list in patterns.items(): - for pattern in pattern_list: - for match in re.finditer(pattern, line): - func_name = extract_function_name(line, match.start(), match.end()) - sinks.append(Sink( - type=sink_type, - file=filepath, - line=line_num, - column=match.start() + 1, - function=func_name, - pattern=pattern, - context=line.strip() - )) - - return sinks - - -def extract_variable(line: str, match_start: int) -> str: - """Extract variable name from assignment.""" - prefix = line[:match_start] - # Look for assignment - assign_match = re.search(r'(\w+)\s*=\s*$', prefix) - if assign_match: - return assign_match.group(1) - # Look for const/let/var declaration - decl_match = re.search(r'(?:const|let|var)\s+(\w+)\s*=\s*$', prefix) - if decl_match: - return decl_match.group(1) - return "unknown" - - -def extract_function_name(line: str, start: int, end: int) -> str: - """Extract function name from match.""" - match_text = line[start:end] - # Remove parenthesis - match_text = re.sub(r'\($', '', match_text) - # Get last part after dot - parts = match_text.split('.') - return parts[-1] if parts else match_text - - -def calculate_risk(source: Source, sink: Sink) -> str: - """Calculate risk level for a flow.""" - # Critical: User input to command execution or raw SQL - if sink.type == "command_exec": - return "critical" - if sink.type == "database" and source.type in ("http_body", "http_query"): - return "critical" - - # High: User input to response (XSS) or template - if sink.type in ("http_response", "template") and source.type in ("http_body", "http_query"): - return "high" - if sink.type == "redirect" and source.type == "http_query": - return "high" - - # Medium: Env vars to sensitive sinks - if source.type == "env_var" and sink.type in ("database", "command_exec"): - return "medium" - if sink.type == "file_write": - return "medium" - - # Low: Logging - if sink.type == "logging": - return "low" - - return "info" - - -def check_sanitization(source: Source, sink: Sink, language: str) -> tuple[bool, list[str]]: - """Check for sanitization patterns.""" - sanitizers = [] - context = sink.context - - if language == "python": - patterns = { - "parameterized_query": r"\?\s*,|\%s", - "html_escape": r"(?:escape|html\.escape|markupsafe\.escape)", - "quote": r"shlex\.quote", - "validator": r"(?:validate|validator|pydantic)", - "bleach": r"bleach\.", - } - else: - patterns = { - "parameterized_query": r"\$\d+|\?", - "escape_html": r"(?:escapeHtml|sanitize|DOMPurify)", - "validator": r"(?:validator|zod|yup|joi)", - "prepared": r"prepare\(", - } - - for name, pattern in patterns.items(): - if re.search(pattern, context): - sanitizers.append(name) - - return len(sanitizers) > 0, sanitizers - - -def track_flows(sources: list[Source], sinks: list[Sink], language: str) -> list[Flow]: - """Connect sources to sinks through variable tracking.""" - flows = [] - - # Build variable map per file - file_var_map: dict[str, dict[str, list[Source]]] = {} - for source in sources: - if source.file not in file_var_map: - file_var_map[source.file] = {} - if source.variable not in file_var_map[source.file]: - file_var_map[source.file][source.variable] = [] - file_var_map[source.file][source.variable].append(source) - - for sink in sinks: - # Same file flows - if sink.file in file_var_map: - for var_name, var_sources in file_var_map[sink.file].items(): - if var_name in sink.context: - for source in var_sources: - risk = calculate_risk(source, sink) - sanitized, sanitizers = check_sanitization(source, sink, language) - - flows.append(Flow( - id=hash_flow(source, sink), - source=asdict(source), - sink=asdict(sink), - path=[source.variable], - sanitized=sanitized, - sanitizers=sanitizers, - risk=risk, - description=describe_flow(source, sink, risk) - )) - - return flows - - -def describe_flow(source: Source, sink: Sink, risk: str) -> str: - """Generate human-readable description.""" - source_desc = { - "http_body": "HTTP request body", - "http_query": "HTTP query parameter", - "http_header": "HTTP header", - "http_path": "URL path parameter", - "env_var": "environment variable", - "file_read": "file content", - "database": "database query result", - "external_api": "external API response", - "user_input": "user input", - } - - sink_desc = { - "database": "database query", - "command_exec": "command execution", - "http_response": "HTTP response", - "logging": "log output", - "file_write": "file write", - "template": "template rendering", - "redirect": "HTTP redirect", - } - - return f"{risk.upper()}: {source_desc.get(source.type, source.type)} flows to {sink_desc.get(sink.type, sink.type)}" - - -def detect_null_sources(files: list[str], language: str) -> list[NilSource]: - """Detect variables that may be null/undefined.""" - if language != "typescript": - return [] - - nil_sources = [] - - for filepath in files: - try: - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - lines = content.split('\n') - except (IOError, OSError): - continue - - # Build null check map - var_checks: dict[str, int] = {} - for line_num, line in enumerate(lines, 1): - for pattern in TS_NULL_CHECK_PATTERNS: - for match in re.finditer(pattern, line): - for group in match.groups(): - if group: - var_checks[group] = line_num - - # Find null sources - for line_num, line in enumerate(lines, 1): - for pattern, origin in TS_NULL_PATTERNS: - for match in re.finditer(pattern, line): - if match.groups(): - var_name = match.group(1) - check_line = var_checks.get(var_name, 0) - is_checked = var_name in var_checks - - nil_sources.append(NilSource( - file=filepath, - line=line_num, - variable=var_name, - origin=origin, - is_checked=is_checked, - check_line=check_line, - usage_line=0, - risk="low" if is_checked else "high" - )) - - return nil_sources - - -def analyze(files: list[str], language: str) -> dict[str, Any]: - """Perform complete analysis.""" - sources = detect_sources(files, language) - sinks = detect_sinks(files, language) - flows = track_flows(sources, sinks, language) - nil_sources = detect_null_sources(files, language) - - # Calculate statistics - unsanitized = sum(1 for f in flows if not f.sanitized) - critical = sum(1 for f in flows if f.risk == "critical") - high = sum(1 for f in flows if f.risk == "high") - unchecked_nil = sum(1 for n in nil_sources if not n.is_checked) - - return { - "language": language, - "sources": [asdict(s) for s in sources], - "sinks": [asdict(s) for s in sinks], - "flows": [asdict(f) for f in flows], - "nil_sources": [asdict(n) for n in nil_sources], - "statistics": { - "total_sources": len(sources), - "total_sinks": len(sinks), - "total_flows": len(flows), - "unsanitized_flows": unsanitized, - "critical_flows": critical, - "high_risk_flows": high, - "nil_risks": len(nil_sources), - "unchecked_nil_risks": unchecked_nil, - } - } - - -def main(): - """CLI entry point.""" - if len(sys.argv) < 3: - print("Usage: data_flow.py [file2] ...", file=sys.stderr) - print("Languages: python, typescript", file=sys.stderr) - sys.exit(1) - - language = sys.argv[1] - if language not in ("python", "typescript"): - print(f"Unsupported language: {language}", file=sys.stderr) - sys.exit(1) - - files = sys.argv[2:] - - # Filter files by language - if language == "python": - files = [f for f in files if f.endswith('.py')] - else: - files = [f for f in files if f.endswith(('.ts', '.tsx', '.js', '.jsx'))] - - result = analyze(files, language) - print(json.dumps(result, indent=2)) - - -if __name__ == "__main__": - main() -``` - -**Verification:** -```bash -chmod +x scripts/ring:codereview/py/data_flow.py -python3 scripts/ring:codereview/py/data_flow.py python scripts/ring:codereview/py/data_flow.py -``` - ---- - -### Task 8: Implement Go wrapper for Python (3 min) - -**Description:** Create Go wrapper to invoke Python analyzer for Python/TypeScript files. - -**File:** `scripts/ring:codereview/internal/dataflow/python.go` - -```go -package dataflow - -import ( - "encoding/json" - "fmt" - "os/exec" - "path/filepath" - "strings" -) - -// PythonAnalyzer wraps the Python data_flow.py script -type PythonAnalyzer struct { - scriptPath string - language string -} - -// NewPythonAnalyzer creates analyzer for Python code -func NewPythonAnalyzer(scriptDir string) *PythonAnalyzer { - return &PythonAnalyzer{ - scriptPath: filepath.Join(scriptDir, "py", "data_flow.py"), - language: "python", - } -} - -// NewTypeScriptAnalyzer creates analyzer for TypeScript code -func NewTypeScriptAnalyzer(scriptDir string) *PythonAnalyzer { - return &PythonAnalyzer{ - scriptPath: filepath.Join(scriptDir, "py", "data_flow.py"), - language: "typescript", - } -} - -// Language returns the language identifier -func (p *PythonAnalyzer) Language() string { - return p.language -} - -// filterFiles returns only files matching the language -func (p *PythonAnalyzer) filterFiles(files []string) []string { - var filtered []string - for _, f := range files { - switch p.language { - case "python": - if strings.HasSuffix(f, ".py") { - filtered = append(filtered, f) - } - case "typescript": - if strings.HasSuffix(f, ".ts") || strings.HasSuffix(f, ".tsx") || - strings.HasSuffix(f, ".js") || strings.HasSuffix(f, ".jsx") { - filtered = append(filtered, f) - } - } - } - return filtered -} - -// runScript executes the Python script and returns parsed output -func (p *PythonAnalyzer) runScript(files []string) (*FlowAnalysis, error) { - filtered := p.filterFiles(files) - if len(filtered) == 0 { - return &FlowAnalysis{ - Language: p.language, - Sources: []Source{}, - Sinks: []Sink{}, - Flows: []Flow{}, - NilSources: []NilSource{}, - Statistics: Stats{}, - }, nil - } - - args := append([]string{p.scriptPath, p.language}, filtered...) - cmd := exec.Command("python3", args...) - - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return nil, fmt.Errorf("python script failed: %s", string(exitErr.Stderr)) - } - return nil, fmt.Errorf("running python script: %w", err) - } - - var result FlowAnalysis - if err := json.Unmarshal(output, &result); err != nil { - return nil, fmt.Errorf("parsing python output: %w", err) - } - - return &result, nil -} - -// DetectSources finds sources using Python script -func (p *PythonAnalyzer) DetectSources(files []string) ([]Source, error) { - result, err := p.runScript(files) - if err != nil { - return nil, err - } - return result.Sources, nil -} - -// DetectSinks finds sinks using Python script -func (p *PythonAnalyzer) DetectSinks(files []string) ([]Sink, error) { - result, err := p.runScript(files) - if err != nil { - return nil, err - } - return result.Sinks, nil -} - -// TrackFlows tracks flows using Python script -func (p *PythonAnalyzer) TrackFlows(sources []Source, sinks []Sink, files []string) ([]Flow, error) { - result, err := p.runScript(files) - if err != nil { - return nil, err - } - return result.Flows, nil -} - -// DetectNilSources finds null/undefined sources using Python script -func (p *PythonAnalyzer) DetectNilSources(files []string) ([]NilSource, error) { - result, err := p.runScript(files) - if err != nil { - return nil, err - } - return result.NilSources, nil -} - -// Analyze performs complete analysis using Python script -func (p *PythonAnalyzer) Analyze(files []string) (*FlowAnalysis, error) { - return p.runScript(files) -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/dataflow/... -``` - ---- - -### Task 9: Generate security-summary.md (5 min) - -**Description:** Create markdown renderer for security summary report with risk levels and recommendations. - -**File:** `scripts/ring:codereview/internal/dataflow/report.go` - -```go -package dataflow - -import ( - "fmt" - "sort" - "strings" - "time" - "unicode" - "unicode/utf8" -) - -// capitalizeFirst returns the string with its first letter capitalized. -// This is a stdlib-only replacement for the deprecated strings.Title. -func capitalizeFirst(s string) string { - if s == "" { - return s - } - r, size := utf8.DecodeRuneInString(s) - if r == utf8.RuneError { - return s - } - return string(unicode.ToUpper(r)) + s[size:] -} - -// GenerateSecuritySummary creates a markdown report from analysis results -func GenerateSecuritySummary(analyses map[string]*FlowAnalysis) string { - var sb strings.Builder - - // Header - sb.WriteString("# Security Data Flow Analysis\n\n") - sb.WriteString(fmt.Sprintf("**Generated:** %s\n\n", time.Now().Format("2006-01-02 15:04:05"))) - - // Calculate totals - var totalStats Stats - var allFlows []Flow - var allNilSources []NilSource - - languages := make([]string, 0, len(analyses)) - for lang := range analyses { - languages = append(languages, lang) - } - sort.Strings(languages) - - for _, lang := range languages { - analysis := analyses[lang] - totalStats.TotalSources += analysis.Statistics.TotalSources - totalStats.TotalSinks += analysis.Statistics.TotalSinks - totalStats.TotalFlows += analysis.Statistics.TotalFlows - totalStats.UnsanitizedFlows += analysis.Statistics.UnsanitizedFlows - totalStats.CriticalFlows += analysis.Statistics.CriticalFlows - totalStats.HighRiskFlows += analysis.Statistics.HighRiskFlows - totalStats.NilRisks += analysis.Statistics.NilRisks - totalStats.UncheckedNilRisks += analysis.Statistics.UncheckedNilRisks - - allFlows = append(allFlows, analysis.Flows...) - allNilSources = append(allNilSources, analysis.NilSources...) - } - - // Executive Summary - sb.WriteString("## Executive Summary\n\n") - sb.WriteString(fmt.Sprintf("| Metric | Count |\n")) - sb.WriteString(fmt.Sprintf("|--------|-------|\n")) - sb.WriteString(fmt.Sprintf("| Languages Analyzed | %d |\n", len(languages))) - sb.WriteString(fmt.Sprintf("| Total Sources | %d |\n", totalStats.TotalSources)) - sb.WriteString(fmt.Sprintf("| Total Sinks | %d |\n", totalStats.TotalSinks)) - sb.WriteString(fmt.Sprintf("| Total Data Flows | %d |\n", totalStats.TotalFlows)) - sb.WriteString(fmt.Sprintf("| **Unsanitized Flows** | **%d** |\n", totalStats.UnsanitizedFlows)) - sb.WriteString(fmt.Sprintf("| Critical Risk Flows | %d |\n", totalStats.CriticalFlows)) - sb.WriteString(fmt.Sprintf("| High Risk Flows | %d |\n", totalStats.HighRiskFlows)) - sb.WriteString(fmt.Sprintf("| Nil/Null Risks | %d |\n", totalStats.NilRisks)) - sb.WriteString(fmt.Sprintf("| Unchecked Nil/Null | %d |\n", totalStats.UncheckedNilRisks)) - sb.WriteString("\n") - - // Risk Assessment - sb.WriteString("## Risk Assessment\n\n") - if totalStats.CriticalFlows > 0 { - sb.WriteString("### :rotating_light: CRITICAL\n\n") - sb.WriteString("**Immediate action required.** Critical vulnerabilities detected that could lead to:\n") - sb.WriteString("- Remote Code Execution (RCE)\n") - sb.WriteString("- SQL Injection\n") - sb.WriteString("- Command Injection\n\n") - } - if totalStats.HighRiskFlows > 0 { - sb.WriteString("### :warning: HIGH\n\n") - sb.WriteString("**Priority remediation needed.** High-risk issues detected:\n") - sb.WriteString("- Cross-Site Scripting (XSS)\n") - sb.WriteString("- Open Redirect\n") - sb.WriteString("- Template Injection\n\n") - } - if totalStats.UncheckedNilRisks > 0 { - sb.WriteString("### :exclamation: NIL SAFETY\n\n") - sb.WriteString(fmt.Sprintf("**%d unchecked nil/null values** that could cause runtime panics or crashes.\n\n", - totalStats.UncheckedNilRisks)) - } - - // Critical and High Risk Flows - if totalStats.CriticalFlows > 0 || totalStats.HighRiskFlows > 0 { - sb.WriteString("## Critical & High Risk Flows\n\n") - - // Sort flows by risk - sortedFlows := make([]Flow, len(allFlows)) - copy(sortedFlows, allFlows) - sort.Slice(sortedFlows, func(i, j int) bool { - return riskPriority(sortedFlows[i].Risk) < riskPriority(sortedFlows[j].Risk) - }) - - for _, flow := range sortedFlows { - if flow.Risk != RiskCritical && flow.Risk != RiskHigh { - continue - } - - icon := ":rotating_light:" - if flow.Risk == RiskHigh { - icon = ":warning:" - } - - sb.WriteString(fmt.Sprintf("### %s %s\n\n", icon, flow.Description)) - sb.WriteString(fmt.Sprintf("- **Source:** `%s:%d` - %s\n", - flow.Source.File, flow.Source.Line, flow.Source.Type)) - sb.WriteString(fmt.Sprintf("- **Sink:** `%s:%d` - %s\n", - flow.Sink.File, flow.Sink.Line, flow.Sink.Function)) - sb.WriteString(fmt.Sprintf("- **Sanitized:** %v\n", flow.Sanitized)) - if len(flow.Sanitizers) > 0 { - sb.WriteString(fmt.Sprintf("- **Sanitizers:** %s\n", strings.Join(flow.Sanitizers, ", "))) - } - - // Context - sb.WriteString("\n**Source Context:**\n```\n") - sb.WriteString(flow.Source.Context) - sb.WriteString("\n```\n\n") - - sb.WriteString("**Sink Context:**\n```\n") - sb.WriteString(flow.Sink.Context) - sb.WriteString("\n```\n\n") - - // Recommendation - sb.WriteString("**Recommendation:** ") - sb.WriteString(getRecommendation(flow)) - sb.WriteString("\n\n---\n\n") - } - } - - // Nil Safety Issues - if len(allNilSources) > 0 { - sb.WriteString("## Nil/Null Safety Issues\n\n") - sb.WriteString("| File | Line | Variable | Origin | Checked | Risk |\n") - sb.WriteString("|------|------|----------|--------|---------|------|\n") - - for _, ns := range allNilSources { - checked := ":x:" - if ns.IsChecked { - checked = ":white_check_mark:" - } - sb.WriteString(fmt.Sprintf("| `%s` | %d | `%s` | %s | %s | %s |\n", - ns.File, ns.Line, ns.Variable, ns.Origin, checked, ns.Risk)) - } - sb.WriteString("\n") - } - - // Per-Language Breakdown - sb.WriteString("## Language Breakdown\n\n") - for _, lang := range languages { - analysis := analyses[lang] - sb.WriteString(fmt.Sprintf("### %s\n\n", capitalizeFirst(lang))) - sb.WriteString(fmt.Sprintf("| Metric | Count |\n")) - sb.WriteString(fmt.Sprintf("|--------|-------|\n")) - sb.WriteString(fmt.Sprintf("| Sources | %d |\n", analysis.Statistics.TotalSources)) - sb.WriteString(fmt.Sprintf("| Sinks | %d |\n", analysis.Statistics.TotalSinks)) - sb.WriteString(fmt.Sprintf("| Flows | %d |\n", analysis.Statistics.TotalFlows)) - sb.WriteString(fmt.Sprintf("| Unsanitized | %d |\n", analysis.Statistics.UnsanitizedFlows)) - sb.WriteString(fmt.Sprintf("| Critical | %d |\n", analysis.Statistics.CriticalFlows)) - sb.WriteString(fmt.Sprintf("| High | %d |\n", analysis.Statistics.HighRiskFlows)) - sb.WriteString("\n") - } - - // Recommendations - sb.WriteString("## General Recommendations\n\n") - sb.WriteString("1. **Use Parameterized Queries:** Never concatenate user input into SQL queries\n") - sb.WriteString("2. **Escape Output:** Always escape data before rendering in HTML responses\n") - sb.WriteString("3. **Validate Input:** Validate and sanitize all user input at entry points\n") - sb.WriteString("4. **Avoid Command Execution:** Never pass user input to shell commands\n") - sb.WriteString("5. **Check for Nil:** Always check pointers/optionals before dereferencing\n") - sb.WriteString("6. **Use Allow Lists:** Prefer allow lists over deny lists for validation\n") - - return sb.String() -} - -func riskPriority(risk RiskLevel) int { - switch risk { - case RiskCritical: - return 0 - case RiskHigh: - return 1 - case RiskMedium: - return 2 - case RiskLow: - return 3 - default: - return 4 - } -} - -func getRecommendation(flow Flow) string { - switch { - case flow.Sink.Type == SinkExec: - return "Remove command execution or use a strict allow list for permitted commands. Never pass user input directly to shell." - case flow.Sink.Type == SinkDatabase && !flow.Sanitized: - return "Use parameterized queries or prepared statements. Never concatenate user input into SQL." - case flow.Sink.Type == SinkResponse && (flow.Source.Type == SourceHTTPBody || flow.Source.Type == SourceHTTPQuery): - return "Escape output using html.EscapeString() or equivalent. Consider using a templating engine with auto-escaping." - case flow.Sink.Type == SinkTemplate: - return "Ensure template engine auto-escapes output. Avoid using raw/unescaped directives with user input." - case flow.Sink.Type == SinkRedirect: - return "Validate redirect URLs against an allow list. Never redirect to user-controlled URLs directly." - case flow.Sink.Type == SinkFile: - return "Validate file paths and use filepath.Clean(). Ensure user cannot control path traversal sequences." - case flow.Sink.Type == SinkLog: - return "Sanitize log output to prevent log injection. Consider masking sensitive data." - default: - return "Review the data flow and ensure proper input validation and output encoding." - } -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build ./internal/dataflow/... -``` - ---- - -### Task 10: Create CLI binary (4 min) - -**Description:** Create the main CLI entry point that orchestrates the data flow analysis. - -**File:** `scripts/ring:codereview/cmd/data-flow/main.go` - -```go -package main - -import ( - "encoding/json" - "flag" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/dataflow" -) - -type ScopeFile struct { - Files []string `json:"files"` - Languages map[string][]string `json:"languages"` -} - -func main() { - var ( - scopeFile = flag.String("scope", "scope.json", "Path to scope.json from Phase 0") - outputDir = flag.String("output", ".", "Output directory for results") - scriptDir = flag.String("scripts", "", "Path to scripts/ring:codereview directory") - language = flag.String("lang", "", "Analyze specific language only (go, python, typescript)") - jsonOutput = flag.Bool("json", false, "Output JSON only, no markdown summary") - verbose = flag.Bool("v", false, "Verbose output") - ) - flag.Parse() - - // Find script directory if not provided - if *scriptDir == "" { - exe, err := os.Executable() - if err == nil { - *scriptDir = filepath.Dir(filepath.Dir(exe)) - } else { - *scriptDir = "." - } - } - - // Load scope file - scope, err := loadScope(*scopeFile) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading scope: %v\n", err) - os.Exit(1) - } - - if *verbose { - fmt.Printf("Loaded scope with %d files\n", len(scope.Files)) - for lang, files := range scope.Languages { - fmt.Printf(" %s: %d files\n", lang, len(files)) - } - } - - // Initialize analyzers - analyzers := make(map[string]dataflow.Analyzer) - - if *language == "" || *language == "go" { - analyzers["go"] = dataflow.NewGoAnalyzer() - } - if *language == "" || *language == "python" { - analyzers["python"] = dataflow.NewPythonAnalyzer(*scriptDir) - } - if *language == "" || *language == "typescript" { - analyzers["typescript"] = dataflow.NewTypeScriptAnalyzer(*scriptDir) - } - - // Run analysis - results := make(map[string]*dataflow.FlowAnalysis) - - for lang, analyzer := range analyzers { - var files []string - if langFiles, ok := scope.Languages[lang]; ok { - files = langFiles - } else { - files = filterFilesByLanguage(scope.Files, lang) - } - - if len(files) == 0 { - if *verbose { - fmt.Printf("No %s files to analyze\n", lang) - } - continue - } - - if *verbose { - fmt.Printf("Analyzing %d %s files...\n", len(files), lang) - } - - analysis, err := analyzer.Analyze(files) - if err != nil { - fmt.Fprintf(os.Stderr, "Error analyzing %s: %v\n", lang, err) - continue - } - - results[lang] = analysis - - // Write language-specific JSON - outputPath := filepath.Join(*outputDir, fmt.Sprintf("%s-flow.json", lang)) - if err := writeJSON(outputPath, analysis); err != nil { - fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", outputPath, err) - } else if *verbose { - fmt.Printf("Wrote %s\n", outputPath) - } - } - - if len(results) == 0 { - fmt.Println("No files analyzed") - os.Exit(0) - } - - // Generate summary - if !*jsonOutput { - summary := dataflow.GenerateSecuritySummary(results) - summaryPath := filepath.Join(*outputDir, "security-summary.md") - if err := os.WriteFile(summaryPath, []byte(summary), 0644); err != nil { - fmt.Fprintf(os.Stderr, "Error writing summary: %v\n", err) - } else if *verbose { - fmt.Printf("Wrote %s\n", summaryPath) - } - - // Print summary stats - printSummary(results) - } else { - // JSON output mode - output, _ := json.MarshalIndent(results, "", " ") - fmt.Println(string(output)) - } -} - -func loadScope(path string) (*ScopeFile, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - var scope ScopeFile - if err := json.Unmarshal(data, &scope); err != nil { - return nil, err - } - - // If languages map is empty, populate from files - if scope.Languages == nil { - scope.Languages = make(map[string][]string) - } - if len(scope.Languages) == 0 { - for _, file := range scope.Files { - lang := detectLanguage(file) - if lang != "" { - scope.Languages[lang] = append(scope.Languages[lang], file) - } - } - } - - return &scope, nil -} - -func detectLanguage(file string) string { - switch { - case strings.HasSuffix(file, ".go"): - return "go" - case strings.HasSuffix(file, ".py"): - return "python" - case strings.HasSuffix(file, ".ts"), strings.HasSuffix(file, ".tsx"): - return "typescript" - case strings.HasSuffix(file, ".js"), strings.HasSuffix(file, ".jsx"): - return "typescript" // Analyze JS with TS patterns - default: - return "" - } -} - -func filterFilesByLanguage(files []string, lang string) []string { - var filtered []string - for _, f := range files { - if detectLanguage(f) == lang { - filtered = append(filtered, f) - } - } - return filtered -} - -func writeJSON(path string, data interface{}) error { - output, err := json.MarshalIndent(data, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, output, 0644) -} - -func printSummary(results map[string]*dataflow.FlowAnalysis) { - var totalFlows, critical, high, unsanitized, nilRisks int - - for _, analysis := range results { - totalFlows += analysis.Statistics.TotalFlows - critical += analysis.Statistics.CriticalFlows - high += analysis.Statistics.HighRiskFlows - unsanitized += analysis.Statistics.UnsanitizedFlows - nilRisks += analysis.Statistics.UncheckedNilRisks - } - - fmt.Println("\n=== Data Flow Analysis Summary ===") - fmt.Printf("Total Flows: %d\n", totalFlows) - fmt.Printf("Critical Risk: %d\n", critical) - fmt.Printf("High Risk: %d\n", high) - fmt.Printf("Unsanitized: %d\n", unsanitized) - fmt.Printf("Unchecked Nil/Null: %d\n", nilRisks) - - if critical > 0 { - fmt.Println("\n[CRITICAL] Immediate remediation required!") - } else if high > 0 { - fmt.Println("\n[WARNING] High-risk issues detected") - } else if unsanitized > 0 { - fmt.Println("\n[INFO] Review unsanitized flows") - } else { - fmt.Println("\n[OK] No critical issues detected") - } -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go build -o bin/data-flow ./cmd/data-flow -./bin/data-flow -h -``` - ---- - -### Task 11: Add tests (5 min) - -**Description:** Create unit tests for the Go analyzer. - -**File:** `scripts/ring:codereview/internal/dataflow/golang_test.go` - -```go -package dataflow - -import ( - "os" - "path/filepath" - "testing" -) - -func TestGoAnalyzer_DetectSources(t *testing.T) { - // Create temp test file - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.go") - - content := `package main - -import ( - "net/http" - "os" -) - -func handler(w http.ResponseWriter, r *http.Request) { - body := r.Body - query := r.URL.Query().Get("id") - header := r.Header.Get("Authorization") - env := os.Getenv("SECRET") - _ = body - _ = query - _ = header - _ = env -} -` - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - analyzer := NewGoAnalyzer() - sources, err := analyzer.DetectSources([]string{testFile}) - if err != nil { - t.Fatal(err) - } - - // Should detect: r.Body, r.URL.Query(), r.Header.Get(), os.Getenv() - if len(sources) < 4 { - t.Errorf("Expected at least 4 sources, got %d", len(sources)) - } - - // Check source types - sourceTypes := make(map[SourceType]bool) - for _, s := range sources { - sourceTypes[s.Type] = true - } - - expectedTypes := []SourceType{SourceHTTPBody, SourceHTTPQuery, SourceHTTPHeader, SourceEnvVar} - for _, expected := range expectedTypes { - if !sourceTypes[expected] { - t.Errorf("Expected source type %s not found", expected) - } - } -} - -func TestGoAnalyzer_DetectSinks(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.go") - - content := `package main - -import ( - "database/sql" - "log" - "net/http" - "os/exec" -) - -func handler(w http.ResponseWriter, r *http.Request, db *sql.DB) { - db.Exec("INSERT INTO users VALUES (?)", "test") - w.Write([]byte("response")) - exec.Command("ls", "-la") - log.Printf("request from %s", r.RemoteAddr) -} -` - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - analyzer := NewGoAnalyzer() - sinks, err := analyzer.DetectSinks([]string{testFile}) - if err != nil { - t.Fatal(err) - } - - // Should detect: db.Exec, w.Write, exec.Command, log.Printf - if len(sinks) < 4 { - t.Errorf("Expected at least 4 sinks, got %d", len(sinks)) - } - - sinkTypes := make(map[SinkType]bool) - for _, s := range sinks { - sinkTypes[s.Type] = true - } - - expectedTypes := []SinkType{SinkDatabase, SinkResponse, SinkExec, SinkLog} - for _, expected := range expectedTypes { - if !sinkTypes[expected] { - t.Errorf("Expected sink type %s not found", expected) - } - } -} - -func TestGoAnalyzer_TrackFlows(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.go") - - content := `package main - -import ( - "database/sql" - "net/http" -) - -func handler(w http.ResponseWriter, r *http.Request, db *sql.DB) { - userInput := r.URL.Query().Get("input") - db.Exec("INSERT INTO logs VALUES (" + userInput + ")") -} -` - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - analyzer := NewGoAnalyzer() - sources, _ := analyzer.DetectSources([]string{testFile}) - sinks, _ := analyzer.DetectSinks([]string{testFile}) - flows, err := analyzer.TrackFlows(sources, sinks, []string{testFile}) - if err != nil { - t.Fatal(err) - } - - // Should detect flow from query param to database - var criticalFlow *Flow - for i, f := range flows { - if f.Risk == RiskCritical { - criticalFlow = &flows[i] - break - } - } - - if criticalFlow == nil { - t.Error("Expected to detect critical flow from HTTP query to database") - } -} - -func TestGoAnalyzer_DetectNilSources(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.go") - - content := `package main - -import "database/sql" - -func getUser(db *sql.DB, id string) (*User, error) { - row := db.QueryRow("SELECT * FROM users WHERE id = ?", id) - var user User - if err := row.Scan(&user.ID, &user.Name); err != nil { - return nil, err - } - return &user, nil -} - -func uncheckedUsage(db *sql.DB) { - result, _ := cache.Get("key") - // Using result without nil check - println(result.Value) -} - -func checkedUsage(db *sql.DB) { - result, ok := cache.Get("key") - if result != nil && ok { - println(result.Value) - } -} - -type User struct { - ID string - Name string -} - -var cache map[string]*Result -type Result struct{ Value string } -` - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - analyzer := NewGoAnalyzer() - nilSources, err := analyzer.DetectNilSources([]string{testFile}) - if err != nil { - t.Fatal(err) - } - - if len(nilSources) == 0 { - t.Error("Expected to detect nil sources") - } - - // Check that we detected both checked and unchecked patterns - var hasUnchecked, hasChecked bool - for _, ns := range nilSources { - if ns.IsChecked { - hasChecked = true - } else { - hasUnchecked = true - } - } - - if !hasUnchecked { - t.Error("Expected to detect unchecked nil source") - } - // Note: hasChecked depends on how our check detection works -} - -func TestGoAnalyzer_Analyze(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.go") - - content := `package main - -import ( - "database/sql" - "net/http" -) - -func handler(w http.ResponseWriter, r *http.Request, db *sql.DB) { - input := r.URL.Query().Get("q") - db.Exec("SELECT * FROM users WHERE name = '" + input + "'") - w.Write([]byte(input)) -} -` - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - analyzer := NewGoAnalyzer() - analysis, err := analyzer.Analyze([]string{testFile}) - if err != nil { - t.Fatal(err) - } - - if analysis.Language != "go" { - t.Errorf("Expected language 'go', got '%s'", analysis.Language) - } - - if analysis.Statistics.TotalSources == 0 { - t.Error("Expected to detect sources") - } - - if analysis.Statistics.TotalSinks == 0 { - t.Error("Expected to detect sinks") - } - - if analysis.Statistics.CriticalFlows == 0 { - t.Error("Expected to detect critical flows (SQL injection)") - } -} - -func TestCalculateRisk(t *testing.T) { - analyzer := NewGoAnalyzer() - - tests := []struct { - sourceType SourceType - sinkType SinkType - expected RiskLevel - }{ - {SourceHTTPQuery, SinkExec, RiskCritical}, - {SourceHTTPBody, SinkDatabase, RiskCritical}, - {SourceHTTPQuery, SinkResponse, RiskHigh}, - {SourceHTTPBody, SinkTemplate, RiskHigh}, - {SourceEnvVar, SinkDatabase, RiskMedium}, - {SourceHTTPQuery, SinkLog, RiskLow}, - } - - for _, tt := range tests { - source := Source{Type: tt.sourceType} - sink := Sink{Type: tt.sinkType} - risk := analyzer.calculateRisk(source, sink) - if risk != tt.expected { - t.Errorf("calculateRisk(%s, %s) = %s, want %s", - tt.sourceType, tt.sinkType, risk, tt.expected) - } - } -} - -func TestCheckSanitization(t *testing.T) { - analyzer := NewGoAnalyzer() - - tests := []struct { - context string - expected bool - }{ - {"db.Exec(\"SELECT * FROM users WHERE id = ?\", id)", true}, - {"db.Exec(\"SELECT * FROM users WHERE id = \" + id)", false}, - {"w.Write([]byte(html.EscapeString(input)))", true}, - {"w.Write([]byte(input))", false}, - } - - for _, tt := range tests { - source := Source{} - sink := Sink{Context: tt.context} - sanitized, _ := analyzer.checkSanitization(source, sink) - if sanitized != tt.expected { - t.Errorf("checkSanitization(%q) = %v, want %v", - tt.context, sanitized, tt.expected) - } - } -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go test ./internal/dataflow/... -v -``` - ---- - -### Task 12: Integration test (3 min) - -**Description:** Create end-to-end integration test that runs the full analysis pipeline. - -**File:** `scripts/ring:codereview/internal/dataflow/integration_test.go` - -```go -//go:build integration - -package dataflow - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestIntegration_FullPipeline(t *testing.T) { - // Create test project structure - tmpDir := t.TempDir() - - // Create Go file with vulnerabilities - goDir := filepath.Join(tmpDir, "go") - if err := os.MkdirAll(goDir, 0755); err != nil { - t.Fatal(err) - } - - goFile := filepath.Join(goDir, "main.go") - goContent := `package main - -import ( - "database/sql" - "html/template" - "net/http" - "os" - "os/exec" -) - -func handler(w http.ResponseWriter, r *http.Request, db *sql.DB) { - // Critical: SQL injection - userID := r.URL.Query().Get("id") - db.Exec("SELECT * FROM users WHERE id = '" + userID + "'") - - // Critical: Command injection - cmd := r.URL.Query().Get("cmd") - exec.Command("sh", "-c", cmd) - - // High: XSS - name := r.URL.Query().Get("name") - w.Write([]byte("

Hello " + name + "

")) - - // High: Template injection - tmpl := template.New("test") - tmpl.Parse(r.URL.Query().Get("template")) - - // Medium: Env to database - secret := os.Getenv("DB_PASSWORD") - db.Exec("SET PASSWORD = '" + secret + "'") - - // Low: Logging user data - log.Printf("User requested: %s", r.URL.Path) - - // Safe: Parameterized query - safeID := r.URL.Query().Get("safe_id") - db.Exec("SELECT * FROM users WHERE id = ?", safeID) -} - -func nilRisk(db *sql.DB) { - // Nil risk: unchecked - user, _ := db.QueryRow("SELECT * FROM users WHERE id = 1").Scan() - println(user) - - // Nil risk: checked - result, ok := cache.Get("key") - if result != nil && ok { - println(result) - } -} - -var cache map[string]interface{} -` - if err := os.WriteFile(goFile, []byte(goContent), 0644); err != nil { - t.Fatal(err) - } - - // Create scope.json - scopeFile := filepath.Join(tmpDir, "scope.json") - scope := map[string]interface{}{ - "files": []string{goFile}, - "languages": map[string][]string{ - "go": {goFile}, - }, - } - scopeData, _ := json.Marshal(scope) - if err := os.WriteFile(scopeFile, scopeData, 0644); err != nil { - t.Fatal(err) - } - - // Run analysis - analyzer := NewGoAnalyzer() - analysis, err := analyzer.Analyze([]string{goFile}) - if err != nil { - t.Fatal(err) - } - - // Verify results - t.Logf("Analysis results:") - t.Logf(" Sources: %d", analysis.Statistics.TotalSources) - t.Logf(" Sinks: %d", analysis.Statistics.TotalSinks) - t.Logf(" Flows: %d", analysis.Statistics.TotalFlows) - t.Logf(" Critical: %d", analysis.Statistics.CriticalFlows) - t.Logf(" High: %d", analysis.Statistics.HighRiskFlows) - t.Logf(" Nil risks: %d", analysis.Statistics.NilRisks) - - // Must detect critical flows - if analysis.Statistics.CriticalFlows < 2 { - t.Errorf("Expected at least 2 critical flows (SQL injection + command injection), got %d", - analysis.Statistics.CriticalFlows) - } - - // Must detect high risk flows - if analysis.Statistics.HighRiskFlows < 1 { - t.Errorf("Expected at least 1 high risk flow (XSS), got %d", - analysis.Statistics.HighRiskFlows) - } - - // Must have some sanitized flows (parameterized query) - sanitizedCount := 0 - for _, flow := range analysis.Flows { - if flow.Sanitized { - sanitizedCount++ - } - } - if sanitizedCount == 0 { - t.Error("Expected at least 1 sanitized flow (parameterized query)") - } - - // Generate security summary - results := map[string]*FlowAnalysis{"go": analysis} - summary := GenerateSecuritySummary(results) - - if len(summary) == 0 { - t.Error("Expected non-empty security summary") - } - - // Verify summary contains expected sections - expectedSections := []string{ - "# Security Data Flow Analysis", - "## Executive Summary", - "## Risk Assessment", - "CRITICAL", - "## Critical & High Risk Flows", - } - - for _, section := range expectedSections { - if !containsString(summary, section) { - t.Errorf("Summary missing expected section: %s", section) - } - } - - // Write outputs for manual inspection - outputDir := filepath.Join(tmpDir, "output") - os.MkdirAll(outputDir, 0755) - - flowJSON, _ := json.MarshalIndent(analysis, "", " ") - os.WriteFile(filepath.Join(outputDir, "go-flow.json"), flowJSON, 0644) - os.WriteFile(filepath.Join(outputDir, "security-summary.md"), []byte(summary), 0644) - - t.Logf("Output written to: %s", outputDir) -} - -func containsString(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsString(s[1:], substr) || s[:len(substr)] == substr) -} - -func TestIntegration_MultiLanguage(t *testing.T) { - // Skip if Python not available - if _, err := os.Stat("/usr/bin/python3"); os.IsNotExist(err) { - t.Skip("Python3 not available") - } - - tmpDir := t.TempDir() - - // Create Go file - goFile := filepath.Join(tmpDir, "main.go") - goContent := `package main - -import "net/http" - -func handler(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(r.URL.Query().Get("q"))) -} -` - os.WriteFile(goFile, []byte(goContent), 0644) - - // Create Python file - pyFile := filepath.Join(tmpDir, "app.py") - pyContent := `from flask import Flask, request - -app = Flask(__name__) - -@app.route("/") -def index(): - return request.args.get("q") -` - os.WriteFile(pyFile, []byte(pyContent), 0644) - - // Analyze Go - goAnalyzer := NewGoAnalyzer() - goAnalysis, err := goAnalyzer.Analyze([]string{goFile}) - if err != nil { - t.Fatal(err) - } - - // Analyze Python (if script exists) - scriptDir := os.Getenv("SCRIPT_DIR") - if scriptDir == "" { - t.Log("SCRIPT_DIR not set, skipping Python analysis") - } else { - pyAnalyzer := NewPythonAnalyzer(scriptDir) - pyAnalysis, err := pyAnalyzer.Analyze([]string{pyFile}) - if err != nil { - t.Logf("Python analysis error (expected if script not found): %v", err) - } else { - // Generate combined summary - results := map[string]*FlowAnalysis{ - "go": goAnalysis, - "python": pyAnalysis, - } - summary := GenerateSecuritySummary(results) - t.Logf("Multi-language summary length: %d bytes", len(summary)) - } - } - - if goAnalysis.Statistics.TotalFlows == 0 { - t.Error("Expected Go analysis to detect flows") - } -} -``` - -**Verification:** -```bash -cd scripts/ring:codereview && go test ./internal/dataflow/... -v -tags=integration -``` - ---- - -## Estimated Total: ~50 minutes - -| Task | Time | Cumulative | -|------|------|------------| -| Task 1: Directory structure | 2 min | 2 min | -| Task 2: Types definition | 3 min | 5 min | -| Task 3: Go source detection | 5 min | 10 min | -| Task 4: Go sink detection | 5 min | 15 min | -| Task 5: Flow tracking | 5 min | 20 min | -| Task 6: Nil source tracking | 4 min | 24 min | -| Task 7: Python data_flow.py | 5 min | 29 min | -| Task 8: Go wrapper for Python | 3 min | 32 min | -| Task 9: Security summary renderer | 5 min | 37 min | -| Task 10: CLI binary | 4 min | 41 min | -| Task 11: Unit tests | 5 min | 46 min | -| Task 12: Integration test | 3 min | 49 min | - ---- - -## Execution Order - -1. **Tasks 1-2**: Setup structure and types (foundation) -2. **Tasks 3-6**: Implement Go analyzer (core functionality) -3. **Tasks 7-8**: Add Python/TypeScript support (language coverage) -4. **Tasks 9-10**: Create output and CLI (user interface) -5. **Tasks 11-12**: Add tests (quality assurance) - -## Usage - -```bash -# Build -cd scripts/ring:codereview -go build -o bin/data-flow ./cmd/data-flow - -# Run with scope file -./bin/data-flow -scope scope.json -output results/ -v - -# Run for specific language -./bin/data-flow -scope scope.json -lang go -output results/ - -# JSON output only -./bin/data-flow -scope scope.json -json -``` - -## Output Files - -- `go-flow.json` - Go analysis results -- `python-flow.json` - Python analysis results -- `typescript-flow.json` - TypeScript analysis results -- `security-summary.md` - Human-readable security report diff --git a/docs/plans/2026-01-13-codereview-phase5-context-compilation.md b/docs/plans/2026-01-13-codereview-phase5-context-compilation.md deleted file mode 100644 index a7b90110..00000000 --- a/docs/plans/2026-01-13-codereview-phase5-context-compilation.md +++ /dev/null @@ -1,3242 +0,0 @@ -# Phase 5: Context Compilation Implementation Plan - -> **For Agents:** REQUIRED SUB-SKILL: Use ring:executing-plans to implement this plan task-by-task. - -**Goal:** Implement the context compilation phase that aggregates all analysis outputs (Phases 0-4) into reviewer-specific markdown files, and create the main orchestrator that runs all phases in sequence. - -**Architecture:** Two Go binaries - `compile-context` reads JSON outputs from all phases and generates reviewer-specific markdown context files; `run-all` orchestrates the entire pipeline from scope detection through context compilation. - -**Tech Stack:** -- Go 1.22+ (binary implementation) -- Template-based markdown generation -- JSON parsing for phase outputs -- No external dependencies (stdlib only) - -**Global Prerequisites:** -- Environment: macOS or Linux, Go 1.22+ -- Tools: git (for repo operations) -- Access: None required (all tools are local) -- State: Clean working tree on feature branch -- Previous phases: Phase 0-4 binaries must be implemented - -**Verification before starting:** -```bash -# Run ALL these commands and verify output: -go version # Expected: go version go1.22+ or higher -git status # Expected: clean working tree -ls scripts/ring:codereview/go.mod # Expected: file exists (from Phase 0/1) -ls scripts/ring:codereview/internal/ring:lint/ # Expected: linter wrappers exist (from Phase 1) -``` - -## Historical Precedent - -**Query:** "ring:codereview context compilation reviewer analysis" -**Index Status:** Populated (no direct matches) - -### Successful Patterns to Reference -- Phase 0/1 plans established Go binary structure in `scripts/ring:codereview/` -- Internal packages pattern: `internal/{domain}/` for shared code -- JSON output/input pattern for inter-phase communication - -### Failure Patterns to AVOID -- None found - this is a new feature area - -### Related Past Plans -- `ring:codereview-enhancement-macro-plan.md`: Parent macro plan defining context file templates -- `2026-01-13-ring:codereview-phase0-scope-detector.md`: Established Go module structure -- `2026-01-13-ring:codereview-phase1-static-analysis.md`: Established lint output formats - ---- - -## File Structure Overview - -``` -scripts/ring:codereview/ -├── cmd/ -│ ├── compile-context/ # Phase 5 binary -│ │ └── main.go -│ └── run-all/ # Main orchestrator binary -│ └── main.go -├── internal/ -│ └── context/ # Context compilation package -│ ├── compiler.go # Main aggregation logic -│ ├── compiler_test.go # Tests -│ ├── templates.go # Markdown templates -│ ├── templates_test.go # Template tests -│ ├── reviewer_mappings.go # Data-to-reviewer mappings -│ └── reviewer_mappings_test.go -└── Makefile # Updated with new targets -``` - ---- - -## Task Overview - -| # | Task | Description | Time | -|---|------|-------------|------| -| 1 | Create context package structure | Set up `internal/context/` directory | 2 min | -| 2 | Define input types | Create structs for reading phase outputs | 5 min | -| 3 | Implement reviewer mappings | Map data sources to reviewers | 5 min | -| 4 | Write templates for ring:code-reviewer | Markdown template for code quality context | 5 min | -| 5 | Write templates for ring:security-reviewer | Markdown template for security context | 5 min | -| 6 | Write templates for ring:business-logic-reviewer | Markdown template for business logic context | 4 min | -| 7 | Write templates for ring:test-reviewer | Markdown template for testing context | 4 min | -| 8 | Write templates for ring:nil-safety-reviewer | Markdown template for nil safety context | 4 min | -| 9 | Implement compiler core | Main aggregation logic | 5 min | -| 10 | Implement file reader | Read and parse phase outputs | 5 min | -| 11 | Implement context writer | Write reviewer context files | 4 min | -| 12 | Implement compile-context CLI | Binary entry point | 5 min | -| 13 | Implement run-all orchestrator | Main pipeline orchestrator | 5 min | -| 14 | Add unit tests: reviewer mappings | Test data mapping logic | 4 min | -| 15 | Add unit tests: templates | Test template rendering | 4 min | -| 16 | Add unit tests: compiler | Test aggregation logic | 5 min | -| 17 | Integration test | End-to-end with sample data | 5 min | -| 18 | Update Makefile | Add build targets | 3 min | -| 19 | Code Review | Run code review checkpoint | 5 min | -| 20 | Build and verify | Build binaries and verify | 3 min | - ---- - -## Task 1: Create Context Package Structure - -**Files:** -- Create: `scripts/ring:codereview/internal/context/` - -**Prerequisites:** -- Go module exists at `scripts/ring:codereview/go.mod` - -**Step 1: Create directory** - -```bash -mkdir -p scripts/ring:codereview/internal/context -``` - -**Step 2: Verify structure** - -Run: `ls -la scripts/ring:codereview/internal/` - -**Expected output:** -``` -total XX -drwxr-xr-x ... . -drwxr-xr-x ... .. -drwxr-xr-x ... context -drwxr-xr-x ... git -drwxr-xr-x ... lint -drwxr-xr-x ... output -drwxr-xr-x ... scope -``` - -**If Task Fails:** - -1. **Directory creation fails:** - - Check: `ls scripts/ring:codereview/internal/` (parent exists?) - - Fix: Verify Phase 0 was completed - - Rollback: N/A (no files created) - ---- - -## Task 2: Define Input Types - -**Files:** -- Create: `scripts/ring:codereview/internal/context/types.go` - -**Prerequisites:** -- Task 1 completed - -**Step 1: Write the types file** - -Create file `scripts/ring:codereview/internal/context/types.go`: - -```go -// Package context provides context compilation for code review. -// It aggregates outputs from all analysis phases and generates -// reviewer-specific context files. -package context - -// PhaseOutputs holds all the outputs from analysis phases. -type PhaseOutputs struct { - Scope *ScopeData `json:"scope"` - StaticAnalysis *StaticAnalysisData `json:"static_analysis"` - AST *ASTData `json:"ast"` - CallGraph *CallGraphData `json:"call_graph"` - DataFlow *DataFlowData `json:"data_flow"` - Errors []string `json:"errors,omitempty"` -} - -// ScopeData represents Phase 0 output (scope.json). -type ScopeData struct { - BaseRef string `json:"base_ref"` - HeadRef string `json:"head_ref"` - Language string `json:"language"` - ModifiedFiles []string `json:"modified"` - AddedFiles []string `json:"added"` - DeletedFiles []string `json:"deleted"` - TotalFiles int `json:"total_files"` - TotalAdditions int `json:"total_additions"` - TotalDeletions int `json:"total_deletions"` - PackagesAffected []string `json:"packages_affected"` -} - -// StaticAnalysisData represents Phase 1 output (static-analysis.json). -type StaticAnalysisData struct { - ToolVersions map[string]string `json:"tool_versions"` - Findings []Finding `json:"findings"` - Summary FindingSummary `json:"summary"` - Errors []string `json:"errors,omitempty"` -} - -// Finding represents a single lint/analysis finding. -type Finding struct { - Tool string `json:"tool"` - Rule string `json:"rule"` - Severity string `json:"severity"` - File string `json:"file"` - Line int `json:"line"` - Column int `json:"column"` - Message string `json:"message"` - Suggestion string `json:"suggestion,omitempty"` - Category string `json:"category"` -} - -// FindingSummary holds counts by severity. -type FindingSummary struct { - Critical int `json:"critical"` - High int `json:"high"` - Warning int `json:"warning"` - Info int `json:"info"` -} - -// ASTData represents Phase 2 output ({lang}-ast.json). -type ASTData struct { - Functions FunctionChanges `json:"functions"` - Types TypeChanges `json:"types"` - Interfaces InterfaceChanges `json:"interfaces,omitempty"` - Classes ClassChanges `json:"classes,omitempty"` - Imports ImportChanges `json:"imports"` - ErrorHandling ErrorHandlingData `json:"error_handling,omitempty"` -} - -// FunctionChanges holds function-level changes. -type FunctionChanges struct { - Modified []FunctionDiff `json:"modified"` - Added []FunctionInfo `json:"added"` - Deleted []FunctionInfo `json:"deleted"` -} - -// FunctionDiff represents a modified function. -type FunctionDiff struct { - Name string `json:"name"` - File string `json:"file"` - Package string `json:"package,omitempty"` - Module string `json:"module,omitempty"` - Receiver string `json:"receiver,omitempty"` - Before FunctionInfo `json:"before"` - After FunctionInfo `json:"after"` - Changes []string `json:"changes"` -} - -// FunctionInfo holds function details. -type FunctionInfo struct { - Name string `json:"name,omitempty"` - Signature string `json:"signature"` - File string `json:"file,omitempty"` - Package string `json:"package,omitempty"` - LineStart int `json:"line_start"` - LineEnd int `json:"line_end"` - Params []ParamInfo `json:"params,omitempty"` - Returns []TypeInfo `json:"returns,omitempty"` -} - -// ParamInfo holds parameter details. -type ParamInfo struct { - Name string `json:"name"` - Type string `json:"type"` - Default string `json:"default,omitempty"` -} - -// TypeInfo holds type information. -type TypeInfo struct { - Type string `json:"type"` -} - -// TypeChanges holds struct/type changes. -type TypeChanges struct { - Modified []TypeDiff `json:"modified"` - Added []TypeInfo `json:"added"` - Deleted []TypeInfo `json:"deleted"` -} - -// TypeDiff represents a modified type. -type TypeDiff struct { - Name string `json:"name"` - Kind string `json:"kind"` - File string `json:"file"` - Before FieldsData `json:"before"` - After FieldsData `json:"after"` - Changes []string `json:"changes"` -} - -// FieldsData holds struct field information. -type FieldsData struct { - Fields []FieldInfo `json:"fields"` -} - -// FieldInfo represents a struct field. -type FieldInfo struct { - Name string `json:"name"` - Type string `json:"type"` - Tags string `json:"tags,omitempty"` -} - -// InterfaceChanges holds interface changes (Go/TS). -type InterfaceChanges struct { - Modified []InterfaceDiff `json:"modified"` - Added []InterfaceInfo `json:"added"` - Deleted []InterfaceInfo `json:"deleted"` -} - -// InterfaceDiff represents a modified interface. -type InterfaceDiff struct { - Name string `json:"name"` - File string `json:"file"` - Before InterfaceInfo `json:"before"` - After InterfaceInfo `json:"after"` - Changes []string `json:"changes"` -} - -// InterfaceInfo holds interface details. -type InterfaceInfo struct { - Name string `json:"name,omitempty"` - Methods []string `json:"methods"` -} - -// ClassChanges holds class changes (TS/Python). -type ClassChanges struct { - Modified []ClassDiff `json:"modified"` - Added []ClassInfo `json:"added"` - Deleted []ClassInfo `json:"deleted"` -} - -// ClassDiff represents a modified class. -type ClassDiff struct { - Name string `json:"name"` - File string `json:"file"` - Before ClassInfo `json:"before"` - After ClassInfo `json:"after"` - Changes []string `json:"changes"` -} - -// ClassInfo holds class details. -type ClassInfo struct { - Name string `json:"name,omitempty"` - Methods []string `json:"methods"` - Attributes []string `json:"attributes,omitempty"` -} - -// ImportChanges holds import changes. -type ImportChanges struct { - Added []ImportInfo `json:"added"` - Removed []ImportInfo `json:"removed"` -} - -// ImportInfo represents an import statement. -type ImportInfo struct { - File string `json:"file"` - Path string `json:"path,omitempty"` - Module string `json:"module,omitempty"` - Names []string `json:"names,omitempty"` -} - -// ErrorHandlingData holds error handling changes (Go). -type ErrorHandlingData struct { - NewErrorReturns []ErrorReturn `json:"new_error_returns"` - RemovedErrorChecks []ErrorCheck `json:"removed_error_checks"` -} - -// ErrorReturn represents a new error return path. -type ErrorReturn struct { - Function string `json:"function"` - File string `json:"file"` - Line int `json:"line"` - ErrorType string `json:"error_type"` - Message string `json:"message"` -} - -// ErrorCheck represents a removed error check. -type ErrorCheck struct { - Function string `json:"function"` - File string `json:"file"` - Line int `json:"line"` -} - -// CallGraphData represents Phase 3 output ({lang}-calls.json). -type CallGraphData struct { - ModifiedFunctions []FunctionCallGraph `json:"modified_functions"` - ImpactAnalysis ImpactSummary `json:"impact_analysis"` -} - -// FunctionCallGraph holds call graph data for a function. -type FunctionCallGraph struct { - Function string `json:"function"` - File string `json:"file"` - Callers []CallSite `json:"callers"` - Callees []CallSite `json:"callees"` - TestCoverage []TestCoverage `json:"test_coverage"` -} - -// CallSite represents a call site. -type CallSite struct { - Function string `json:"function"` - File string `json:"file"` - Line int `json:"line"` - CallSite string `json:"call_site,omitempty"` -} - -// TestCoverage represents test coverage for a function. -type TestCoverage struct { - TestFunction string `json:"test_function"` - File string `json:"file"` - Line int `json:"line"` -} - -// ImpactSummary holds impact analysis summary. -type ImpactSummary struct { - DirectCallers int `json:"direct_callers"` - TransitiveCallers int `json:"transitive_callers"` - AffectedTests int `json:"affected_tests"` - AffectedPackages []string `json:"affected_packages,omitempty"` - AffectedModules []string `json:"affected_modules,omitempty"` -} - -// DataFlowData represents Phase 4 output ({lang}-flow.json). -type DataFlowData struct { - Flows []DataFlow `json:"flows"` - NilSources []NilSource `json:"nil_sources,omitempty"` - Summary FlowSummary `json:"summary"` -} - -// DataFlow represents a data flow path. -type DataFlow struct { - ID string `json:"id"` - Source FlowSource `json:"source"` - Path []FlowStep `json:"path"` - Sink FlowSink `json:"sink"` - Sanitized bool `json:"sanitized"` - Risk string `json:"risk"` - Notes string `json:"notes,omitempty"` -} - -// FlowSource represents where data enters. -type FlowSource struct { - Type string `json:"type"` - Subtype string `json:"subtype,omitempty"` - Framework string `json:"framework,omitempty"` - Variable string `json:"variable"` - File string `json:"file"` - Line int `json:"line"` - Expression string `json:"expression"` -} - -// FlowStep represents a step in data flow. -type FlowStep struct { - Step int `json:"step"` - File string `json:"file"` - Line int `json:"line"` - Expression string `json:"expression"` - Operation string `json:"operation"` - SanitizerType string `json:"sanitizer_type,omitempty"` -} - -// FlowSink represents where data exits. -type FlowSink struct { - Type string `json:"type"` - Subtype string `json:"subtype,omitempty"` - File string `json:"file"` - Line int `json:"line"` - Expression string `json:"expression"` -} - -// NilSource represents a potential nil source. -type NilSource struct { - Variable string `json:"variable"` - File string `json:"file"` - Line int `json:"line"` - Expression string `json:"expression"` - Checked bool `json:"checked"` - CheckLine int `json:"check_line,omitempty"` - CheckExpression string `json:"check_expression,omitempty"` - Risk string `json:"risk,omitempty"` - Notes string `json:"notes,omitempty"` -} - -// FlowSummary holds data flow summary. -type FlowSummary struct { - TotalFlows int `json:"total_flows"` - SanitizedFlows int `json:"sanitized_flows"` - UnsanitizedFlows int `json:"unsanitized_flows"` - HighRisk int `json:"high_risk"` - MediumRisk int `json:"medium_risk"` - LowRisk int `json:"low_risk"` - NilRisks int `json:"nil_risks,omitempty"` - NoneRisks int `json:"none_risks,omitempty"` -} - -// ReviewerContext holds the compiled context for a specific reviewer. -type ReviewerContext struct { - ReviewerName string - Title string - Content string -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/context/... && cd ../..` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/context/types.go -git commit -m "feat(ring:codereview): add input types for context compilation - -Phase 5 types for reading outputs from all analysis phases (0-4). -Includes structures for scope, static analysis, AST, call graph, -and data flow data." -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: Error message for syntax issues - - Fix: Correct JSON tags or type definitions - - Rollback: `rm scripts/ring:codereview/internal/context/types.go` - ---- - -## Task 3: Implement Reviewer Mappings - -**Files:** -- Create: `scripts/ring:codereview/internal/context/reviewer_mappings.go` - -**Prerequisites:** -- Task 2 completed - -**Step 1: Write the failing test** - -Create file `scripts/ring:codereview/internal/context/reviewer_mappings_test.go`: - -```go -package context - -import ( - "testing" -) - -func TestReviewerNames(t *testing.T) { - expected := []string{ - "ring:code-reviewer", - "ring:security-reviewer", - "ring:business-logic-reviewer", - "ring:test-reviewer", - "ring:nil-safety-reviewer", - } - - names := GetReviewerNames() - if len(names) != len(expected) { - t.Errorf("GetReviewerNames() returned %d names, want %d", len(names), len(expected)) - } - - for i, name := range expected { - if names[i] != name { - t.Errorf("GetReviewerNames()[%d] = %q, want %q", i, names[i], name) - } - } -} - -func TestGetReviewerDataSources(t *testing.T) { - tests := []struct { - reviewer string - wantLen int - }{ - {"ring:code-reviewer", 2}, // static-analysis, semantic-diff - {"ring:security-reviewer", 2}, // static-analysis (security), security-summary - {"ring:business-logic-reviewer", 2}, // semantic-diff, impact-summary - {"ring:test-reviewer", 1}, // impact-summary (test coverage) - {"ring:nil-safety-reviewer", 1}, // data-flow (nil_sources) - } - - for _, tt := range tests { - t.Run(tt.reviewer, func(t *testing.T) { - sources := GetReviewerDataSources(tt.reviewer) - if len(sources) != tt.wantLen { - t.Errorf("GetReviewerDataSources(%q) returned %d sources, want %d", - tt.reviewer, len(sources), tt.wantLen) - } - }) - } -} - -func TestFilterFindingsByCategory(t *testing.T) { - findings := []Finding{ - {Category: "security", Severity: "high", Message: "SQL injection"}, - {Category: "style", Severity: "info", Message: "line too long"}, - {Category: "bug", Severity: "warning", Message: "unused var"}, - {Category: "security", Severity: "warning", Message: "weak hash"}, - } - - securityFindings := FilterFindingsByCategory(findings, "security") - if len(securityFindings) != 2 { - t.Errorf("FilterFindingsByCategory(security) returned %d, want 2", len(securityFindings)) - } - - styleFindings := FilterFindingsByCategory(findings, "style") - if len(styleFindings) != 1 { - t.Errorf("FilterFindingsByCategory(style) returned %d, want 1", len(styleFindings)) - } -} - -func TestFilterFindingsBySeverity(t *testing.T) { - findings := []Finding{ - {Severity: "critical", Message: "crash"}, - {Severity: "high", Message: "security"}, - {Severity: "warning", Message: "style"}, - {Severity: "info", Message: "hint"}, - {Severity: "high", Message: "another"}, - } - - // Filter high and above - highAndAbove := FilterFindingsBySeverity(findings, "high") - if len(highAndAbove) != 3 { // critical + 2 high - t.Errorf("FilterFindingsBySeverity(high) returned %d, want 3", len(highAndAbove)) - } -} - -func TestFilterNilSourcesByRisk(t *testing.T) { - sources := []NilSource{ - {Variable: "a", Risk: "high", Checked: false}, - {Variable: "b", Risk: "low", Checked: true}, - {Variable: "c", Risk: "high", Checked: false}, - {Variable: "d", Risk: "medium", Checked: false}, - } - - highRisk := FilterNilSourcesByRisk(sources, "high") - if len(highRisk) != 2 { - t.Errorf("FilterNilSourcesByRisk(high) returned %d, want 2", len(highRisk)) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd scripts/ring:codereview && go test -v ./internal/context/... 2>&1 | head -20 && cd ../..` - -**Expected output:** -``` -# github.com/lerianstudio/ring/scripts/ring:codereview/internal/context -./reviewer_mappings_test.go:XX:XX: undefined: GetReviewerNames -FAIL github.com/lerianstudio/ring/scripts/ring:codereview/internal/context [build failed] -``` - -**Step 3: Write minimal implementation** - -Create file `scripts/ring:codereview/internal/context/reviewer_mappings.go`: - -```go -package context - -// DataSource represents a data source for a reviewer. -type DataSource struct { - Name string // e.g., "static-analysis", "semantic-diff" - Description string // What this data provides -} - -// reviewerDataSources maps each reviewer to their primary data sources. -var reviewerDataSources = map[string][]DataSource{ - "ring:code-reviewer": { - {Name: "static-analysis", Description: "Lint findings, style issues, deprecations"}, - {Name: "semantic-diff", Description: "Function/type changes, signature modifications"}, - }, - "ring:security-reviewer": { - {Name: "static-analysis-security", Description: "Security scanner findings (gosec, bandit)"}, - {Name: "security-summary", Description: "Data flow analysis, taint tracking"}, - }, - "ring:business-logic-reviewer": { - {Name: "semantic-diff", Description: "Business logic changes in functions/methods"}, - {Name: "impact-summary", Description: "Call graph impact, affected callers"}, - }, - "ring:test-reviewer": { - {Name: "impact-summary", Description: "Test coverage for modified code"}, - }, - "ring:nil-safety-reviewer": { - {Name: "data-flow", Description: "Nil/None source analysis"}, - }, -} - -// severityOrder defines severity ranking for filtering. -var severityOrder = map[string]int{ - "critical": 4, - "high": 3, - "warning": 2, - "info": 1, -} - -// securityCategories lists categories relevant to security reviewer. -var securityCategories = map[string]bool{ - "security": true, -} - -// codeQualityCategories lists categories relevant to code reviewer. -var codeQualityCategories = map[string]bool{ - "bug": true, - "style": true, - "performance": true, - "deprecation": true, - "complexity": true, - "type": true, - "unused": true, - "other": true, -} - -// GetReviewerNames returns the list of all reviewer names in order. -func GetReviewerNames() []string { - return []string{ - "ring:code-reviewer", - "ring:security-reviewer", - "ring:business-logic-reviewer", - "ring:test-reviewer", - "ring:nil-safety-reviewer", - } -} - -// GetReviewerDataSources returns the data sources for a specific reviewer. -func GetReviewerDataSources(reviewer string) []DataSource { - return reviewerDataSources[reviewer] -} - -// FilterFindingsByCategory filters findings to only those in the specified category. -func FilterFindingsByCategory(findings []Finding, category string) []Finding { - var filtered []Finding - for _, f := range findings { - if f.Category == category { - filtered = append(filtered, f) - } - } - return filtered -} - -// FilterFindingsBySeverity filters findings at or above the specified severity. -func FilterFindingsBySeverity(findings []Finding, minSeverity string) []Finding { - minLevel := severityOrder[minSeverity] - var filtered []Finding - for _, f := range findings { - if severityOrder[f.Severity] >= minLevel { - filtered = append(filtered, f) - } - } - return filtered -} - -// FilterFindingsForCodeReviewer filters findings relevant to code quality. -func FilterFindingsForCodeReviewer(findings []Finding) []Finding { - var filtered []Finding - for _, f := range findings { - if codeQualityCategories[f.Category] { - filtered = append(filtered, f) - } - } - return filtered -} - -// FilterFindingsForSecurityReviewer filters findings relevant to security. -func FilterFindingsForSecurityReviewer(findings []Finding) []Finding { - var filtered []Finding - for _, f := range findings { - if securityCategories[f.Category] { - filtered = append(filtered, f) - } - } - return filtered -} - -// FilterNilSourcesByRisk filters nil sources by risk level. -func FilterNilSourcesByRisk(sources []NilSource, risk string) []NilSource { - var filtered []NilSource - for _, s := range sources { - if s.Risk == risk { - filtered = append(filtered, s) - } - } - return filtered -} - -// FilterNilSourcesUnchecked filters nil sources that are not checked. -func FilterNilSourcesUnchecked(sources []NilSource) []NilSource { - var filtered []NilSource - for _, s := range sources { - if !s.Checked { - filtered = append(filtered, s) - } - } - return filtered -} - -// GetUncoveredFunctions returns functions with no test coverage. -func GetUncoveredFunctions(callGraph *CallGraphData) []FunctionCallGraph { - var uncovered []FunctionCallGraph - for _, f := range callGraph.ModifiedFunctions { - if len(f.TestCoverage) == 0 { - uncovered = append(uncovered, f) - } - } - return uncovered -} - -// GetHighImpactFunctions returns functions with many callers. -func GetHighImpactFunctions(callGraph *CallGraphData, threshold int) []FunctionCallGraph { - var highImpact []FunctionCallGraph - for _, f := range callGraph.ModifiedFunctions { - if len(f.Callers) >= threshold { - highImpact = append(highImpact, f) - } - } - return highImpact -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd scripts/ring:codereview && go test -v ./internal/context/... && cd ../..` - -**Expected output:** -``` -=== RUN TestReviewerNames ---- PASS: TestReviewerNames (0.00s) -=== RUN TestGetReviewerDataSources ---- PASS: TestGetReviewerDataSources (0.00s) -=== RUN TestFilterFindingsByCategory ---- PASS: TestFilterFindingsByCategory (0.00s) -=== RUN TestFilterFindingsBySeverity ---- PASS: TestFilterFindingsBySeverity (0.00s) -=== RUN TestFilterNilSourcesByRisk ---- PASS: TestFilterNilSourcesByRisk (0.00s) -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/context -``` - -**Step 5: Commit** - -```bash -git add scripts/ring:codereview/internal/context/reviewer_mappings.go -git add scripts/ring:codereview/internal/context/reviewer_mappings_test.go -git commit -m "feat(ring:codereview): add reviewer data mappings and filters - -Maps each reviewer to their primary data sources and provides -filter functions for findings by category, severity, and risk." -``` - -**If Task Fails:** - -1. **Tests fail:** - - Check: Expected data source counts may need adjustment - - Fix: Update expected values in tests - - Rollback: `git checkout -- scripts/ring:codereview/internal/context/` - ---- - -## Task 4: Write Templates for ring:code-reviewer - -**Files:** -- Create: `scripts/ring:codereview/internal/context/templates.go` - -**Prerequisites:** -- Task 3 completed - -**Step 1: Write the templates file** - -Create file `scripts/ring:codereview/internal/context/templates.go`: - -```go -package context - -import ( - "bytes" - "fmt" - "strings" - "text/template" -) - -// Template definitions for each reviewer's context file. - -const codeReviewerTemplate = `# Pre-Analysis Context: Code Quality - -## Static Analysis Findings ({{.FindingCount}} issues) - -{{if .Findings}} -| Severity | Tool | File | Line | Message | -|----------|------|------|------|---------| -{{- range .Findings}} -| {{.Severity}} | {{.Tool}} | {{.File}} | {{.Line}} | {{.Message}} | -{{- end}} -{{else}} -No static analysis findings. -{{end}} - -## Semantic Changes - -{{if .HasSemanticChanges}} -### Functions Modified ({{len .ModifiedFunctions}}) -{{range .ModifiedFunctions}} -#### ` + "`{{.Package}}.{{.Name}}`" + ` -**File:** ` + "`{{.File}}:{{.After.LineStart}}-{{.After.LineEnd}}`" + ` -{{if .Changes}}**Changes:** {{join .Changes ", "}}{{end}} - -{{if signatureChanged .}} -` + "```diff" + ` -- {{.Before.Signature}} -+ {{.After.Signature}} -` + "```" + ` -{{end}} -{{end}} - -### Functions Added ({{len .AddedFunctions}}) -{{range .AddedFunctions}} -#### ` + "`{{.Package}}.{{.Name}}`" + ` -**File:** ` + "`{{.File}}:{{.LineStart}}-{{.LineEnd}}`" + ` -**Purpose:** New function (requires review) -{{end}} - -### Types Modified ({{len .ModifiedTypes}}) -{{range .ModifiedTypes}} -#### ` + "`{{.Name}}`" + ` -**File:** ` + "`{{.File}}`" + ` - -| Field | Before | After | -|-------|--------|-------| -{{- range fieldChanges .}} -| {{.Name}} | {{.Before}} | {{.After}} | -{{- end}} -{{end}} -{{else}} -No semantic changes detected. -{{end}} - -## Focus Areas - -Based on analysis, pay special attention to: -{{range $i, $area := .FocusAreas}} -{{inc $i}}. **{{$area.Title}}** - {{$area.Description}} -{{- end}} -{{if not .FocusAreas}} -No specific focus areas identified. -{{end}} -` - -const securityReviewerTemplate = `# Pre-Analysis Context: Security - -## Security Scanner Findings ({{.FindingCount}} issues) - -{{if .Findings}} -| Severity | Tool | Rule | File | Line | Message | -|----------|------|------|------|------|---------| -{{- range .Findings}} -| {{.Severity}} | {{.Tool}} | {{.Rule}} | {{.File}} | {{.Line}} | {{.Message}} | -{{- end}} -{{else}} -No security scanner findings. -{{end}} - -## Data Flow Analysis - -{{if .HasDataFlowAnalysis}} -### High Risk Flows ({{len .HighRiskFlows}}) -{{range .HighRiskFlows}} -#### {{.ID}}: {{.Source.Type}} -> {{.Sink.Type}} -**File:** ` + "`{{.Source.File}}:{{.Source.Line}}`" + ` -**Risk:** {{.Risk}} -**Notes:** {{.Notes}} - -**Source:** ` + "`{{.Source.Expression}}`" + ` -**Sink:** ` + "`{{.Sink.Expression}}`" + ` -**Sanitized:** {{if .Sanitized}}Yes{{else}}No{{end}} -{{end}} - -### Medium Risk Flows ({{len .MediumRiskFlows}}) -{{range .MediumRiskFlows}} -- {{.Source.Type}} -> {{.Sink.Type}} at ` + "`{{.Source.File}}:{{.Source.Line}}`" + ` ({{.Notes}}) -{{- end}} -{{else}} -No data flow analysis available. -{{end}} - -## Focus Areas - -Based on analysis, pay special attention to: -{{range $i, $area := .FocusAreas}} -{{inc $i}}. **{{$area.Title}}** - {{$area.Description}} -{{- end}} -{{if not .FocusAreas}} -No specific focus areas identified. -{{end}} -` - -const businessLogicReviewerTemplate = `# Pre-Analysis Context: Business Logic - -## Impact Analysis - -{{if .HasCallGraph}} -### High Impact Changes - -{{range .HighImpactFunctions}} -#### ` + "`{{.Function}}`" + ` -**File:** ` + "`{{.File}}`" + ` -**Risk Level:** {{riskLevel .}} ({{len .Callers}} direct callers) - -**Direct Callers (signature change affects these):** -{{range $i, $caller := .Callers}} -{{inc $i}}. ` + "`{{$caller.Function}}`" + ` - ` + "`{{$caller.File}}:{{$caller.Line}}`" + ` -{{- end}} - -**Callees (this function depends on):** -{{range $i, $callee := .Callees}} -{{inc $i}}. ` + "`{{$callee.Function}}`" + ` -{{- end}} -{{end}} -{{else}} -No call graph analysis available. -{{end}} - -## Semantic Changes - -{{if .HasSemanticChanges}} -### Functions with Logic Changes -{{range $i, $f := .ModifiedFunctions}} -{{inc $i}}. **` + "`{{$f.Package}}.{{$f.Name}}`" + `** - {{join $f.Changes ", "}} -{{- end}} -{{else}} -No semantic changes detected. -{{end}} - -## Focus Areas - -Based on analysis, pay special attention to: -{{range $i, $area := .FocusAreas}} -{{inc $i}}. **{{$area.Title}}** - {{$area.Description}} -{{- end}} -{{if not .FocusAreas}} -No specific focus areas identified. -{{end}} -` - -const testReviewerTemplate = `# Pre-Analysis Context: Testing - -## Test Coverage for Modified Code - -{{if .HasCallGraph}} -| Function | File | Tests | Status | -|----------|------|-------|--------| -{{- range .ModifiedFunctions}} -| ` + "`{{.Function}}`" + ` | {{.File}} | {{len .TestCoverage}} tests | {{testStatus .}} | -{{- end}} -{{else}} -No call graph analysis available for test coverage. -{{end}} - -## Uncovered New Code - -{{if .UncoveredFunctions}} -{{range .UncoveredFunctions}} -- ` + "`{{.Function}}`" + ` at ` + "`{{.File}}`" + ` - **No tests found** -{{- end}} -{{else}} -All modified code has test coverage. -{{end}} - -## Focus Areas - -Based on analysis, pay special attention to: -{{range $i, $area := .FocusAreas}} -{{inc $i}}. **{{$area.Title}}** - {{$area.Description}} -{{- end}} -{{if not .FocusAreas}} -No specific focus areas identified. -{{end}} -` - -const nilSafetyReviewerTemplate = `# Pre-Analysis Context: Nil Safety - -## Nil Source Analysis - -{{if .HasNilSources}} -| Variable | File | Line | Checked? | Risk | -|----------|------|------|----------|------| -{{- range .NilSources}} -| ` + "`{{.Variable}}`" + ` | {{.File}} | {{.Line}} | {{if .Checked}}Yes{{else}}No{{end}} | {{.Risk}} | -{{- end}} -{{else}} -No nil sources detected in changed code. -{{end}} - -## High Risk Nil Sources - -{{range .HighRiskNilSources}} -### ` + "`{{.Variable}}`" + ` at ` + "`{{.File}}:{{.Line}}`" + ` -**Expression:** ` + "`{{.Expression}}`" + ` -**Checked:** {{if .Checked}}Yes (line {{.CheckLine}}){{else}}No{{end}} -**Notes:** {{.Notes}} -{{end}} - -## Focus Areas - -Based on analysis, pay special attention to: -{{range $i, $area := .FocusAreas}} -{{inc $i}}. **{{$area.Title}}** - {{$area.Description}} -{{- end}} -{{if not .FocusAreas}} -No specific focus areas identified. -{{end}} -` - -// FocusArea represents a specific area requiring attention. -type FocusArea struct { - Title string - Description string -} - -// FieldChange represents a before/after field comparison. -type FieldChange struct { - Name string - Before string - After string -} - -// TemplateData holds data for template rendering. -type TemplateData struct { - // Common fields - FindingCount int - Findings []Finding - FocusAreas []FocusArea - - // Semantic changes (ring:code-reviewer, ring:business-logic-reviewer) - HasSemanticChanges bool - ModifiedFunctions []FunctionDiff - AddedFunctions []FunctionInfo - ModifiedTypes []TypeDiff - - // Data flow (ring:security-reviewer) - HasDataFlowAnalysis bool - HighRiskFlows []DataFlow - MediumRiskFlows []DataFlow - - // Call graph (ring:business-logic-reviewer, ring:test-reviewer) - HasCallGraph bool - HighImpactFunctions []FunctionCallGraph - UncoveredFunctions []FunctionCallGraph - - // Nil safety (ring:nil-safety-reviewer) - HasNilSources bool - NilSources []NilSource - HighRiskNilSources []NilSource -} - -// templateFuncs provides custom functions for templates. -var templateFuncs = template.FuncMap{ - "inc": func(i int) int { - return i + 1 - }, - "join": func(items []string, sep string) string { - return strings.Join(items, sep) - }, - "signatureChanged": func(f FunctionDiff) bool { - return f.Before.Signature != f.After.Signature - }, - "fieldChanges": func(t TypeDiff) []FieldChange { - var changes []FieldChange - beforeMap := make(map[string]string) - for _, f := range t.Before.Fields { - beforeMap[f.Name] = f.Type - } - afterMap := make(map[string]string) - for _, f := range t.After.Fields { - afterMap[f.Name] = f.Type - } - - // Find modified and added fields - for _, f := range t.After.Fields { - if before, ok := beforeMap[f.Name]; ok { - if before != f.Type { - changes = append(changes, FieldChange{ - Name: f.Name, - Before: before, - After: f.Type, - }) - } - } else { - changes = append(changes, FieldChange{ - Name: f.Name, - Before: "-", - After: f.Type + " (added)", - }) - } - } - - // Find deleted fields - for _, f := range t.Before.Fields { - if _, ok := afterMap[f.Name]; !ok { - changes = append(changes, FieldChange{ - Name: f.Name, - Before: f.Type, - After: "(deleted)", - }) - } - } - - return changes - }, - "riskLevel": func(f FunctionCallGraph) string { - callerCount := len(f.Callers) - if callerCount >= 5 { - return "HIGH" - } - if callerCount >= 2 { - return "MEDIUM" - } - return "LOW" - }, - "testStatus": func(f FunctionCallGraph) string { - if len(f.TestCoverage) == 0 { - return "No tests" - } - return fmt.Sprintf("%d tests", len(f.TestCoverage)) - }, -} - -// RenderTemplate renders a template with the given data. -func RenderTemplate(templateStr string, data *TemplateData) (string, error) { - tmpl, err := template.New("context").Funcs(templateFuncs).Parse(templateStr) - if err != nil { - return "", fmt.Errorf("failed to parse template: %w", err) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return "", fmt.Errorf("failed to execute template: %w", err) - } - - return buf.String(), nil -} - -// GetTemplateForReviewer returns the template string for a specific reviewer. -func GetTemplateForReviewer(reviewer string) string { - switch reviewer { - case "ring:code-reviewer": - return codeReviewerTemplate - case "ring:security-reviewer": - return securityReviewerTemplate - case "ring:business-logic-reviewer": - return businessLogicReviewerTemplate - case "ring:test-reviewer": - return testReviewerTemplate - case "ring:nil-safety-reviewer": - return nilSafetyReviewerTemplate - default: - return "" - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/context/... && cd ../..` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/context/templates.go -git commit -m "feat(ring:codereview): add markdown templates for all reviewers - -Includes templates for ring:code-reviewer, ring:security-reviewer, -ring:business-logic-reviewer, ring:test-reviewer, and ring:nil-safety-reviewer. -Uses text/template with custom functions for rendering." -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: Template syntax, especially backticks and escaping - - Fix: Use raw string literals correctly - - Rollback: `git checkout -- scripts/ring:codereview/internal/context/templates.go` - ---- - -## Task 5: Add Template Tests - -**Files:** -- Create: `scripts/ring:codereview/internal/context/templates_test.go` - -**Prerequisites:** -- Task 4 completed - -**Step 1: Write template tests** - -Create file `scripts/ring:codereview/internal/context/templates_test.go`: - -```go -package context - -import ( - "strings" - "testing" -) - -func TestRenderTemplate_CodeReviewer(t *testing.T) { - data := &TemplateData{ - FindingCount: 2, - Findings: []Finding{ - {Tool: "staticcheck", Rule: "SA1019", Severity: "warning", File: "user.go", Line: 45, Message: "deprecated API"}, - {Tool: "golangci-lint", Rule: "ineffassign", Severity: "info", File: "repo.go", Line: 23, Message: "unused var"}, - }, - HasSemanticChanges: true, - ModifiedFunctions: []FunctionDiff{ - { - Name: "CreateUser", - Package: "handler", - File: "user.go", - Before: FunctionInfo{Signature: "func CreateUser(ctx context.Context)", LineStart: 10, LineEnd: 20}, - After: FunctionInfo{Signature: "func CreateUser(ctx context.Context, opts ...Option)", LineStart: 10, LineEnd: 30}, - Changes: []string{"added_param"}, - }, - }, - FocusAreas: []FocusArea{ - {Title: "Deprecated API", Description: "grpc.Dial needs update"}, - }, - } - - result, err := RenderTemplate(codeReviewerTemplate, data) - if err != nil { - t.Fatalf("RenderTemplate failed: %v", err) - } - - // Verify key sections exist - if !strings.Contains(result, "# Pre-Analysis Context: Code Quality") { - t.Error("Missing title section") - } - if !strings.Contains(result, "Static Analysis Findings (2 issues)") { - t.Error("Missing findings count") - } - if !strings.Contains(result, "staticcheck") { - t.Error("Missing tool name in findings") - } - if !strings.Contains(result, "handler.CreateUser") { - t.Error("Missing function name") - } - if !strings.Contains(result, "Deprecated API") { - t.Error("Missing focus area") - } -} - -func TestRenderTemplate_SecurityReviewer(t *testing.T) { - data := &TemplateData{ - FindingCount: 1, - Findings: []Finding{ - {Tool: "gosec", Rule: "G401", Severity: "high", File: "crypto.go", Line: 23, Message: "weak crypto"}, - }, - HasDataFlowAnalysis: true, - HighRiskFlows: []DataFlow{ - { - ID: "flow-1", - Risk: "high", - Source: FlowSource{ - Type: "http_request", - File: "handler.go", - Line: 45, - Expression: "r.URL.Query().Get(\"id\")", - }, - Sink: FlowSink{ - Type: "database", - File: "repo.go", - Line: 23, - Expression: "db.Query(q, id)", - }, - Sanitized: false, - Notes: "Query param without validation", - }, - }, - } - - result, err := RenderTemplate(securityReviewerTemplate, data) - if err != nil { - t.Fatalf("RenderTemplate failed: %v", err) - } - - if !strings.Contains(result, "# Pre-Analysis Context: Security") { - t.Error("Missing title section") - } - if !strings.Contains(result, "gosec") { - t.Error("Missing security tool") - } - if !strings.Contains(result, "High Risk Flows") { - t.Error("Missing high risk flows section") - } - if !strings.Contains(result, "http_request") { - t.Error("Missing source type") - } -} - -func TestRenderTemplate_NilSafetyReviewer(t *testing.T) { - data := &TemplateData{ - HasNilSources: true, - NilSources: []NilSource{ - {Variable: "user", File: "handler.go", Line: 67, Checked: true, Risk: "low"}, - {Variable: "config", File: "service.go", Line: 23, Checked: false, Risk: "high", Notes: "env var may be empty"}, - }, - HighRiskNilSources: []NilSource{ - {Variable: "config", File: "service.go", Line: 23, Checked: false, Risk: "high", Expression: "os.Getenv(...)", Notes: "env var may be empty"}, - }, - } - - result, err := RenderTemplate(nilSafetyReviewerTemplate, data) - if err != nil { - t.Fatalf("RenderTemplate failed: %v", err) - } - - if !strings.Contains(result, "# Pre-Analysis Context: Nil Safety") { - t.Error("Missing title section") - } - if !strings.Contains(result, "config") { - t.Error("Missing variable name") - } - if !strings.Contains(result, "High Risk Nil Sources") { - t.Error("Missing high risk section") - } -} - -func TestGetTemplateForReviewer(t *testing.T) { - tests := []struct { - reviewer string - wantLen int // non-zero means template exists - }{ - {"ring:code-reviewer", 100}, - {"ring:security-reviewer", 100}, - {"ring:business-logic-reviewer", 100}, - {"ring:test-reviewer", 100}, - {"ring:nil-safety-reviewer", 100}, - {"unknown-reviewer", 0}, - } - - for _, tt := range tests { - t.Run(tt.reviewer, func(t *testing.T) { - template := GetTemplateForReviewer(tt.reviewer) - if (len(template) > 0) != (tt.wantLen > 0) { - t.Errorf("GetTemplateForReviewer(%q) returned len=%d, want len>0: %v", - tt.reviewer, len(template), tt.wantLen > 0) - } - }) - } -} - -func TestRenderTemplate_EmptyData(t *testing.T) { - data := &TemplateData{ - FindingCount: 0, - Findings: nil, - HasSemanticChanges: false, - FocusAreas: nil, - } - - result, err := RenderTemplate(codeReviewerTemplate, data) - if err != nil { - t.Fatalf("RenderTemplate failed with empty data: %v", err) - } - - if !strings.Contains(result, "No static analysis findings") { - t.Error("Should show 'No static analysis findings' for empty findings") - } - if !strings.Contains(result, "No semantic changes detected") { - t.Error("Should show 'No semantic changes detected' for empty changes") - } -} -``` - -**Step 2: Run tests** - -Run: `cd scripts/ring:codereview && go test -v ./internal/context/... && cd ../..` - -**Expected output:** -``` -=== RUN TestReviewerNames ---- PASS: TestReviewerNames (0.00s) -... -=== RUN TestRenderTemplate_CodeReviewer ---- PASS: TestRenderTemplate_CodeReviewer (0.00s) -=== RUN TestRenderTemplate_SecurityReviewer ---- PASS: TestRenderTemplate_SecurityReviewer (0.00s) -=== RUN TestRenderTemplate_NilSafetyReviewer ---- PASS: TestRenderTemplate_NilSafetyReviewer (0.00s) -=== RUN TestGetTemplateForReviewer ---- PASS: TestGetTemplateForReviewer (0.00s) -=== RUN TestRenderTemplate_EmptyData ---- PASS: TestRenderTemplate_EmptyData (0.00s) -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/context -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/context/templates_test.go -git commit -m "test(ring:codereview): add template rendering tests - -Covers ring:code-reviewer, ring:security-reviewer, ring:nil-safety-reviewer templates -and edge cases with empty data." -``` - -**If Task Fails:** - -1. **Template rendering fails:** - - Check: Template syntax errors in the error message - - Fix: Adjust template string escaping - - Rollback: `git checkout -- scripts/ring:codereview/internal/context/templates_test.go` - ---- - -## Task 6: Implement Compiler Core - -**Files:** -- Create: `scripts/ring:codereview/internal/context/compiler.go` - -**Prerequisites:** -- Tasks 4-5 completed - -**Step 1: Write the compiler implementation** - -Create file `scripts/ring:codereview/internal/context/compiler.go`: - -```go -package context - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -// Compiler aggregates phase outputs and generates reviewer context files. -type Compiler struct { - inputDir string - outputDir string - language string -} - -// NewCompiler creates a new context compiler. -// inputDir: directory containing phase outputs (e.g., .ring/ring:codereview/) -// outputDir: directory to write context files (typically same as inputDir) -func NewCompiler(inputDir, outputDir string) *Compiler { - return &Compiler{ - inputDir: inputDir, - outputDir: outputDir, - } -} - -// Compile reads all phase outputs and generates reviewer context files. -func (c *Compiler) Compile() error { - // Read all phase outputs - outputs, err := c.readPhaseOutputs() - if err != nil { - return fmt.Errorf("failed to read phase outputs: %w", err) - } - - // Determine language from scope - if outputs.Scope != nil { - c.language = outputs.Scope.Language - } - - // Generate context for each reviewer - reviewers := GetReviewerNames() - for _, reviewer := range reviewers { - if err := c.generateReviewerContext(reviewer, outputs); err != nil { - return fmt.Errorf("failed to generate context for %s: %w", reviewer, err) - } - } - - return nil -} - -// readPhaseOutputs reads all phase outputs from the input directory. -func (c *Compiler) readPhaseOutputs() (*PhaseOutputs, error) { - outputs := &PhaseOutputs{} - - // Read scope.json (Phase 0) - scopePath := filepath.Join(c.inputDir, "scope.json") - if data, err := os.ReadFile(scopePath); err == nil { - var scope ScopeData - if err := json.Unmarshal(data, &scope); err == nil { - outputs.Scope = &scope - } else { - outputs.Errors = append(outputs.Errors, fmt.Sprintf("scope.json parse error: %v", err)) - } - } - - // Read static-analysis.json (Phase 1) - staticPath := filepath.Join(c.inputDir, "static-analysis.json") - if data, err := os.ReadFile(staticPath); err == nil { - var static StaticAnalysisData - if err := json.Unmarshal(data, &static); err == nil { - outputs.StaticAnalysis = &static - } else { - outputs.Errors = append(outputs.Errors, fmt.Sprintf("static-analysis.json parse error: %v", err)) - } - } - - // Read language-specific AST (Phase 2) - // Try each language suffix - for _, lang := range []string{"go", "ts", "py"} { - astPath := filepath.Join(c.inputDir, fmt.Sprintf("%s-ast.json", lang)) - if data, err := os.ReadFile(astPath); err == nil { - var ast ASTData - if err := json.Unmarshal(data, &ast); err == nil { - outputs.AST = &ast - break - } - } - } - - // Read language-specific call graph (Phase 3) - for _, lang := range []string{"go", "ts", "py"} { - callsPath := filepath.Join(c.inputDir, fmt.Sprintf("%s-calls.json", lang)) - if data, err := os.ReadFile(callsPath); err == nil { - var calls CallGraphData - if err := json.Unmarshal(data, &calls); err == nil { - outputs.CallGraph = &calls - break - } - } - } - - // Read language-specific data flow (Phase 4) - for _, lang := range []string{"go", "ts", "py"} { - flowPath := filepath.Join(c.inputDir, fmt.Sprintf("%s-flow.json", lang)) - if data, err := os.ReadFile(flowPath); err == nil { - var flow DataFlowData - if err := json.Unmarshal(data, &flow); err == nil { - outputs.DataFlow = &flow - break - } - } - } - - return outputs, nil -} - -// generateReviewerContext generates the context file for a specific reviewer. -func (c *Compiler) generateReviewerContext(reviewer string, outputs *PhaseOutputs) error { - // Build template data based on reviewer - data := c.buildTemplateData(reviewer, outputs) - - // Get and render template - templateStr := GetTemplateForReviewer(reviewer) - if templateStr == "" { - return fmt.Errorf("no template found for reviewer: %s", reviewer) - } - - content, err := RenderTemplate(templateStr, data) - if err != nil { - return fmt.Errorf("failed to render template: %w", err) - } - - // Write context file - outputPath := filepath.Join(c.outputDir, fmt.Sprintf("context-%s.md", reviewer)) - if err := os.MkdirAll(c.outputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil { - return fmt.Errorf("failed to write context file: %w", err) - } - - return nil -} - -// buildTemplateData constructs the template data for a specific reviewer. -func (c *Compiler) buildTemplateData(reviewer string, outputs *PhaseOutputs) *TemplateData { - data := &TemplateData{} - - switch reviewer { - case "ring:code-reviewer": - c.buildCodeReviewerData(data, outputs) - case "ring:security-reviewer": - c.buildSecurityReviewerData(data, outputs) - case "ring:business-logic-reviewer": - c.buildBusinessLogicReviewerData(data, outputs) - case "ring:test-reviewer": - c.buildTestReviewerData(data, outputs) - case "ring:nil-safety-reviewer": - c.buildNilSafetyReviewerData(data, outputs) - } - - return data -} - -// buildCodeReviewerData populates data for the code reviewer. -func (c *Compiler) buildCodeReviewerData(data *TemplateData, outputs *PhaseOutputs) { - // Static analysis findings (non-security) - if outputs.StaticAnalysis != nil { - data.Findings = FilterFindingsForCodeReviewer(outputs.StaticAnalysis.Findings) - data.FindingCount = len(data.Findings) - } - - // Semantic changes from AST - if outputs.AST != nil { - data.HasSemanticChanges = true - data.ModifiedFunctions = outputs.AST.Functions.Modified - data.AddedFunctions = outputs.AST.Functions.Added - data.ModifiedTypes = outputs.AST.Types.Modified - } - - // Build focus areas - data.FocusAreas = c.buildCodeReviewerFocusAreas(outputs) -} - -// buildSecurityReviewerData populates data for the security reviewer. -func (c *Compiler) buildSecurityReviewerData(data *TemplateData, outputs *PhaseOutputs) { - // Security-specific findings - if outputs.StaticAnalysis != nil { - data.Findings = FilterFindingsForSecurityReviewer(outputs.StaticAnalysis.Findings) - data.FindingCount = len(data.Findings) - } - - // Data flow analysis - if outputs.DataFlow != nil { - data.HasDataFlowAnalysis = true - for _, flow := range outputs.DataFlow.Flows { - switch flow.Risk { - case "high": - data.HighRiskFlows = append(data.HighRiskFlows, flow) - case "medium": - data.MediumRiskFlows = append(data.MediumRiskFlows, flow) - } - } - } - - // Build focus areas - data.FocusAreas = c.buildSecurityReviewerFocusAreas(outputs) -} - -// buildBusinessLogicReviewerData populates data for the business logic reviewer. -func (c *Compiler) buildBusinessLogicReviewerData(data *TemplateData, outputs *PhaseOutputs) { - // Call graph for impact analysis - if outputs.CallGraph != nil { - data.HasCallGraph = true - data.HighImpactFunctions = GetHighImpactFunctions(outputs.CallGraph, 2) - } - - // Semantic changes - if outputs.AST != nil { - data.HasSemanticChanges = true - data.ModifiedFunctions = outputs.AST.Functions.Modified - } - - // Build focus areas - data.FocusAreas = c.buildBusinessLogicReviewerFocusAreas(outputs) -} - -// buildTestReviewerData populates data for the test reviewer. -func (c *Compiler) buildTestReviewerData(data *TemplateData, outputs *PhaseOutputs) { - // Call graph for test coverage - if outputs.CallGraph != nil { - data.HasCallGraph = true - data.ModifiedFunctions = nil // Will use FunctionCallGraph instead - - // Convert to the right type for template - for _, f := range outputs.CallGraph.ModifiedFunctions { - data.HighImpactFunctions = append(data.HighImpactFunctions, f) - } - - data.UncoveredFunctions = GetUncoveredFunctions(outputs.CallGraph) - } - - // Build focus areas - data.FocusAreas = c.buildTestReviewerFocusAreas(outputs) -} - -// buildNilSafetyReviewerData populates data for the nil safety reviewer. -func (c *Compiler) buildNilSafetyReviewerData(data *TemplateData, outputs *PhaseOutputs) { - // Nil sources from data flow - if outputs.DataFlow != nil && len(outputs.DataFlow.NilSources) > 0 { - data.HasNilSources = true - data.NilSources = outputs.DataFlow.NilSources - data.HighRiskNilSources = FilterNilSourcesByRisk(outputs.DataFlow.NilSources, "high") - } - - // Build focus areas - data.FocusAreas = c.buildNilSafetyReviewerFocusAreas(outputs) -} - -// Focus area builders - -func (c *Compiler) buildCodeReviewerFocusAreas(outputs *PhaseOutputs) []FocusArea { - var areas []FocusArea - - // Check for deprecation warnings - if outputs.StaticAnalysis != nil { - deprecations := FilterFindingsByCategory(outputs.StaticAnalysis.Findings, "deprecation") - if len(deprecations) > 0 { - areas = append(areas, FocusArea{ - Title: "Deprecated API Usage", - Description: fmt.Sprintf("%d deprecated API calls need updating", len(deprecations)), - }) - } - } - - // Check for signature changes - if outputs.AST != nil { - for _, f := range outputs.AST.Functions.Modified { - if f.Before.Signature != f.After.Signature { - areas = append(areas, FocusArea{ - Title: fmt.Sprintf("Signature change in %s", f.Name), - Description: "Function signature modified - verify caller compatibility", - }) - } - } - } - - return areas -} - -func (c *Compiler) buildSecurityReviewerFocusAreas(outputs *PhaseOutputs) []FocusArea { - var areas []FocusArea - - // Check for high-risk data flows - if outputs.DataFlow != nil { - highRisk := 0 - for _, flow := range outputs.DataFlow.Flows { - if flow.Risk == "high" && !flow.Sanitized { - highRisk++ - } - } - if highRisk > 0 { - areas = append(areas, FocusArea{ - Title: "Unsanitized High-Risk Flows", - Description: fmt.Sprintf("%d data flows without sanitization", highRisk), - }) - } - } - - // Check for security findings - if outputs.StaticAnalysis != nil { - critical := FilterFindingsBySeverity( - FilterFindingsForSecurityReviewer(outputs.StaticAnalysis.Findings), - "high", - ) - if len(critical) > 0 { - areas = append(areas, FocusArea{ - Title: "Critical Security Findings", - Description: fmt.Sprintf("%d high/critical security issues detected", len(critical)), - }) - } - } - - return areas -} - -func (c *Compiler) buildBusinessLogicReviewerFocusAreas(outputs *PhaseOutputs) []FocusArea { - var areas []FocusArea - - // Check for high-impact changes - if outputs.CallGraph != nil { - highImpact := GetHighImpactFunctions(outputs.CallGraph, 3) - if len(highImpact) > 0 { - areas = append(areas, FocusArea{ - Title: "High-Impact Functions", - Description: fmt.Sprintf("%d functions with 3+ callers modified", len(highImpact)), - }) - } - } - - // Check for new functions - if outputs.AST != nil && len(outputs.AST.Functions.Added) > 0 { - areas = append(areas, FocusArea{ - Title: "New Functions", - Description: fmt.Sprintf("%d new functions added - verify business requirements", len(outputs.AST.Functions.Added)), - }) - } - - return areas -} - -func (c *Compiler) buildTestReviewerFocusAreas(outputs *PhaseOutputs) []FocusArea { - var areas []FocusArea - - // Check for uncovered functions - if outputs.CallGraph != nil { - uncovered := GetUncoveredFunctions(outputs.CallGraph) - if len(uncovered) > 0 { - areas = append(areas, FocusArea{ - Title: "Uncovered Code", - Description: fmt.Sprintf("%d modified functions without test coverage", len(uncovered)), - }) - } - } - - // Check for new error paths - if outputs.AST != nil && outputs.AST.ErrorHandling.NewErrorReturns != nil { - if len(outputs.AST.ErrorHandling.NewErrorReturns) > 0 { - areas = append(areas, FocusArea{ - Title: "New Error Paths", - Description: fmt.Sprintf("%d new error return paths need negative tests", len(outputs.AST.ErrorHandling.NewErrorReturns)), - }) - } - } - - return areas -} - -func (c *Compiler) buildNilSafetyReviewerFocusAreas(outputs *PhaseOutputs) []FocusArea { - var areas []FocusArea - - // Check for unchecked nil sources - if outputs.DataFlow != nil { - unchecked := FilterNilSourcesUnchecked(outputs.DataFlow.NilSources) - if len(unchecked) > 0 { - areas = append(areas, FocusArea{ - Title: "Unchecked Nil Sources", - Description: fmt.Sprintf("%d potential nil values without checks", len(unchecked)), - }) - } - - // Check for high-risk nil sources - highRisk := FilterNilSourcesByRisk(outputs.DataFlow.NilSources, "high") - if len(highRisk) > 0 { - areas = append(areas, FocusArea{ - Title: "High-Risk Nil Sources", - Description: fmt.Sprintf("%d high-risk nil sources require immediate attention", len(highRisk)), - }) - } - } - - return areas -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build ./internal/context/... && cd ../..` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/context/compiler.go -git commit -m "feat(ring:codereview): add context compiler core logic - -Implements Compiler struct that reads phase outputs (0-4), -builds template data for each reviewer, and generates -context markdown files." -``` - -**If Task Fails:** - -1. **Compilation fails:** - - Check: Import paths and type references - - Fix: Ensure all types are defined in types.go - - Rollback: `git checkout -- scripts/ring:codereview/internal/context/compiler.go` - ---- - -## Task 7: Add Compiler Tests - -**Files:** -- Create: `scripts/ring:codereview/internal/context/compiler_test.go` - -**Prerequisites:** -- Task 6 completed - -**Step 1: Write compiler tests** - -Create file `scripts/ring:codereview/internal/context/compiler_test.go`: - -```go -package context - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCompiler_Compile(t *testing.T) { - // Create temp directories - inputDir := t.TempDir() - outputDir := t.TempDir() - - // Create sample phase outputs - createSamplePhaseOutputs(t, inputDir) - - // Create compiler and run - compiler := NewCompiler(inputDir, outputDir) - if err := compiler.Compile(); err != nil { - t.Fatalf("Compile() error = %v", err) - } - - // Verify all context files were created - reviewers := GetReviewerNames() - for _, reviewer := range reviewers { - contextPath := filepath.Join(outputDir, "context-"+reviewer+".md") - if _, err := os.Stat(contextPath); os.IsNotExist(err) { - t.Errorf("Context file not created for %s", reviewer) - } - - // Read and verify content - content, err := os.ReadFile(contextPath) - if err != nil { - t.Errorf("Failed to read context file for %s: %v", reviewer, err) - continue - } - - // Basic content verification - if len(content) == 0 { - t.Errorf("Context file for %s is empty", reviewer) - } - } -} - -func TestCompiler_CodeReviewerContext(t *testing.T) { - inputDir := t.TempDir() - outputDir := t.TempDir() - createSamplePhaseOutputs(t, inputDir) - - compiler := NewCompiler(inputDir, outputDir) - if err := compiler.Compile(); err != nil { - t.Fatalf("Compile() error = %v", err) - } - - content, err := os.ReadFile(filepath.Join(outputDir, "context-ring:code-reviewer.md")) - if err != nil { - t.Fatalf("Failed to read ring:code-reviewer context: %v", err) - } - - contentStr := string(content) - if !strings.Contains(contentStr, "Code Quality") { - t.Error("Missing 'Code Quality' title") - } - if !strings.Contains(contentStr, "Static Analysis Findings") { - t.Error("Missing static analysis section") - } -} - -func TestCompiler_SecurityReviewerContext(t *testing.T) { - inputDir := t.TempDir() - outputDir := t.TempDir() - createSamplePhaseOutputs(t, inputDir) - - compiler := NewCompiler(inputDir, outputDir) - if err := compiler.Compile(); err != nil { - t.Fatalf("Compile() error = %v", err) - } - - content, err := os.ReadFile(filepath.Join(outputDir, "context-ring:security-reviewer.md")) - if err != nil { - t.Fatalf("Failed to read ring:security-reviewer context: %v", err) - } - - contentStr := string(content) - if !strings.Contains(contentStr, "Security") { - t.Error("Missing 'Security' title") - } -} - -func TestCompiler_MissingInputs(t *testing.T) { - // Test with empty input directory - inputDir := t.TempDir() - outputDir := t.TempDir() - - compiler := NewCompiler(inputDir, outputDir) - - // Should not fail, just produce minimal output - if err := compiler.Compile(); err != nil { - t.Fatalf("Compile() should not fail with missing inputs: %v", err) - } - - // Context files should still be created (with "no data" messages) - contextPath := filepath.Join(outputDir, "context-ring:code-reviewer.md") - if _, err := os.Stat(contextPath); os.IsNotExist(err) { - t.Error("Context file should be created even with missing inputs") - } -} - -func TestCompiler_PartialInputs(t *testing.T) { - inputDir := t.TempDir() - outputDir := t.TempDir() - - // Create only scope.json - scope := ScopeData{ - BaseRef: "main", - HeadRef: "HEAD", - Language: "go", - } - scopeData, _ := json.Marshal(scope) - os.WriteFile(filepath.Join(inputDir, "scope.json"), scopeData, 0644) - - compiler := NewCompiler(inputDir, outputDir) - if err := compiler.Compile(); err != nil { - t.Fatalf("Compile() error with partial inputs: %v", err) - } - - // Verify files were created - for _, reviewer := range GetReviewerNames() { - contextPath := filepath.Join(outputDir, "context-"+reviewer+".md") - if _, err := os.Stat(contextPath); os.IsNotExist(err) { - t.Errorf("Context file not created for %s with partial inputs", reviewer) - } - } -} - -// Helper function to create sample phase outputs for testing -func createSamplePhaseOutputs(t *testing.T, dir string) { - t.Helper() - - // scope.json - scope := ScopeData{ - BaseRef: "main", - HeadRef: "HEAD", - Language: "go", - ModifiedFiles: []string{"internal/handler/user.go"}, - AddedFiles: []string{"internal/service/notification.go"}, - TotalFiles: 2, - TotalAdditions: 100, - TotalDeletions: 10, - PackagesAffected: []string{"internal/handler", "internal/service"}, - } - writeJSON(t, dir, "scope.json", scope) - - // static-analysis.json - static := StaticAnalysisData{ - ToolVersions: map[string]string{"golangci-lint": "1.56.0"}, - Findings: []Finding{ - {Tool: "staticcheck", Rule: "SA1019", Severity: "warning", File: "user.go", Line: 45, Message: "deprecated", Category: "deprecation"}, - {Tool: "gosec", Rule: "G401", Severity: "high", File: "crypto.go", Line: 23, Message: "weak crypto", Category: "security"}, - }, - Summary: FindingSummary{High: 1, Warning: 1}, - } - writeJSON(t, dir, "static-analysis.json", static) - - // go-ast.json - ast := ASTData{ - Functions: FunctionChanges{ - Modified: []FunctionDiff{ - { - Name: "CreateUser", - Package: "handler", - File: "user.go", - Before: FunctionInfo{Signature: "func CreateUser(ctx context.Context)", LineStart: 10, LineEnd: 20}, - After: FunctionInfo{Signature: "func CreateUser(ctx context.Context, opts ...Option)", LineStart: 10, LineEnd: 30}, - Changes: []string{"added_param"}, - }, - }, - Added: []FunctionInfo{ - {Name: "NotifyUser", Package: "service", File: "notification.go", Signature: "func NotifyUser(ctx context.Context)", LineStart: 10, LineEnd: 40}, - }, - }, - } - writeJSON(t, dir, "go-ast.json", ast) - - // go-calls.json - calls := CallGraphData{ - ModifiedFunctions: []FunctionCallGraph{ - { - Function: "handler.CreateUser", - File: "user.go", - Callers: []CallSite{ - {Function: "router.ServeHTTP", File: "router.go", Line: 89}, - }, - TestCoverage: []TestCoverage{ - {TestFunction: "TestCreateUser", File: "user_test.go", Line: 23}, - }, - }, - }, - ImpactAnalysis: ImpactSummary{DirectCallers: 1, AffectedTests: 1}, - } - writeJSON(t, dir, "go-calls.json", calls) - - // go-flow.json - flow := DataFlowData{ - Flows: []DataFlow{ - { - ID: "flow-1", - Risk: "medium", - Sanitized: false, - Source: FlowSource{Type: "http_request", File: "user.go", Line: 23}, - Sink: FlowSink{Type: "database", File: "repo.go", Line: 45}, - }, - }, - NilSources: []NilSource{ - {Variable: "user", File: "user.go", Line: 67, Checked: true, Risk: "low"}, - {Variable: "config", File: "service.go", Line: 23, Checked: false, Risk: "high", Notes: "env var"}, - }, - Summary: FlowSummary{TotalFlows: 1, MediumRisk: 1, NilRisks: 1}, - } - writeJSON(t, dir, "go-flow.json", flow) -} - -func writeJSON(t *testing.T, dir, filename string, data interface{}) { - t.Helper() - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - t.Fatalf("Failed to marshal %s: %v", filename, err) - } - if err := os.WriteFile(filepath.Join(dir, filename), jsonData, 0644); err != nil { - t.Fatalf("Failed to write %s: %v", filename, err) - } -} -``` - -**Step 2: Run tests** - -Run: `cd scripts/ring:codereview && go test -v ./internal/context/... && cd ../..` - -**Expected output:** -``` -=== RUN TestCompiler_Compile ---- PASS: TestCompiler_Compile (0.XX s) -=== RUN TestCompiler_CodeReviewerContext ---- PASS: TestCompiler_CodeReviewerContext (0.XX s) -=== RUN TestCompiler_SecurityReviewerContext ---- PASS: TestCompiler_SecurityReviewerContext (0.XX s) -=== RUN TestCompiler_MissingInputs ---- PASS: TestCompiler_MissingInputs (0.XX s) -=== RUN TestCompiler_PartialInputs ---- PASS: TestCompiler_PartialInputs (0.XX s) -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/context -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/internal/context/compiler_test.go -git commit -m "test(ring:codereview): add compiler integration tests - -Tests full compilation flow, individual reviewer contexts, -missing inputs handling, and partial inputs scenarios." -``` - -**If Task Fails:** - -1. **Tests fail:** - - Check: Error messages for file path issues - - Fix: Ensure temp directories are created correctly - - Rollback: `git checkout -- scripts/ring:codereview/internal/context/compiler_test.go` - ---- - -## Task 8: Implement compile-context CLI - -**Files:** -- Create: `scripts/ring:codereview/cmd/compile-context/main.go` - -**Prerequisites:** -- Tasks 6-7 completed - -**Step 1: Create CLI binary** - -Create file `scripts/ring:codereview/cmd/compile-context/main.go`: - -```go -// compile-context aggregates analysis phase outputs into reviewer-specific context files. -// -// Usage: -// -// compile-context --input=.ring/ring:codereview/ --output=.ring/ring:codereview/ -// -// This binary reads all phase outputs (scope.json, static-analysis.json, etc.) -// and generates context files for each reviewer: -// - context-ring:code-reviewer.md -// - context-ring:security-reviewer.md -// - context-ring:business-logic-reviewer.md -// - context-ring:test-reviewer.md -// - context-ring:nil-safety-reviewer.md -package main - -import ( - "flag" - "fmt" - "os" - - "github.com/lerianstudio/ring/scripts/ring:codereview/internal/context" -) - -func main() { - inputDir := flag.String("input", ".ring/ring:codereview", "Directory containing phase outputs") - outputDir := flag.String("output", "", "Directory to write context files (defaults to input dir)") - verbose := flag.Bool("verbose", false, "Enable verbose output") - help := flag.Bool("help", false, "Show help message") - - flag.Parse() - - if *help { - printUsage() - os.Exit(0) - } - - // Default output to input directory - if *outputDir == "" { - *outputDir = *inputDir - } - - // Verify input directory exists - if _, err := os.Stat(*inputDir); os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error: Input directory does not exist: %s\n", *inputDir) - os.Exit(1) - } - - if *verbose { - fmt.Printf("Input directory: %s\n", *inputDir) - fmt.Printf("Output directory: %s\n", *outputDir) - } - - // Create compiler and run - compiler := context.NewCompiler(*inputDir, *outputDir) - if err := compiler.Compile(); err != nil { - fmt.Fprintf(os.Stderr, "Error: Compilation failed: %v\n", err) - os.Exit(1) - } - - if *verbose { - fmt.Println("Context files generated:") - for _, reviewer := range context.GetReviewerNames() { - fmt.Printf(" - context-%s.md\n", reviewer) - } - } - - fmt.Println("Context compilation complete.") -} - -func printUsage() { - fmt.Println(`compile-context - Generate reviewer-specific context files - -Usage: - compile-context [flags] - -Flags: - --input DIR Directory containing phase outputs (default: .ring/ring:codereview) - --output DIR Directory to write context files (default: same as input) - --verbose Enable verbose output - --help Show this help message - -Examples: - # Compile context from default location - compile-context - - # Specify custom input/output directories - compile-context --input=/path/to/analysis --output=/path/to/output - - # Verbose mode - compile-context --verbose - -Phase Outputs Expected (in input directory): - - scope.json (Phase 0: Scope detection) - - static-analysis.json (Phase 1: Static analysis) - - {go,ts,py}-ast.json (Phase 2: AST extraction) - - {go,ts,py}-calls.json (Phase 3: Call graph) - - {go,ts,py}-flow.json (Phase 4: Data flow) - -Context Files Generated (in output directory): - - context-ring:code-reviewer.md - - context-ring:security-reviewer.md - - context-ring:business-logic-reviewer.md - - context-ring:test-reviewer.md - - context-ring:nil-safety-reviewer.md -`) -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build -o bin/compile-context ./cmd/compile-context && cd ../..` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Test binary** - -Run: `scripts/ring:codereview/bin/compile-context --help` - -**Expected output:** -``` -compile-context - Generate reviewer-specific context files - -Usage: - compile-context [flags] -... -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/cmd/compile-context/main.go -git commit -m "feat(ring:codereview): add compile-context CLI binary - -Phase 5 binary that reads all phase outputs and generates -reviewer-specific context markdown files." -``` - -**If Task Fails:** - -1. **Build fails:** - - Check: Import path for context package - - Fix: Ensure module path matches - - Rollback: `rm -rf scripts/ring:codereview/cmd/compile-context` - ---- - -## Task 9: Implement run-all Orchestrator - -**Files:** -- Create: `scripts/ring:codereview/cmd/run-all/main.go` - -**Prerequisites:** -- Task 8 completed - -**Step 1: Create orchestrator binary** - -Create file `scripts/ring:codereview/cmd/run-all/main.go`: - -```go -// run-all orchestrates the complete code review pre-analysis pipeline. -// -// Usage: -// -// run-all --base=main --head=HEAD --output=.ring/ring:codereview/ -// -// This binary runs all phases in sequence: -// - Phase 0: Scope detection -// - Phase 1: Static analysis -// - Phase 2: AST extraction -// - Phase 3: Call graph analysis -// - Phase 4: Data flow analysis -// - Phase 5: Context compilation -package main - -import ( - "flag" - "fmt" - "os" - "os/exec" - "path/filepath" - "time" -) - -// Phase represents a single analysis phase. -type Phase struct { - Name string - Binary string - Args func(cfg *Config) []string - Output string - Timeout time.Duration -} - -// Config holds the configuration for the pipeline run. -type Config struct { - BaseRef string - HeadRef string - OutputDir string - BinDir string - Verbose bool -} - -func main() { - baseRef := flag.String("base", "main", "Base git ref for comparison") - headRef := flag.String("head", "HEAD", "Head git ref for comparison") - outputDir := flag.String("output", ".ring/ring:codereview", "Output directory for analysis results") - binDir := flag.String("bin-dir", "", "Directory containing analysis binaries (auto-detected)") - verbose := flag.Bool("verbose", false, "Enable verbose output") - skipPhases := flag.String("skip", "", "Comma-separated phases to skip (e.g., 'ast,callgraph')") - help := flag.Bool("help", false, "Show help message") - - flag.Parse() - - if *help { - printUsage() - os.Exit(0) - } - - // Auto-detect bin directory - if *binDir == "" { - execPath, err := os.Executable() - if err == nil { - *binDir = filepath.Dir(execPath) - } else { - *binDir = "scripts/ring:codereview/bin" - } - } - - cfg := &Config{ - BaseRef: *baseRef, - HeadRef: *headRef, - OutputDir: *outputDir, - BinDir: *binDir, - Verbose: *verbose, - } - - // Create output directory - if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "Error: Failed to create output directory: %v\n", err) - os.Exit(1) - } - - // Parse skip phases - skipMap := parseSkipPhases(*skipPhases) - - // Define phases - phases := []Phase{ - { - Name: "scope", - Binary: "scope-detector", - Args: func(c *Config) []string { - return []string{"--base", c.BaseRef, "--head", c.HeadRef, "--output", filepath.Join(c.OutputDir, "scope.json")} - }, - Output: "scope.json", - Timeout: 30 * time.Second, - }, - { - Name: "static-analysis", - Binary: "static-analysis", - Args: func(c *Config) []string { - return []string{"--scope", filepath.Join(c.OutputDir, "scope.json"), "--output", c.OutputDir} - }, - Output: "static-analysis.json", - Timeout: 5 * time.Minute, - }, - { - Name: "ast", - Binary: "ast-extractor", - Args: func(c *Config) []string { - return []string{"--scope", filepath.Join(c.OutputDir, "scope.json"), "--output", c.OutputDir} - }, - Output: "*-ast.json", - Timeout: 2 * time.Minute, - }, - { - Name: "callgraph", - Binary: "call-graph", - Args: func(c *Config) []string { - return []string{"--scope", filepath.Join(c.OutputDir, "scope.json"), "--output", c.OutputDir} - }, - Output: "*-calls.json", - Timeout: 3 * time.Minute, - }, - { - Name: "dataflow", - Binary: "data-flow", - Args: func(c *Config) []string { - return []string{"--scope", filepath.Join(c.OutputDir, "scope.json"), "--output", c.OutputDir} - }, - Output: "*-flow.json", - Timeout: 3 * time.Minute, - }, - { - Name: "context", - Binary: "compile-context", - Args: func(c *Config) []string { - return []string{"--input", c.OutputDir, "--output", c.OutputDir} - }, - Output: "context-*.md", - Timeout: 30 * time.Second, - }, - } - - // Run phases - startTime := time.Now() - failedPhases := []string{} - - for _, phase := range phases { - if skipMap[phase.Name] { - if cfg.Verbose { - fmt.Printf("Skipping phase: %s\n", phase.Name) - } - continue - } - - if cfg.Verbose { - fmt.Printf("Running phase: %s\n", phase.Name) - } - - if err := runPhase(cfg, phase); err != nil { - fmt.Fprintf(os.Stderr, "Warning: Phase %s failed: %v\n", phase.Name, err) - failedPhases = append(failedPhases, phase.Name) - // Continue with remaining phases for graceful degradation - } - } - - elapsed := time.Since(startTime) - - // Summary - fmt.Println() - fmt.Println("=== Pre-Analysis Complete ===") - fmt.Printf("Duration: %v\n", elapsed.Round(time.Millisecond)) - fmt.Printf("Output: %s\n", cfg.OutputDir) - - if len(failedPhases) > 0 { - fmt.Printf("Failed phases: %v\n", failedPhases) - fmt.Println("Some context may be incomplete. Reviewers will proceed with available data.") - os.Exit(1) // Non-zero exit for CI awareness - } - - fmt.Println("All phases completed successfully.") -} - -func runPhase(cfg *Config, phase Phase) error { - binaryPath := filepath.Join(cfg.BinDir, phase.Binary) - - // Check if binary exists - if _, err := os.Stat(binaryPath); os.IsNotExist(err) { - return fmt.Errorf("binary not found: %s", binaryPath) - } - - args := phase.Args(cfg) - cmd := exec.Command(binaryPath, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if cfg.Verbose { - fmt.Printf(" Command: %s %v\n", binaryPath, args) - } - - // Run with timeout - done := make(chan error, 1) - go func() { - done <- cmd.Run() - }() - - select { - case err := <-done: - return err - case <-time.After(phase.Timeout): - if cmd.Process != nil { - cmd.Process.Kill() - } - return fmt.Errorf("timeout after %v", phase.Timeout) - } -} - -func parseSkipPhases(skip string) map[string]bool { - skipMap := make(map[string]bool) - if skip == "" { - return skipMap - } - for _, phase := range splitAndTrim(skip, ',') { - skipMap[phase] = true - } - return skipMap -} - -func splitAndTrim(s string, sep rune) []string { - var result []string - current := "" - for _, c := range s { - if c == sep { - if current != "" { - result = append(result, current) - current = "" - } - } else if c != ' ' { - current += string(c) - } - } - if current != "" { - result = append(result, current) - } - return result -} - -func printUsage() { - fmt.Println(`run-all - Orchestrate complete code review pre-analysis pipeline - -Usage: - run-all [flags] - -Flags: - --base REF Base git ref for comparison (default: main) - --head REF Head git ref for comparison (default: HEAD) - --output DIR Output directory for results (default: .ring/ring:codereview) - --bin-dir DIR Directory containing binaries (auto-detected) - --skip PHASES Comma-separated phases to skip - --verbose Enable verbose output - --help Show this help message - -Phases (in order): - 1. scope - Detect changed files and language - 2. static-analysis - Run linters and static analyzers - 3. ast - Extract AST and semantic changes - 4. callgraph - Analyze call graphs and impact - 5. dataflow - Track data flow and nil sources - 6. context - Compile reviewer context files - -Examples: - # Run full pipeline - run-all - - # Compare specific refs - run-all --base=origin/main --head=feature-branch - - # Skip slow phases - run-all --skip=callgraph,dataflow - - # Custom output location - run-all --output=/tmp/ring:codereview-analysis - -Graceful Degradation: - If a phase fails, subsequent phases continue with available data. - Context files will note missing analysis sections. -`) -} -``` - -**Step 2: Verify compilation** - -Run: `cd scripts/ring:codereview && go build -o bin/run-all ./cmd/run-all && cd ../..` - -**Expected output:** -``` -(no output - successful compilation) -``` - -**Step 3: Test binary** - -Run: `scripts/ring:codereview/bin/run-all --help` - -**Expected output:** -``` -run-all - Orchestrate complete code review pre-analysis pipeline - -Usage: - run-all [flags] -... -``` - -**Step 4: Commit** - -```bash -git add scripts/ring:codereview/cmd/run-all/main.go -git commit -m "feat(ring:codereview): add run-all orchestrator binary - -Main entry point that runs all analysis phases (0-5) in sequence -with timeout handling and graceful degradation on failures." -``` - -**If Task Fails:** - -1. **Build fails:** - - Check: Import paths and command structure - - Fix: Ensure all packages are importable - - Rollback: `rm -rf scripts/ring:codereview/cmd/run-all` - ---- - -## Task 10: Update Makefile - -**Files:** -- Modify: `scripts/ring:codereview/Makefile` - -**Prerequisites:** -- Tasks 8-9 completed - -**Step 1: Update Makefile with new targets** - -Modify `scripts/ring:codereview/Makefile` to add the new binaries: - -```makefile -.PHONY: all build test clean install fmt vet lint - -# Binary output directory -BIN_DIR := bin - -# All binaries to build -BINARIES := scope-detector static-analysis ast-extractor call-graph data-flow compile-context run-all - -all: build - -build: $(BINARIES) - -scope-detector: - @echo "Building scope-detector..." - @go build -o $(BIN_DIR)/scope-detector ./cmd/scope-detector - -static-analysis: - @echo "Building static-analysis..." - @go build -o $(BIN_DIR)/static-analysis ./cmd/static-analysis - -ast-extractor: - @echo "Building ast-extractor..." - @go build -o $(BIN_DIR)/ast-extractor ./cmd/ast-extractor - -call-graph: - @echo "Building call-graph..." - @go build -o $(BIN_DIR)/call-graph ./cmd/call-graph - -data-flow: - @echo "Building data-flow..." - @go build -o $(BIN_DIR)/data-flow ./cmd/data-flow - -compile-context: - @echo "Building compile-context..." - @go build -o $(BIN_DIR)/compile-context ./cmd/compile-context - -run-all: - @echo "Building run-all..." - @go build -o $(BIN_DIR)/run-all ./cmd/run-all - -test: - @echo "Running tests..." - @go test -v -race ./... - -test-coverage: - @echo "Running tests with coverage..." - @go test -v -race -coverprofile=coverage.out ./... - @go tool cover -html=coverage.out -o coverage.html - @echo "Coverage report: coverage.html" - -clean: - @echo "Cleaning..." - @rm -rf $(BIN_DIR) - @rm -f coverage.out coverage.html - -install: build - @echo "Installing binaries to $(BIN_DIR)..." - @chmod +x $(BIN_DIR)/* - -# Development helpers -fmt: - @go fmt ./... - -vet: - @go vet ./... - -lint: fmt vet - -# Quick build for development (only context-related) -build-context: compile-context run-all - @echo "Context binaries built." -``` - -**Step 2: Verify Makefile** - -Run: `cd scripts/ring:codereview && make build-context && cd ../..` - -**Expected output:** -``` -Building compile-context... -Building run-all... -Context binaries built. -``` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/Makefile -git commit -m "build(ring:codereview): add compile-context and run-all targets to Makefile - -Updates Makefile with Phase 5 binaries and convenience target build-context." -``` - -**If Task Fails:** - -1. **Make fails:** - - Check: Tab vs spaces in Makefile - - Fix: Ensure tabs are used for indentation - - Rollback: `git checkout -- scripts/ring:codereview/Makefile` - ---- - -## Task 11: Update install.sh - -**Files:** -- Modify: `scripts/ring:codereview/install.sh` - -**Prerequisites:** -- Task 10 completed - -**Step 1: Update install.sh with new binaries** - -If `scripts/ring:codereview/install.sh` exists, update it. If not, create it: - -```bash -#!/bin/bash -set -e - -# Installs all required tools for ring:codereview pre-analysis -# Run: ./scripts/ring:codereview/install.sh [go|ts|py|all] - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -INSTALL_TARGET="${1:-all}" - -install_go_tools() { - echo "=== Installing Go tools ===" - go install honnef.co/go/tools/cmd/staticcheck@latest - go install github.com/securego/gosec/v2/cmd/gosec@latest - go install golang.org/x/tools/cmd/callgraph@latest - go install golang.org/x/tools/cmd/guru@latest - - # golangci-lint (platform-specific) - if [[ "$OSTYPE" == "darwin"* ]]; then - brew install golangci-lint 2>/dev/null || brew upgrade golangci-lint 2>/dev/null || true - else - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)/bin" - fi - echo "Go tools installed" -} - -install_ts_tools() { - echo "=== Installing TypeScript tools ===" - npm install -g typescript dependency-cruiser madge - - # Install local TS helper deps - if [ -d "$SCRIPT_DIR/ts" ]; then - cd "$SCRIPT_DIR/ts" - npm install - cd - - fi - echo "TypeScript tools installed" -} - -install_py_tools() { - echo "=== Installing Python tools ===" - pip install --upgrade ruff mypy pylint bandit pyan3 - - # Verify Python helper has no external deps (uses stdlib ast) - python3 -c "import ast; print('Python ast module: OK')" - echo "Python tools installed" -} - -build_binaries() { - echo "=== Building Go analysis binaries ===" - cd "$SCRIPT_DIR" - - # Ensure bin directory exists - mkdir -p bin - - # Build all binaries - go build -o bin/scope-detector ./cmd/scope-detector - go build -o bin/static-analysis ./cmd/static-analysis - go build -o bin/ast-extractor ./cmd/ast-extractor - go build -o bin/call-graph ./cmd/call-graph - go build -o bin/data-flow ./cmd/data-flow - go build -o bin/compile-context ./cmd/compile-context - go build -o bin/run-all ./cmd/run-all - - cd - - echo "Binaries built" -} - -verify_installation() { - echo "=== Verifying installation ===" - local missing="" - - # Check Go binaries - for bin in scope-detector static-analysis compile-context run-all; do - if [ ! -f "$SCRIPT_DIR/bin/$bin" ]; then - missing="$missing $bin" - fi - done - - if [ -n "$missing" ]; then - echo "Warning: Missing binaries:$missing" - echo "Run './install.sh all' to build all binaries." - else - echo "All binaries present." - fi -} - -case "$INSTALL_TARGET" in - go) - install_go_tools - ;; - ts) - install_ts_tools - ;; - py) - install_py_tools - ;; - build) - build_binaries - ;; - all) - install_go_tools - install_ts_tools - install_py_tools - build_binaries - verify_installation - ;; - verify) - verify_installation - ;; - *) - echo "Usage: $0 [go|ts|py|build|all|verify]" - exit 1 - ;; -esac - -echo "" -echo "Installation complete!" -echo "Run '$0 verify' to verify installation." -``` - -**Step 2: Make script executable** - -Run: `chmod +x scripts/ring:codereview/install.sh` - -**Step 3: Commit** - -```bash -git add scripts/ring:codereview/install.sh -git commit -m "build(ring:codereview): update install.sh with all Phase 5 binaries - -Includes compile-context and run-all in build targets, adds verify option." -``` - -**If Task Fails:** - -1. **Script fails:** - - Check: Bash syntax - - Fix: Verify shebang and variable expansion - - Rollback: `git checkout -- scripts/ring:codereview/install.sh` - ---- - -## Task 12: Code Review Checkpoint - -**Prerequisites:** -- Tasks 1-11 completed - -**Step 1: Run all tests** - -Run: `cd scripts/ring:codereview && go test -v -race ./... && cd ../..` - -**Expected output:** -``` -=== RUN TestReviewerNames ---- PASS: TestReviewerNames (0.00s) -... -=== RUN TestCompiler_Compile ---- PASS: TestCompiler_Compile (0.XX s) -... -PASS -ok github.com/lerianstudio/ring/scripts/ring:codereview/internal/context -``` - -**Step 2: Build all binaries** - -Run: `cd scripts/ring:codereview && make build-context && cd ../..` - -**Expected output:** -``` -Building compile-context... -Building run-all... -Context binaries built. -``` - -**Step 3: Dispatch code review** - -1. **REQUIRED SUB-SKILL: Use ring:requesting-code-review** -2. All reviewers run simultaneously (ring:code-reviewer, ring:business-logic-reviewer, ring:security-reviewer, ring:test-reviewer, ring:nil-safety-reviewer) -3. Wait for all to complete - -**Step 4: Handle findings by severity (MANDATORY):** - -**Critical/High/Medium Issues:** -- Fix immediately (do NOT add TODO comments for these severities) -- Re-run all 5 reviewers in parallel after fixes -- Repeat until zero Critical/High/Medium issues remain - -**Low Issues:** -- Add `TODO(review):` comments in code at the relevant location -- Format: `TODO(review): [Issue description] (reported by [reviewer] on [date], severity: Low)` - -**Cosmetic/Nitpick Issues:** -- Add `FIXME(nitpick):` comments in code at the relevant location - -**Step 5: Proceed only when:** -- Zero Critical/High/Medium issues remain -- All Low issues have TODO(review): comments added -- All Cosmetic issues have FIXME(nitpick): comments added - ---- - -## Task 13: Build and Verify - -**Prerequisites:** -- Task 12 completed - -**Step 1: Full build** - -Run: `cd scripts/ring:codereview && make clean && make build && cd ../..` - -**Expected output:** -``` -Cleaning... -Building scope-detector... -Building static-analysis... -Building ast-extractor... -Building call-graph... -Building data-flow... -Building compile-context... -Building run-all... -``` - -**Step 2: Verify binary sizes** - -Run: `ls -lh scripts/ring:codereview/bin/` - -**Expected output:** -``` -total XX --rwxr-xr-x 1 user staff X.XM Jan 13 XX:XX compile-context --rwxr-xr-x 1 user staff X.XM Jan 13 XX:XX run-all -... -``` - -**Step 3: Test compile-context with sample data** - -```bash -# Create test directory -mkdir -p /tmp/test-ring:codereview - -# Create minimal scope.json -cat > /tmp/test-ring:codereview/scope.json << 'EOF' -{ - "base_ref": "main", - "head_ref": "HEAD", - "language": "go", - "modified": ["user.go"], - "added": [], - "deleted": [], - "total_files": 1, - "total_additions": 10, - "total_deletions": 5, - "packages_affected": ["internal/handler"] -} -EOF - -# Run compile-context -scripts/ring:codereview/bin/compile-context --input=/tmp/test-ring:codereview --output=/tmp/test-ring:codereview --verbose - -# Verify output -ls -la /tmp/test-ring:codereview/context-*.md -``` - -**Expected output:** -``` -Input directory: /tmp/test-ring:codereview -Output directory: /tmp/test-ring:codereview -Context files generated: - - context-ring:code-reviewer.md - - context-ring:security-reviewer.md - - context-ring:business-logic-reviewer.md - - context-ring:test-reviewer.md - - context-ring:nil-safety-reviewer.md -Context compilation complete. -... --rw-r--r-- 1 user staff XXX Jan 13 XX:XX /tmp/test-ring:codereview/context-ring:code-reviewer.md -... -``` - -**Step 4: Cleanup test data** - -Run: `rm -rf /tmp/test-ring:codereview` - -**Step 5: Final commit** - -```bash -git add . -git commit -m "feat(ring:codereview): complete Phase 5 context compilation - -Implements: -- context package with types, templates, and compiler -- compile-context binary for generating reviewer context files -- run-all binary for orchestrating the full analysis pipeline -- Updated Makefile and install.sh - -All 5 reviewers get dedicated context files: -- context-ring:code-reviewer.md -- context-ring:security-reviewer.md -- context-ring:business-logic-reviewer.md -- context-ring:test-reviewer.md -- context-ring:nil-safety-reviewer.md" -``` - -**If Task Fails:** - -1. **Build fails:** - - Check: `go build` error messages - - Fix: Address compilation errors - - Rollback: `git reset --hard HEAD~1` - -2. **Test run fails:** - - Check: Binary execution errors - - Fix: Verify input JSON format - - Rollback: N/A (test data is temporary) - ---- - -## Plan Checklist - -Before executing this plan, verify: - -- [ ] **Historical precedent queried** (artifact-query --mode planning) -- [ ] Historical Precedent section included in plan -- [ ] Header with goal, architecture, tech stack, prerequisites -- [ ] Verification commands with expected output -- [ ] Tasks broken into bite-sized steps (2-5 min each) -- [ ] Exact file paths for all files -- [ ] Complete code (no placeholders) -- [ ] Exact commands with expected output -- [ ] Failure recovery steps for each task -- [ ] Code review checkpoints after batches -- [ ] Severity-based issue handling documented -- [ ] Passes Zero-Context Test -- [ ] **Plan avoids known failure patterns** (none found) - ---- - -## After Implementation - -After completing all tasks: - -1. **Verify all tests pass:** - ```bash - cd scripts/ring:codereview && go test -v -race ./... && cd ../.. - ``` - -2. **Verify all binaries build:** - ```bash - cd scripts/ring:codereview && make clean && make build && cd ../.. - ``` - -3. **Integration test with real project:** - ```bash - # In a Go project with changes - scripts/ring:codereview/bin/run-all --base=main --head=HEAD --verbose - ``` - -4. **Review generated context files** in `.ring/ring:codereview/`: - - Verify each reviewer's context file contains relevant data - - Check that focus areas are actionable - - Ensure empty sections show appropriate "no data" messages diff --git a/docs/plans/codereview-enhancement-macro-plan.md b/docs/plans/codereview-enhancement-macro-plan.md deleted file mode 100644 index 1a77ad34..00000000 --- a/docs/plans/codereview-enhancement-macro-plan.md +++ /dev/null @@ -1,1774 +0,0 @@ -# Codereview Enhancement: Pre-Analysis Pipeline - -## Macro Plan v1.0 - -**Goal:** Enhance `/ring:codereview` with script-based static analysis, AST parsing, call graphs, and data flow analysis to catch issues before PR submission. - -**Languages:** Go, TypeScript, Python (single-language projects, not mixed) -**Approach:** Script-based with Go binaries for analysis tools -**Integration:** `/ring:codereview` unchanged; scripts provide enriched context to existing reviewers -**Output Location:** `.ring/ring:codereview/` (project-local, gitignored) - ---- - -## Decisions Log - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Script language | Go binaries | Team codes in Go; native AST parsing | -| Output location | `.ring/ring:codereview/` | Project-local, consistent with Ring tooling | -| CI integration | Not in v1 | Focus on local dev experience first | -| Monorepo support | Option B (detect & adapt) | Simple projects expected; warn on complexity | -| Project type | Single-language | Go OR TypeScript OR Python per project, not mixed | - ---- - -## 1. Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ /ring:codereview INVOCATION │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ PHASE 0: SCOPE DETECTION │ -│ scripts/ring:codereview/bin/scope-detector │ -│ ├── Parses git diff to identify changed files │ -│ ├── Detects language (Go, TypeScript, or Python) │ -│ ├── Identifies change types (new files, modifications, deletions) │ -│ └── Outputs: scope.json │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ PHASE 1: STATIC ANALYSIS │ -│ Runs for detected language: │ -│ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │ -│ │ Go │ │ TypeScript │ │ Python │ │ -│ │ ├── go vet │ │ ├── tsc --noEmit │ │ ├── ruff │ │ -│ │ ├── staticcheck │ │ ├── eslint │ │ ├── mypy │ │ -│ │ ├── golangci-lint │ │ ├── npm audit │ │ ├── pylint │ │ -│ │ ├── gosec │ │ └── ts-lint.json │ │ ├── bandit │ │ -│ │ └── go-lint.json │ │ │ │ └── py-lint.json │ │ -│ └───────────────────┘ └───────────────────┘ └───────────────────┘ │ -│ Output: static-analysis.json (aggregate) + go/ts/py-lint.json │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ PHASE 2: AST EXTRACTION │ -│ Runs for detected language: │ -│ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │ -│ │ Go │ │ TypeScript │ │ Python │ │ -│ │ ├── Functions │ │ ├── Functions │ │ ├── Functions │ │ -│ │ ├── Structs │ │ ├── Interfaces │ │ ├── Classes │ │ -│ │ ├── Interfaces │ │ ├── Types │ │ ├── Dataclasses │ │ -│ │ ├── Imports │ │ ├── Classes │ │ ├── Type hints │ │ -│ │ └── go-ast.json │ │ └── ts-ast.json │ │ └── py-ast.json │ │ -│ └───────────────────┘ └───────────────────┘ └───────────────────┘ │ -│ Output: semantic-diff.md │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ PHASE 3: CALL GRAPH ANALYSIS │ -│ Runs for detected language: │ -│ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │ -│ │ Go │ │ TypeScript │ │ Python │ │ -│ │ ├── callgraph │ │ ├── dep-cruiser │ │ ├── pyan3 │ │ -│ │ ├── guru │ │ ├── madge │ │ ├── custom ast │ │ -│ │ └── go-calls.json │ │ └── ts-calls.json │ │ └── py-calls.json │ │ -│ └───────────────────┘ └───────────────────┘ └───────────────────┘ │ -│ Output: call-graph.json + impact-summary.md │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ PHASE 4: DATA FLOW ANALYSIS │ -│ Runs for detected language: │ -│ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │ -│ │ Go │ │ TypeScript │ │ Python │ │ -│ │ ├── HTTP sources │ │ ├── req.* sources │ │ ├── Flask/Django │ │ -│ │ ├── DB sinks │ │ ├── DOM sinks │ │ │ request.* │ │ -│ │ ├── Nil tracking │ │ ├── Null tracking │ │ ├── SQLAlchemy │ │ -│ │ └── go-flow.json │ │ └── ts-flow.json │ │ ├── None tracking │ │ -│ └───────────────────┘ └───────────────────┘ │ └── py-flow.json │ │ -│ └───────────────────┘ │ -│ Output: data-flow.json + security-summary.md │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ PHASE 5: CONTEXT COMPILATION │ -│ scripts/ring:codereview/bin/compile-context │ -│ ├── Aggregates all phase outputs │ -│ ├── Generates reviewer-specific context files: │ -│ │ ├── context-ring:code-reviewer.md │ -│ │ ├── context-ring:security-reviewer.md │ -│ │ ├── context-ring:business-logic-reviewer.md │ -│ │ ├── context-ring:test-reviewer.md │ -│ │ └── context-ring:nil-safety-reviewer.md │ -│ └── Outputs: .ring/ring:codereview/ directory │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ EXISTING /ring:codereview FLOW │ -│ 5 reviewers dispatched in parallel (unchanged) │ -│ Each reviewer reads their context file + git diff │ -│ ├── ring:code-reviewer + context-ring:code-reviewer.md │ -│ ├── ring:security-reviewer + context-ring:security-reviewer.md │ -│ ├── ring:business-logic-reviewer + context-ring:business-logic-reviewer.md │ -│ ├── ring:test-reviewer + context-ring:test-reviewer.md │ -│ └── ring:nil-safety-reviewer + context-ring:nil-safety-reviewer.md │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 2. Directory Structure - -``` -ring/ -├── scripts/ -│ └── ring:codereview/ -│ ├── cmd/ # Go binaries (main packages) -│ │ ├── scope-detector/ # Phase 0 -│ │ │ └── main.go -│ │ ├── static-analysis/ # Phase 1 orchestrator -│ │ │ └── main.go -│ │ ├── ast-extractor/ # Phase 2 -│ │ │ └── main.go -│ │ ├── call-graph/ # Phase 3 -│ │ │ └── main.go -│ │ ├── data-flow/ # Phase 4 -│ │ │ └── main.go -│ │ ├── compile-context/ # Phase 5 -│ │ │ └── main.go -│ │ └── run-all/ # Main orchestrator -│ │ └── main.go -│ │ -│ ├── internal/ # Shared Go packages -│ │ ├── git/ # Git operations (diff, show, etc.) -│ │ │ └── git.go -│ │ ├── scope/ # Scope detection logic -│ │ │ └── scope.go -│ │ ├── lint/ # Linter integrations -│ │ │ ├── golangci.go -│ │ │ ├── staticcheck.go -│ │ │ ├── gosec.go -│ │ │ ├── eslint.go -│ │ │ ├── tsc.go -│ │ │ ├── ruff.go # Python: ruff -│ │ │ ├── mypy.go # Python: mypy -│ │ │ ├── pylint.go # Python: pylint -│ │ │ └── bandit.go # Python: bandit (security) -│ │ ├── ast/ # AST extraction -│ │ │ ├── golang.go # Uses go/ast, go/parser -│ │ │ ├── typescript.go # Shells out to ts-ast-extractor -│ │ │ └── python.go # Shells out to py-ast-extractor -│ │ ├── callgraph/ # Call graph analysis -│ │ │ ├── golang.go # Uses golang.org/x/tools -│ │ │ ├── typescript.go # Uses dependency-cruiser -│ │ │ └── python.go # Uses pyan3 or custom -│ │ ├── dataflow/ # Data flow / taint analysis -│ │ │ ├── golang.go -│ │ │ ├── typescript.go -│ │ │ └── python.go # Flask/Django/FastAPI patterns -│ │ ├── output/ # Output formatters -│ │ │ ├── json.go -│ │ │ └── markdown.go -│ │ └── cache/ # Caching utilities -│ │ └── cache.go -│ │ -│ ├── ts/ # TypeScript helpers (minimal) -│ │ ├── ast-extractor.ts # Called by Go binary -│ │ ├── package.json -│ │ └── tsconfig.json -│ │ -│ ├── py/ # Python helpers (minimal) -│ │ ├── ast_extractor.py # Called by Go binary -│ │ ├── call_graph.py # Uses ast module for call analysis -│ │ ├── data_flow.py # Framework-aware taint tracking -│ │ └── requirements.txt # Minimal deps (stdlib mostly) -│ │ -│ ├── go.mod # Go module for scripts -│ ├── go.sum -│ ├── Makefile # Build targets -│ └── install.sh # One-time setup -│ -├── .ring/ # Project-local Ring data (gitignored) -│ └── ring:codereview/ # Pre-analysis outputs -│ ├── scope.json -│ ├── static-analysis.json -│ ├── ast.json # Language-specific AST -│ ├── semantic-diff.md -│ ├── call-graph.json -│ ├── impact-summary.md -│ ├── data-flow.json -│ ├── security-summary.md -│ ├── context-ring:code-reviewer.md -│ ├── context-ring:security-reviewer.md -│ ├── context-ring:business-logic-reviewer.md -│ ├── context-ring:test-reviewer.md -│ ├── context-ring:nil-safety-reviewer.md -│ └── cache/ # Hash-based cache -│ └── ... -│ -└── .gitignore # Add .ring/ -``` - -**Note:** TypeScript and Python AST extraction require small helper scripts because Go cannot natively parse these languages. The Go binaries shell out to `ts/ast-extractor.ts` and `py/ast_extractor.py` and consume JSON output. - ---- - -## 3. Script Specifications - -### 3.1 Phase 0: Scope Detection - -**Binary:** `scripts/ring:codereview/bin/scope-detector` (source: `cmd/scope-detector`) - -**Input:** Git diff (staged, unstaged, or between refs) -**Output:** `scope.json` - -```json -{ - "base_ref": "main", - "head_ref": "HEAD", - "languages": ["go", "typescript"], - "files": { - "go": { - "modified": ["internal/handler/user.go", "internal/repo/user.go"], - "added": ["internal/service/notification.go"], - "deleted": [] - }, - "typescript": { - "modified": ["src/components/UserForm.tsx"], - "added": [], - "deleted": ["src/utils/deprecated.ts"] - } - }, - "stats": { - "total_files": 4, - "total_additions": 245, - "total_deletions": 32 - }, - "packages_affected": { - "go": ["internal/handler", "internal/repo", "internal/service"], - "typescript": ["src/components", "src/utils"] - } -} -``` - -**Logic:** -1. Run `git diff --name-status --stat` -2. Categorize files by extension (`.go`, `.ts`, `.tsx`) -3. Extract package/directory information -4. Return structured scope - ---- - -### 3.2 Phase 1: Static Analysis - -#### 3.2.1 Go Static Analysis - -**Binary:** `scripts/ring:codereview/bin/static-analysis` (source: `cmd/static-analysis`) - -**Tools Required:** -| Tool | Purpose | Install | -|------|---------|---------| -| `go vet` | Built-in correctness checks | (bundled) | -| `staticcheck` | Advanced static analysis | `go install honnef.co/go/tools/cmd/staticcheck@latest` | -| `golangci-lint` | Meta-linter (runs 50+ linters) | `brew install golangci-lint` | -| `gosec` | Security-focused analysis | `go install github.com/securego/gosec/v2/cmd/gosec@latest` | - -**Input:** Changed `.go` files from `scope.json` + derived package list -**Output:** `go-lint.json` - -```json -{ - "tool_versions": { - "go": "1.22.0", - "staticcheck": "2024.1", - "golangci-lint": "1.56.0", - "gosec": "2.19.0" - }, - "findings": [ - { - "tool": "staticcheck", - "rule": "SA1019", - "severity": "warning", - "file": "internal/handler/user.go", - "line": 45, - "column": 12, - "message": "grpc.Dial is deprecated: use grpc.NewClient instead", - "suggestion": "Replace grpc.Dial with grpc.NewClient", - "category": "deprecation" - }, - { - "tool": "gosec", - "rule": "G401", - "severity": "high", - "file": "internal/crypto/hash.go", - "line": 23, - "column": 8, - "message": "Use of weak cryptographic primitive", - "category": "security" - } - ], - "summary": { - "critical": 0, - "high": 1, - "warning": 3, - "info": 5 - } -} -``` - -**Logic:** -1. Determine affected packages from changed files (`go list` + path mapping) -2. Run each tool at package scope (not individual files) -3. Parse outputs and filter findings to changed files -4. Normalize to common schema -5. Deduplicate findings (same issue from multiple tools) - -#### 3.2.2 TypeScript Static Analysis - -**Script:** `internal/ring:lint/tsc.go`, `internal/ring:lint/eslint.go` - -**Tools Required:** -| Tool | Purpose | Install | -|------|---------|---------| -| `tsc` | Type checking | `npm install -g typescript` | -| `eslint` | Linting + style | Project-local | -| `npm audit` | Dependency vulnerabilities | (bundled) | - -**Input:** Changed `.ts`/`.tsx` files from `scope.json` + project config (`tsconfig.json`, `.eslintrc`) -**Output:** `ts-lint.json` (same schema as Go) - -**Notes:** -- Run `tsc --noEmit -p tsconfig.json` at project scope and filter diagnostics to changed files -- Run `eslint` on changed files; `npm audit` runs at project scope - -#### 3.2.3 Python Static Analysis - -**Script:** `internal/ring:lint/ruff.go`, `internal/ring:lint/mypy.go`, `internal/ring:lint/pylint.go`, `internal/ring:lint/bandit.go` - -**Tools Required:** -| Tool | Purpose | Install | -|------|---------|---------| -| `ruff` | Fast linter (replaces flake8, isort, pyupgrade) | `pip install ruff` | -| `mypy` | Static type checking | `pip install mypy` | -| `pylint` | Comprehensive linting | `pip install pylint` | -| `bandit` | Security-focused analysis | `pip install bandit` | - -**Input:** Changed `.py` files from `scope.json` + project config (`pyproject.toml`, `mypy.ini`, `ruff.toml`) -**Output:** `py-lint.json` - -```json -{ - "tool_versions": { - "python": "3.11.0", - "ruff": "0.4.0", - "mypy": "1.10.0", - "pylint": "3.2.0", - "bandit": "1.7.8" - }, - "findings": [ - { - "tool": "ruff", - "rule": "F841", - "severity": "warning", - "file": "app/services/user.py", - "line": 45, - "column": 5, - "message": "Local variable 'result' is assigned but never used", - "category": "unused" - }, - { - "tool": "mypy", - "rule": "arg-type", - "severity": "error", - "file": "app/handlers/api.py", - "line": 23, - "column": 15, - "message": "Argument 1 to 'process' has incompatible type 'str | None'; expected 'str'", - "category": "type" - }, - { - "tool": "bandit", - "rule": "B608", - "severity": "high", - "file": "app/db/queries.py", - "line": 34, - "column": 12, - "message": "Possible SQL injection via string concatenation", - "category": "security" - } - ], - "summary": { - "critical": 0, - "high": 1, - "warning": 5, - "info": 3 - } -} -``` - -**Logic:** -1. Run `ruff check --output-format=json` on changed files -2. Run `mypy --show-error-codes --output=json` at package/project scope, then filter diagnostics to changed files -3. Run `pylint --output-format=json` on changed files -4. Run `bandit -f json` on changed files -5. Normalize and deduplicate findings - ---- - -### 3.3 Phase 2: AST Extraction - -#### 3.3.1 Go AST Extractor - -**Binary:** `scripts/ring:codereview/bin/ast-extractor` (source: `cmd/ast-extractor`) - -**Why Go binary?** Native `go/ast` and `go/parser` packages provide accurate parsing. - -**Input:** Changed `.go` files + their base versions (from git) -**Output:** `go-ast.json` - -```json -{ - "functions": { - "modified": [ - { - "name": "CreateUser", - "file": "internal/handler/user.go", - "package": "handler", - "receiver": "*UserHandler", - "before": { - "signature": "func (h *UserHandler) CreateUser(ctx context.Context, req *CreateUserRequest) (*User, error)", - "params": [ - {"name": "ctx", "type": "context.Context"}, - {"name": "req", "type": "*CreateUserRequest"} - ], - "returns": [ - {"type": "*User"}, - {"type": "error"} - ], - "line_start": 45, - "line_end": 78 - }, - "after": { - "signature": "func (h *UserHandler) CreateUser(ctx context.Context, req *CreateUserRequest, opts ...CreateOption) (*User, error)", - "params": [ - {"name": "ctx", "type": "context.Context"}, - {"name": "req", "type": "*CreateUserRequest"}, - {"name": "opts", "type": "...CreateOption"} - ], - "returns": [ - {"type": "*User"}, - {"type": "error"} - ], - "line_start": 45, - "line_end": 92 - }, - "changes": ["added_param:opts", "body_expanded"] - } - ], - "added": [ - { - "name": "NotifyUser", - "file": "internal/service/notification.go", - "package": "service", - "signature": "func NotifyUser(ctx context.Context, userID string, event Event) error", - "line_start": 12, - "line_end": 45 - } - ], - "deleted": [] - }, - "types": { - "modified": [ - { - "name": "CreateUserRequest", - "kind": "struct", - "file": "internal/handler/types.go", - "before": { - "fields": [ - {"name": "Name", "type": "string", "tags": "`json:\"name\"`"}, - {"name": "Email", "type": "string", "tags": "`json:\"email\"`"} - ] - }, - "after": { - "fields": [ - {"name": "Name", "type": "string", "tags": "`json:\"name\"`"}, - {"name": "Email", "type": "string", "tags": "`json:\"email\"`"}, - {"name": "Metadata", "type": "map[string]any", "tags": "`json:\"metadata,omitempty\"`"} - ] - }, - "changes": ["added_field:Metadata"] - } - ], - "added": [], - "deleted": [] - }, - "interfaces": { - "modified": [], - "added": [], - "deleted": [] - }, - "imports": { - "added": [ - {"file": "internal/handler/user.go", "path": "github.com/company/lib/options"} - ], - "removed": [] - }, - "error_handling": { - "new_error_returns": [ - { - "function": "CreateUser", - "file": "internal/handler/user.go", - "line": 67, - "error_type": "validation", - "message": "return nil, ErrInvalidMetadata" - } - ], - "removed_error_checks": [] - } -} -``` - -**Logic:** -1. Parse current file with `go/parser` -2. Parse base version (from git) with `go/parser` -3. Compare ASTs: functions, types, interfaces, imports -4. Extract semantic changes (not line-level diff) - -#### 3.3.2 TypeScript AST Extractor - -**Script:** `ts/ast-extractor.ts` (Node script, called by Go binary) - -**Why TypeScript?** Native TypeScript compiler API provides accurate parsing. - -**Dependencies:** -```json -{ - "dependencies": { - "typescript": "^5.0.0" - } -} -``` - -**Input:** Changed `.ts`/`.tsx` files + their base versions -**Output:** `ts-ast.json` (similar schema to Go) - -```json -{ - "functions": { - "modified": [...], - "added": [...], - "deleted": [...] - }, - "types": { - "interfaces": {...}, - "type_aliases": {...}, - "enums": {...} - }, - "classes": { - "modified": [...], - "added": [...], - "deleted": [...] - }, - "imports_exports": { - "added_imports": [...], - "removed_imports": [...], - "added_exports": [...], - "removed_exports": [...] - }, - "react_components": { - "modified": [ - { - "name": "UserForm", - "file": "src/components/UserForm.tsx", - "props_before": [...], - "props_after": [...], - "hooks_used": ["useState", "useEffect", "useCallback"], - "changes": ["added_prop:onMetadataChange"] - } - ] - } -} -``` - -#### 3.3.3 Python AST Extractor - -**Script:** `py/ast_extractor.py` (Python script, called by Go binary) - -**Why Python?** Native `ast` module provides accurate parsing without dependencies. - -**Dependencies:** None (uses stdlib `ast` module) - -**Input:** Changed `.py` files + their base versions (from git) -**Output:** `py-ast.json` - -```json -{ - "functions": { - "modified": [ - { - "name": "create_user", - "file": "app/services/user.py", - "module": "app.services.user", - "is_async": true, - "decorators": ["@transactional"], - "before": { - "signature": "async def create_user(db: Session, data: CreateUserRequest) -> User", - "params": [ - {"name": "db", "type": "Session", "default": null}, - {"name": "data", "type": "CreateUserRequest", "default": null} - ], - "returns": "User", - "line_start": 45, - "line_end": 78 - }, - "after": { - "signature": "async def create_user(db: Session, data: CreateUserRequest, *, notify: bool = True) -> User", - "params": [ - {"name": "db", "type": "Session", "default": null}, - {"name": "data", "type": "CreateUserRequest", "default": null}, - {"name": "notify", "type": "bool", "default": "True", "keyword_only": true} - ], - "returns": "User", - "line_start": 45, - "line_end": 92 - }, - "changes": ["added_param:notify", "body_expanded"] - } - ], - "added": [...], - "deleted": [...] - }, - "classes": { - "modified": [ - { - "name": "UserService", - "file": "app/services/user.py", - "bases": ["BaseService"], - "is_dataclass": false, - "before": { - "methods": ["__init__", "create", "update", "delete"], - "attributes": ["db", "cache"] - }, - "after": { - "methods": ["__init__", "create", "update", "delete", "notify"], - "attributes": ["db", "cache", "notifier"] - }, - "changes": ["added_method:notify", "added_attr:notifier"] - } - ], - "added": [], - "deleted": [] - }, - "dataclasses": { - "modified": [ - { - "name": "CreateUserRequest", - "file": "app/schemas/user.py", - "before": { - "fields": [ - {"name": "name", "type": "str"}, - {"name": "email", "type": "str"} - ] - }, - "after": { - "fields": [ - {"name": "name", "type": "str"}, - {"name": "email", "type": "str"}, - {"name": "metadata", "type": "dict[str, Any]", "default": "field(default_factory=dict)"} - ] - }, - "changes": ["added_field:metadata"] - } - ] - }, - "imports": { - "added": [ - {"file": "app/services/user.py", "module": "app.notifications", "names": ["Notifier"]} - ], - "removed": [] - }, - "type_aliases": { - "added": [], - "modified": [], - "deleted": [] - } -} -``` - -**Logic:** -1. Parse current file with `ast.parse()` -2. Parse base version (from git) with `ast.parse()` -3. Walk AST to extract: functions, classes, dataclasses, imports, type aliases -4. Compare and identify changes -5. Output JSON to stdout for Go binary to consume - -#### 3.3.4 Semantic Diff Renderer - -**Script:** `internal/output/markdown.go` - -**Input:** `go-ast.json` OR `ts-ast.json` OR `py-ast.json` (based on detected language) -**Output:** `semantic-diff.md` - -```markdown -# Semantic Change Summary - -## Go Changes - -### Functions Modified (2) - -#### `handler.CreateUser` -**File:** `internal/handler/user.go:45-92` -**Change:** Signature modified - -```diff -- func (h *UserHandler) CreateUser(ctx context.Context, req *CreateUserRequest) (*User, error) -+ func (h *UserHandler) CreateUser(ctx context.Context, req *CreateUserRequest, opts ...CreateOption) (*User, error) -``` - -**Impact:** -- Added variadic parameter `opts ...CreateOption` -- Body expanded (+14 lines) -- New error return path at line 67 - -#### `repo.SaveUser` -**File:** `internal/repo/user.go:23-45` -**Change:** Body modified (logic change) - -### Functions Added (1) - -#### `service.NotifyUser` -**File:** `internal/service/notification.go:12-45` -**Purpose:** New function (requires review) - -### Types Modified (1) - -#### `handler.CreateUserRequest` -**File:** `internal/handler/types.go` - -| Field | Before | After | -|-------|--------|-------| -| Metadata | - | `map[string]any` (added) | - ---- - -## TypeScript Changes - -### Components Modified (1) - -#### `UserForm` -**File:** `src/components/UserForm.tsx` -**Props Changed:** -- Added: `onMetadataChange?: (metadata: Record) => void` - -**Hooks Used:** `useState`, `useEffect`, `useCallback` -``` - ---- - -### 3.4 Phase 3: Call Graph Analysis - -#### 3.4.1 Go Call Graph - -**Binary:** `scripts/ring:codereview/bin/call-graph` (source: `cmd/call-graph`) - -**Tools Required:** -| Tool | Purpose | Install | -|------|---------|---------| -| `callgraph` | Static call graph | `go install golang.org/x/tools/cmd/callgraph@latest` | -| `guru` | Code navigation | `go install golang.org/x/tools/cmd/guru@latest` | - -**Input:** Modified functions from `go-ast.json` -**Output:** `go-calls.json` - -```json -{ - "modified_functions": [ - { - "function": "handler.CreateUser", - "file": "internal/handler/user.go", - "callers": [ - { - "function": "handler.UserHandler.ServeHTTP", - "file": "internal/handler/router.go", - "line": 89, - "call_site": "h.CreateUser(ctx, req)" - }, - { - "function": "batch.BatchImporter.ProcessUsers", - "file": "internal/batch/importer.go", - "line": 156, - "call_site": "h.CreateUser(ctx, &req)" - } - ], - "callees": [ - { - "function": "validator.ValidateCreateUserRequest", - "file": "internal/validator/user.go", - "line": 12 - }, - { - "function": "repo.UserRepository.Save", - "file": "internal/repo/user.go", - "line": 67 - }, - { - "function": "events.Publisher.Publish", - "file": "internal/events/publisher.go", - "line": 34 - } - ], - "test_coverage": [ - { - "test_function": "TestCreateUser", - "file": "internal/handler/user_test.go", - "line": 23 - }, - { - "test_function": "TestCreateUserValidation", - "file": "internal/handler/user_test.go", - "line": 89 - } - ] - } - ], - "impact_analysis": { - "direct_callers": 2, - "transitive_callers": 5, - "affected_tests": 2, - "affected_packages": ["handler", "batch"] - } -} -``` - -**Logic:** -1. Build call graph for affected packages only: `callgraph -algo=cha ` -2. Enforce a time budget (target <30s); if exceeded, emit partial results with a warning -3. For each modified function, find: - - Direct callers (who calls this function) - - Direct callees (what this function calls) - - Transitive callers (full upstream chain) -4. Cross-reference with test files - -#### 3.4.2 TypeScript Call Graph - -**Script:** `internal/callgraph/typescript.go` (calls `dependency-cruiser`) - -**Tools Required:** -| Tool | Purpose | Install | -|------|---------|---------| -| `dependency-cruiser` | Dependency analysis | `npm install -g dependency-cruiser` | -| `madge` | Circular dependency detection | `npm install -g madge` | - -**Input:** Modified functions/components from `ts-ast.json` -**Output:** `ts-calls.json` (similar schema) - -#### 3.4.3 Python Call Graph - -**Script:** `internal/callgraph/python.go` (calls `py/call_graph.py`) - -**Tools Required:** -| Tool | Purpose | Install | -|------|---------|---------| -| `pyan3` | Static call graph generator | `pip install pyan3` | -| Custom `py/call_graph.py` | AST-based call analysis | (bundled) | - -**Input:** Modified functions from `py-ast.json` -**Output:** `py-calls.json` - -```json -{ - "modified_functions": [ - { - "function": "app.services.user.create_user", - "file": "app/services/user.py", - "callers": [ - { - "function": "app.api.routes.user.create_user_endpoint", - "file": "app/api/routes/user.py", - "line": 45, - "call_site": "await user_service.create_user(db, data)" - }, - { - "function": "app.tasks.batch.import_users", - "file": "app/tasks/batch.py", - "line": 78, - "call_site": "await create_user(session, user_data)" - } - ], - "callees": [ - { - "function": "app.validators.user.validate_user_data", - "file": "app/validators/user.py", - "line": 12 - }, - { - "function": "app.repositories.user.UserRepository.save", - "file": "app/repositories/user.py", - "line": 56 - } - ], - "test_coverage": [ - { - "test_function": "test_create_user", - "file": "tests/services/test_user.py", - "line": 23 - } - ] - } - ], - "impact_analysis": { - "direct_callers": 2, - "transitive_callers": 4, - "affected_tests": 1, - "affected_modules": ["app.api.routes.user", "app.tasks.batch"] - } -} -``` - -**Logic:** -1. Use `pyan3` to generate static call graph: `pyan3 --dot app/` -2. Parse into internal representation -3. For each modified function, find callers and callees -4. Cross-reference with test files (`tests/`, `*_test.py`) - -#### 3.4.4 Impact Summary Renderer - -**Input:** `go-calls.json` OR `ts-calls.json` OR `py-calls.json` (based on detected language) -**Output:** `impact-summary.md` - -```markdown -# Impact Analysis - -## High Impact Changes - -### `handler.CreateUser` (Go) -**Risk Level:** HIGH (2 direct callers, 5 transitive) - -**Direct Callers (signature change affects these):** -1. `handler.UserHandler.ServeHTTP` - `internal/handler/router.go:89` -2. `batch.BatchImporter.ProcessUsers` - `internal/batch/importer.go:156` - -**Callees (this function depends on):** -1. `validator.ValidateCreateUserRequest` -2. `repo.UserRepository.Save` -3. `events.Publisher.Publish` - -**Test Coverage:** -- ✅ `TestCreateUser` - `internal/handler/user_test.go:23` -- ✅ `TestCreateUserValidation` - `internal/handler/user_test.go:89` -- ⚠️ New parameter `opts` may need additional test cases - ---- - -## Low Impact Changes - -### `service.NotifyUser` (Go) -**Risk Level:** LOW (new function, no existing callers) - -**Note:** New function - verify it's called somewhere or mark as dead code. -``` - ---- - -### 3.5 Phase 4: Data Flow Analysis - -#### 3.5.1 Go Data Flow - -**Binary:** `scripts/ring:codereview/bin/data-flow` (source: `cmd/data-flow`) - -**Why Go binary?** Complex AST traversal for taint tracking. - -**Concepts:** -- **Sources:** Where untrusted data enters (HTTP params, env vars, DB reads, file reads) -- **Sinks:** Where data exits (DB writes, HTTP responses, logs, file writes, exec) -- **Sanitizers:** Validation/encoding functions that make data safe -- **Taint:** Data that flows from source without passing through sanitizer - -**Input:** Changed `.go` files -**Output:** `go-flow.json` - -```json -{ - "flows": [ - { - "id": "flow-1", - "source": { - "type": "http_request", - "subtype": "body", - "variable": "req", - "file": "internal/handler/user.go", - "line": 48, - "expression": "json.NewDecoder(r.Body).Decode(&req)" - }, - "path": [ - { - "step": 1, - "file": "internal/handler/user.go", - "line": 52, - "expression": "req.Name", - "operation": "field_access" - }, - { - "step": 2, - "file": "internal/handler/user.go", - "line": 55, - "expression": "validator.ValidateName(req.Name)", - "operation": "sanitizer", - "sanitizer_type": "validation" - }, - { - "step": 3, - "file": "internal/repo/user.go", - "line": 34, - "expression": "db.Exec(query, name)", - "operation": "sink_usage" - } - ], - "sink": { - "type": "database", - "subtype": "write", - "file": "internal/repo/user.go", - "line": 34, - "expression": "db.Exec(query, name)" - }, - "sanitized": true, - "risk": "low", - "notes": "Input validated before DB write" - }, - { - "id": "flow-2", - "source": { - "type": "http_request", - "subtype": "query_param", - "variable": "userID", - "file": "internal/handler/user.go", - "line": 23, - "expression": "r.URL.Query().Get(\"user_id\")" - }, - "path": [ - { - "step": 1, - "file": "internal/handler/user.go", - "line": 25, - "expression": "repo.GetUser(ctx, userID)", - "operation": "function_call" - } - ], - "sink": { - "type": "database", - "subtype": "read", - "file": "internal/repo/user.go", - "line": 12, - "expression": "db.Query(\"SELECT * FROM users WHERE id = $1\", userID)" - }, - "sanitized": false, - "risk": "medium", - "notes": "Query param used in DB query without UUID validation" - } - ], - "nil_sources": [ - { - "variable": "user", - "file": "internal/handler/user.go", - "line": 67, - "expression": "user, err := repo.GetUser(ctx, id)", - "checked": true, - "check_line": 68, - "check_expression": "if err != nil || user == nil" - }, - { - "variable": "config", - "file": "internal/service/notification.go", - "line": 23, - "expression": "config := os.Getenv(\"NOTIFICATION_CONFIG\")", - "checked": false, - "risk": "high", - "notes": "Environment variable used without empty check" - } - ], - "summary": { - "total_flows": 2, - "sanitized_flows": 1, - "unsanitized_flows": 1, - "high_risk": 1, - "medium_risk": 1, - "low_risk": 0, - "nil_risks": 1 - } -} -``` - -**Source Types to Track:** -| Source Type | Examples | -|-------------|----------| -| `http_request` | `r.Body`, `r.URL.Query()`, `r.Header`, `r.FormValue()` | -| `environment` | `os.Getenv()`, `os.LookupEnv()` | -| `database` | `db.Query()`, `db.QueryRow()` | -| `file` | `os.ReadFile()`, `io.ReadAll()` | -| `external_api` | `http.Get()`, `client.Do()` | - -**Sink Types to Track:** -| Sink Type | Examples | -|-----------|----------| -| `database` | `db.Exec()`, `db.Query()` (with interpolation) | -| `http_response` | `w.Write()`, `json.NewEncoder().Encode()` | -| `logging` | `log.Printf()`, `logger.Info()` | -| `file` | `os.WriteFile()`, `io.WriteString()` | -| `exec` | `exec.Command()`, `os.StartProcess()` | - -#### 3.5.2 TypeScript Data Flow - -**Script:** `internal/dataflow/typescript.go` (calls `ts/data-flow.ts`) - -**Input:** Changed `.ts`/`.tsx` files -**Output:** `ts-flow.json` - -**Source Types:** -| Source Type | Examples | -|-------------|----------| -| `http_request` | `req.body`, `req.params`, `req.query`, `req.headers` | -| `environment` | `process.env.*` | -| `user_input` | `event.target.value`, form inputs | -| `url_params` | `useParams()`, `searchParams` | -| `local_storage` | `localStorage.getItem()` | - -**Sink Types:** -| Sink Type | Examples | -|-----------|----------| -| `database` | ORM calls, raw queries | -| `http_response` | `res.json()`, `res.send()` | -| `dom_manipulation` | `innerHTML`, `dangerouslySetInnerHTML` | -| `external_api` | `fetch()`, `axios.*` | -| `eval` | `eval()`, `Function()` | - -#### 3.5.3 Python Data Flow - -**Script:** `internal/dataflow/python.go` (calls `py/data_flow.py`) - -**Input:** Changed `.py` files -**Output:** `py-flow.json` - -**Framework Detection:** -The Python data flow analyzer auto-detects the web framework: -- **Flask:** `from flask import request`, `request.args`, `request.form`, `request.json` -- **Django:** `request.GET`, `request.POST`, `request.body` -- **FastAPI:** `Query()`, `Body()`, `Path()` parameters -- **Starlette:** `request.query_params`, `request.form()` - -**Source Types:** -| Source Type | Framework | Examples | -|-------------|-----------|----------| -| `http_request` | Flask | `request.args.get()`, `request.form`, `request.json` | -| `http_request` | Django | `request.GET`, `request.POST`, `request.body` | -| `http_request` | FastAPI | Function parameters with `Query()`, `Body()`, `Path()` | -| `environment` | All | `os.environ.get()`, `os.getenv()` | -| `database` | All | `session.query()`, `Model.objects.get()`, ORM reads | -| `file` | All | `open()`, `Path.read_text()` | -| `user_input` | All | `input()`, CLI arguments | - -**Sink Types:** -| Sink Type | Examples | -|-----------|----------| -| `database` | `session.execute()`, `cursor.execute()`, `Model.save()` | -| `http_response` | `jsonify()`, `HttpResponse()`, `return {...}` (FastAPI) | -| `file` | `open(..., 'w')`, `Path.write_text()` | -| `subprocess` | `subprocess.run()`, `os.system()`, `os.popen()` | -| `eval` | `eval()`, `exec()`, `compile()` | -| `template` | `render_template()`, `Template().render()` (XSS risk) | -| `deserialization` | `pickle.loads()`, `yaml.load()` (insecure deserialize) | - -**None/Optional Tracking:** -| Pattern | Risk | -|---------|------| -| `dict.get()` without default | Returns `None` if key missing | -| `Optional[T]` without check | May be `None` | -| `or None` in expression | Explicit `None` possibility | -| `getattr(..., None)` | May return `None` | -| `.first()` ORM queries | Returns `None` if no match | - -**Output:** `py-flow.json` (same schema as Go/TS) - -```json -{ - "flows": [ - { - "id": "flow-1", - "source": { - "type": "http_request", - "subtype": "query_param", - "framework": "fastapi", - "variable": "user_id", - "file": "app/api/routes/user.py", - "line": 23, - "expression": "user_id: str = Query(...)" - }, - "path": [ - { - "step": 1, - "file": "app/api/routes/user.py", - "line": 25, - "expression": "user_service.get_user(user_id)", - "operation": "function_call" - } - ], - "sink": { - "type": "database", - "subtype": "read", - "file": "app/repositories/user.py", - "line": 34, - "expression": "session.execute(select(User).where(User.id == user_id))" - }, - "sanitized": false, - "risk": "medium", - "notes": "Query param used in DB query - verify UUID validation in Pydantic model" - } - ], - "none_sources": [ - { - "variable": "user", - "file": "app/services/user.py", - "line": 45, - "expression": "user = session.query(User).filter_by(id=user_id).first()", - "checked": true, - "check_line": 46, - "check_expression": "if user is None:" - }, - { - "variable": "config_value", - "file": "app/config.py", - "line": 12, - "expression": "config_value = os.environ.get('API_KEY')", - "checked": false, - "risk": "high", - "notes": "Environment variable may be None" - } - ], - "summary": { - "total_flows": 1, - "sanitized_flows": 0, - "unsanitized_flows": 1, - "high_risk": 1, - "medium_risk": 1, - "low_risk": 0, - "none_risks": 1 - } -} -``` - -#### 3.5.4 Security Summary Renderer - -**Input:** `go-flow.json` OR `ts-flow.json` OR `py-flow.json` (based on detected language) -**Output:** `security-summary.md` - -```markdown -# Security Analysis - -## 🔴 High Risk Findings - -### Unvalidated Environment Variable -**File:** `internal/service/notification.go:23` -**Issue:** Environment variable used without validation - -```go -config := os.Getenv("NOTIFICATION_CONFIG") // No empty check -``` - -**Recommendation:** Add validation before use: -```go -config := os.Getenv("NOTIFICATION_CONFIG") -if config == "" { - return fmt.Errorf("NOTIFICATION_CONFIG not set") -} -``` - ---- - -## 🟡 Medium Risk Findings - -### Query Parameter Without UUID Validation -**File:** `internal/handler/user.go:23` -**Flow:** HTTP query param → Database query - -```go -userID := r.URL.Query().Get("user_id") // No UUID validation -// ... -db.Query("SELECT * FROM users WHERE id = $1", userID) -``` - -**Recommendation:** Validate UUID format before use: -```go -userID := r.URL.Query().Get("user_id") -if _, err := uuid.Parse(userID); err != nil { - return ErrInvalidUserID -} -``` - ---- - -## ✅ Properly Sanitized Flows - -### User Name Input -**File:** `internal/handler/user.go:48-55` -**Flow:** HTTP body → Validator → Database - -✅ Input validated via `validator.ValidateName()` before DB write. - ---- - -## Nil Safety Concerns - -| Variable | File | Line | Checked? | Risk | -|----------|------|------|----------|------| -| `user` | handler/user.go | 67 | ✅ Yes | Low | -| `config` | service/notification.go | 23 | ❌ No | High | -``` - ---- - -### 3.6 Phase 5: Context Compilation - -**Binary:** `scripts/ring:codereview/bin/compile-context` (source: `cmd/compile-context`) - -**Purpose:** Aggregate all analysis outputs into reviewer-specific context files. - -**Input:** All phase outputs -**Output:** Reviewer context files in `.ring/ring:codereview/` - -#### Context File Templates - -**`context-ring:code-reviewer.md`:** -```markdown -# Pre-Analysis Context: Code Quality - -## Static Analysis Findings (3 issues) - -| Severity | Tool | File | Line | Message | -|----------|------|------|------|---------| -| warning | staticcheck | handler/user.go | 45 | SA1019: grpc.Dial is deprecated | -| warning | golangci-lint | repo/user.go | 23 | ineffassign: variable assigned but never used | -| info | staticcheck | service/notification.go | 12 | S1000: single-case select can be simplified | - -## Semantic Changes - -[Embedded content from semantic-diff.md] - -## Focus Areas - -Based on analysis, pay special attention to: -1. **Signature change in `CreateUser`** - New variadic parameter may affect callers -2. **New error return path** - Line 67 in handler/user.go -3. **Deprecated API usage** - grpc.Dial needs update -``` - -**`context-ring:security-reviewer.md`:** -```markdown -# Pre-Analysis Context: Security - -## Security Scanner Findings (1 issue) - -| Severity | Tool | Rule | File | Line | Message | -|----------|------|------|------|------|---------| -| high | gosec | G401 | crypto/hash.go | 23 | Use of weak cryptographic primitive | - -## Data Flow Analysis - -[Embedded content from security-summary.md] - -## Focus Areas - -Based on analysis, pay special attention to: -1. **Unvalidated env var** - `NOTIFICATION_CONFIG` at service/notification.go:23 -2. **Query param validation** - `user_id` at handler/user.go:23 -3. **Crypto weakness** - crypto/hash.go:23 -``` - -**`context-ring:business-logic-reviewer.md`:** -```markdown -# Pre-Analysis Context: Business Logic - -## Impact Analysis - -[Embedded content from impact-summary.md] - -## Semantic Changes - -### Functions with Logic Changes - -1. **`handler.CreateUser`** - Added options parameter, new validation path -2. **`repo.SaveUser`** - Body modified (requires logic review) - -## Focus Areas - -Based on analysis, pay special attention to: -1. **New parameter handling** - How are options processed? -2. **Caller impact** - 2 direct callers affected by signature change -3. **New function** - `service.NotifyUser` - verify business requirements -``` - -**`context-ring:test-reviewer.md`:** -```markdown -# Pre-Analysis Context: Testing - -## Test Coverage for Modified Code - -| Function | File | Tests | Status | -|----------|------|-------|--------| -| `handler.CreateUser` | handler/user.go | 2 tests | ⚠️ May need update for new param | -| `repo.SaveUser` | repo/user.go | 1 test | ✅ Covered | -| `service.NotifyUser` | service/notification.go | 0 tests | ❌ New, needs tests | - -## Focus Areas - -1. **New function without tests** - `NotifyUser` needs test coverage -2. **New parameter** - `CreateUser` opts parameter may need test cases -3. **New error path** - Line 67 error return needs negative test -``` - -**`context-ring:nil-safety-reviewer.md`:** -```markdown -# Pre-Analysis Context: Nil Safety - -## Nil Source Analysis - -| Variable | File | Line | Checked? | Risk | -|----------|------|------|----------|------| -| `user` | handler/user.go | 67 | ✅ Yes | Low | -| `config` | service/notification.go | 23 | ❌ No | High | - -## Data Flow Nil Risks - -[Relevant sections from data-flow analysis] - -## Focus Areas - -1. **Unchecked env var** - `config` at service/notification.go:23 -2. **New function returns** - Verify nil checks for `NotifyUser` return values -``` - ---- - -## 4. Integration with /ring:codereview - -### 4.1 Modified Reviewer Prompts - -Each reviewer's prompt in `ring:codereview.md` gets updated to include: - -```markdown -## Pre-Analysis Context - -Before reviewing, read the pre-analysis context: -1. Run: `cat .ring/ring:codereview/context-{reviewer-name}.md` -2. Use findings to guide your review -3. Verify pre-analysis findings (tools can miss context) -4. Add findings that tools missed -``` - -### 4.2 Execution Flow - -``` -User runs: /ring:codereview - -1. Orchestrator runs: scripts/ring:codereview/bin/run-all - ├── Phase 0: bin/scope-detector → scope.json - ├── Phase 1: static-analysis (detected language only) - ├── Phase 2: ast-extractor (detected language only) - ├── Phase 3: call-graph (detected language only) - ├── Phase 4: data-flow (detected language only) - └── Phase 5: bin/compile-context → .ring/ring:codereview/ - -2. Orchestrator dispatches 5 reviewers (parallel) - Each reviewer: - ├── Reads: .ring/ring:codereview/context-{name}.md - ├── Reads: git diff - └── Produces: findings - -3. Orchestrator aggregates findings -``` - -### 4.3 Caching Strategy - -**Problem:** Re-running analysis on unchanged files wastes time. - -**Solution:** Hash-based caching in `.ring/ring:codereview/cache/` - -```bash -# Cache structure -.ring/ring:codereview/cache/ -├── scope-{hash}.json # Cached scope -├── go-lint-{hash}.json # Cached Go lint -├── ts-lint-{hash}.json # Cached TS lint -├── go-ast-{hash}.json # Cached Go AST -└── ... -``` - -**Cache key:** SHA256 of (file content + tool version + tool flags + relevant config files) - -**Invalidation:** -- File content changes → re-analyze -- Tool version changes → re-analyze -- Config changes (e.g., .golangci.yml, tsconfig.json, .eslintrc, pyproject.toml, mypy.ini, ruff.toml) → re-analyze - ---- - -## 5. Tool Requirements - -### 5.1 Go Tools - -| Tool | Version | Purpose | Installation | -|------|---------|---------|--------------| -| `go` | ≥1.21 | Compiler, vet | System | -| `staticcheck` | ≥2024.1 | Static analysis | `go install honnef.co/go/tools/cmd/staticcheck@latest` | -| `golangci-lint` | ≥1.56 | Meta-linter | `brew install golangci-lint` | -| `gosec` | ≥2.19 | Security scanner | `go install github.com/securego/gosec/v2/cmd/gosec@latest` | -| `callgraph` | latest | Call graph | `go install golang.org/x/tools/cmd/callgraph@latest` | -| `guru` | latest | Code navigation | `go install golang.org/x/tools/cmd/guru@latest` | - -### 5.2 TypeScript Tools - -| Tool | Version | Purpose | Installation | -|------|---------|---------|--------------| -| `node` | ≥18 | Runtime | System | -| `typescript` | ≥5.0 | Type checking, AST | `npm install -g typescript` | -| `eslint` | ≥8.0 | Linting | Project-local | -| `dependency-cruiser` | ≥16.0 | Dependency analysis | `npm install -g dependency-cruiser` | -| `madge` | ≥7.0 | Circular deps | `npm install -g madge` | - -### 5.3 Python Tools - -| Tool | Version | Purpose | Installation | -|------|---------|---------|--------------| -| `python` | ≥3.10 | Runtime, AST (stdlib) | System | -| `ruff` | ≥0.4.0 | Fast linter (flake8 + isort + more) | `pip install ruff` | -| `mypy` | ≥1.10 | Static type checking | `pip install mypy` | -| `pylint` | ≥3.0 | Comprehensive linting | `pip install pylint` | -| `bandit` | ≥1.7 | Security scanner | `pip install bandit` | -| `pyan3` | ≥1.2 | Call graph analysis | `pip install pyan3` | - -**Note:** Python's `ast` module (stdlib) is used for AST extraction - no additional dependencies needed. - -### 5.4 Installation Script - -**Script:** `scripts/ring:codereview/install.sh` - -```bash -#!/bin/bash -set -e - -# Installs all required tools for ring:codereview pre-analysis -# Run: ./scripts/ring:codereview/install.sh [go|ts|py|all] - -INSTALL_TARGET="${1:-all}" - -install_go_tools() { - echo "=== Installing Go tools ===" - go install honnef.co/go/tools/cmd/staticcheck@latest - go install github.com/securego/gosec/v2/cmd/gosec@latest - go install golang.org/x/tools/cmd/callgraph@latest - go install golang.org/x/tools/cmd/guru@latest - - # golangci-lint (platform-specific) - if [[ "$OSTYPE" == "darwin"* ]]; then - brew install golangci-lint || brew upgrade golangci-lint - else - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin - fi - echo "✅ Go tools installed" -} - -install_ts_tools() { - echo "=== Installing TypeScript tools ===" - npm install -g typescript dependency-cruiser madge - - # Install local TS helper deps - cd "$(dirname "$0")/ts" - npm install - cd - - echo "✅ TypeScript tools installed" -} - -install_py_tools() { - echo "=== Installing Python tools ===" - pip install --upgrade ruff mypy pylint bandit pyan3 - - # Verify Python helper has no external deps (uses stdlib ast) - python3 -c "import ast; print('Python ast module: OK')" - echo "✅ Python tools installed" -} - -build_binaries() { - echo "=== Building Go analysis binaries ===" - cd "$(dirname "$0")" - go build -o bin/scope-detector ./cmd/scope-detector - go build -o bin/static-analysis ./cmd/static-analysis - go build -o bin/ast-extractor ./cmd/ast-extractor - go build -o bin/call-graph ./cmd/call-graph - go build -o bin/data-flow ./cmd/data-flow - go build -o bin/compile-context ./cmd/compile-context - go build -o bin/run-all ./cmd/run-all - cd - - echo "✅ Binaries built" -} - -case "$INSTALL_TARGET" in - go) - install_go_tools - ;; - ts) - install_ts_tools - ;; - py) - install_py_tools - ;; - all) - install_go_tools - install_ts_tools - install_py_tools - build_binaries - ;; - *) - echo "Usage: $0 [go|ts|py|all]" - exit 1 - ;; -esac - -echo "" -echo "🎉 Installation complete!" -echo "Run 'scripts/ring:codereview/verify.sh' to verify installation." -``` - ---- - -## 6. Error Handling - -### 6.1 Graceful Degradation - -If a tool fails, the pipeline continues with reduced context: - -| Failure | Impact | Fallback | -|---------|--------|----------| -| `staticcheck` fails | No static findings | Continue with `go vet` only | -| `ast-extractor` fails | No semantic diff | Reviewers use raw git diff | -| `call-graph` fails | No impact analysis | Reviewers explore manually | -| `data-flow` fails | No taint analysis | Security reviewer uses heuristics | - -### 6.2 Error Reporting - -Each script outputs errors to `stderr` and includes in context: - -```json -{ - "success": false, - "error": "staticcheck: command not found", - "partial_results": true, - "available_data": ["go-vet.json"] -} -``` - -Reviewers see warnings: - -```markdown -## ⚠️ Pre-Analysis Warnings - -Some analysis tools failed. Manual verification recommended: -- staticcheck: not installed (run `scripts/install-tools.sh`) -``` - ---- - -## 7. Future Enhancements (Out of Scope for v1) - -| Enhancement | Description | Complexity | Phase | -|-------------|-------------|------------|-------| -| **Test coverage integration** | Map changes to coverage reports | Medium | v2 | -| **Historical pattern learning** | Learn from past review findings | High | v2 | -| **Custom rule definitions** | Project-specific lint rules | Medium | v2 | -| **IDE integration** | VSCode extension for pre-commit | High | v3 | -| **CI integration** | Run analysis in GitHub Actions | Low | v1.1 | -| **Incremental analysis** | Only analyze changed lines | High | v2 | -| **Cross-language flow** | Track data across Go ↔ TS ↔ Python | Very High | v3 | - ---- - -## 8. Success Metrics - -| Metric | Target | Measurement | -|--------|--------|-------------| -| **Pre-catch rate** | 40% of issues found by scripts before AI review | Compare script findings vs reviewer findings | -| **False positive rate** | <10% of script findings are wrong | Track dismissed findings | -| **Analysis time** | <30 seconds for typical PR | Time `run-all.sh` | -| **Reviewer efficiency** | 20% fewer tokens used per review | Compare context sizes | -| **Issue severity accuracy** | 80% severity match with human judgment | Sample review | - ---- - -## 9. Phasing Summary - -| Phase | Components | Effort | Value | -|-------|------------|--------|-------| -| **Phase 1** | Scope detection, static analysis (Go + TS + Python) | 4-5 days | High (immediate lint integration) | -| **Phase 2** | AST extraction (Go + TS + Python), semantic diff | 6-8 days | High (structured understanding) | -| **Phase 3** | Call graph (Go + TS + Python), impact analysis | 5-6 days | Medium (blast radius awareness) | -| **Phase 4** | Data flow (Go + TS + Python), security summary | 7-9 days | High (security pre-screening) | -| **Phase 5** | Context compilation, reviewer integration | 3-4 days | Critical (ties everything together) | -| **Phase 6** | Caching, error handling, polish | 2-3 days | Medium (performance, reliability) | - -**Total estimated effort:** 27-35 days - -### Per-Language Effort Breakdown - -| Component | Go | TypeScript | Python | -|-----------|----|-----------:|-------:| -| Static Analysis | 1 day | 1 day | 1.5 days | -| AST Extraction | 2 days | 2 days | 2 days | -| Call Graph | 1.5 days | 1.5 days | 2 days | -| Data Flow | 2 days | 2 days | 3 days | -| **Subtotal** | 6.5 days | 6.5 days | 8.5 days | - -**Note:** Python data flow is more complex due to framework detection (Flask/Django/FastAPI) and dynamic typing. - ---- - -## 10. Resolved Questions - -| Question | Decision | -|----------|----------| -| Script language | Go binaries (team expertise, native AST) | -| Context file format | Markdown for reviewers (human-readable) | -| Cache location | `.ring/ring:codereview/cache/` (project-local) | -| CI mode | Not in v1 (local dev focus) | -| Monorepo support | Option B: detect & adapt, warn on complexity | - -## 11. Remaining Open Questions - -1. **TS AST helper:** Bundle pre-built JS or require `npx ts-node`? -2. **Binary distribution:** Build per-platform or require Go toolchain? -3. **Python framework detection:** Auto-detect or require config? - ---- - -## 12. Project Assumptions - -Based on user input, the pipeline assumes: - -1. **Single-language projects** - A project is Go OR TypeScript OR Python, not mixed -2. **Simple module structure** - One `go.mod`, one root `package.json`, or standard Python layout -3. **Standard layouts:** - - Go: `internal/`, `cmd/`, `pkg/` - - TypeScript: `src/` - - Python: `app/`, `src/`, or flat layout with `pyproject.toml`/`setup.py` - -**Graceful degradation on complexity:** -- Multiple `go.mod` detected → Warn, analyze files individually -- Workspace files detected (`go.work`, `pnpm-workspace.yaml`) → Warn, analyze changed files only -- Multiple `pyproject.toml` detected → Warn, analyze changed files only -- Mixed languages detected → Warn, run analysis for dominant language only diff --git a/docs/pre-dev/ring-continuity/api-design.md b/docs/pre-dev/ring-continuity/api-design.md deleted file mode 100644 index 34128b45..00000000 --- a/docs/pre-dev/ring-continuity/api-design.md +++ /dev/null @@ -1,1310 +0,0 @@ -# Ring Continuity - API Design (Gate 4) - -> **Version:** 1.0 -> **Date:** 2026-01-12 -> **Status:** Draft -> **Based on:** TRD v1.0 (docs/pre-dev/ring-continuity/trd.md) - ---- - -## Executive Summary - -This document defines **protocol-agnostic contracts** for the 6 components in Ring Continuity. All interfaces use **data transfer objects (DTOs)** with explicit schemas, **result types** for error handling, and **command/query separation** for clarity. - -**Design Principles:** -- **Contract-first:** Interfaces defined before implementation -- **Protocol-agnostic:** No HTTP/gRPC/function call specifics -- **Fail-safe:** Every operation returns success/failure status -- **Immutable data:** DTOs are read-only after creation - ---- - -## Component Contracts - -### 1. Ring Home Directory Manager - -**Interface Name:** `RingHomeManager` - -**Operations:** - -#### Operation: Initialize - -``` -initialize(force_recreate: boolean) -> Result - -Input: - force_recreate: boolean # If true, recreate directory structure - -Output (Success): - RingHome { - path: FilePath # Absolute path to ~/.ring/ - config_path: FilePath # Path to config.yaml - memory_path: FilePath # Path to memory.db - handoffs_path: FilePath # Path to handoffs/ - created: boolean # True if newly created - } - -Output (Error): - InitError { - code: enum [PERMISSION_DENIED, PATH_EXISTS, DISK_FULL] - message: string - fallback_path: FilePath? # Suggested fallback if available - } - -Pre-conditions: - - User has write access to home directory - -Post-conditions: - - ~/.ring/ exists with standard subdirectories - - OR fallback path identified and returned -``` - -#### Operation: Get Config - -``` -get_config(scope: ConfigScope, key: string) -> Result - -Input: - scope: enum [GLOBAL, PROJECT, MERGED] # Which config to query - key: string # Config key (dot notation: "skill.activation.enabled") - -Output (Success): - ConfigValue { - value: any # The configuration value - source: enum [GLOBAL, PROJECT, DEFAULT] - precedence: int # 0=default, 1=global, 2=project - } - -Output (Error): - ConfigError { - code: enum [KEY_NOT_FOUND, INVALID_SCOPE, PARSE_ERROR] - message: string - default_value: any? - } - -Pre-conditions: - - Config file exists OR defaults available - -Post-conditions: - - Returns value with source attribution -``` - -#### Operation: Migrate Legacy Data - -``` -migrate_legacy_data(source_path: FilePath, data_type: DataType) -> Result - -Input: - source_path: FilePath # Path to legacy data location - data_type: enum [HANDOFFS, LEDGERS, STATE] - -Output (Success): - MigrationResult { - source: FilePath - destination: FilePath - items_migrated: int - items_skipped: int - warnings: [string] - } - -Output (Error): - MigrationError { - code: enum [SOURCE_NOT_FOUND, PERMISSION_DENIED, VALIDATION_FAILED] - message: string - partially_migrated: int - } - -Pre-conditions: - - source_path exists and is readable - - Destination has write access - -Post-conditions: - - Source files preserved (read-only) - - Migrated files in destination -``` - ---- - -### 2. Memory Repository - -**Interface Name:** `MemoryRepository` - -**Operations:** - -#### Operation: Store Learning - -``` -store_learning(learning: LearningInput) -> Result - -Input: - LearningInput { - content: string # The learning text - type: LearningType # enum [FAILED_APPROACH, WORKING_SOLUTION, ...] - tags: [string] # Optional tags - confidence: ConfidenceLevel # enum [HIGH, MEDIUM, LOW] - context: string? # Optional context - session_id: string? # Optional session association - expires_at: Timestamp? # Optional expiration - } - -Output (Success): - LearningId { - id: string # Unique identifier - stored_at: Timestamp - } - -Output (Error): - StoreError { - code: enum [DUPLICATE_DETECTED, VALIDATION_FAILED, DB_LOCKED, DISK_FULL] - message: string - duplicate_id: string? # If DUPLICATE_DETECTED - } - -Pre-conditions: - - type is valid LearningType - - content is non-empty - -Post-conditions: - - Learning persisted with timestamps - - Content sanitized of secrets (API keys, tokens) - - Full-text index updated -``` - -#### Operation: Search Memories - -``` -search_memories(query: SearchQuery) -> Result - -Input: - SearchQuery { - text: string # Search query - filters: SearchFilters? # Optional filters - limit: int # Max results (default: 10) - include_expired: boolean # Include expired memories - } - - SearchFilters { - types: [LearningType]? # Filter by learning type - tags: [string]? # Filter by tags - confidence: ConfidenceLevel? # Minimum confidence - date_range: DateRange? # Created within range - session_id: string? # From specific session - } - -Output (Success): - SearchResults { - results: [SearchResult] - total_count: int # Total matches (before limit) - query_time_ms: int # Performance metric - } - - SearchResult { - learning: Learning - relevance_score: float # 0.0 to 1.0 - highlights: [string] # Matched snippets - } - -Output (Error): - SearchError { - code: enum [QUERY_INVALID, DB_UNAVAILABLE, TIMEOUT] - message: string - fallback_available: boolean - } - -Pre-conditions: - - text is non-empty - - limit > 0 and <= 100 - -Post-conditions: - - Results sorted by relevance (desc) - - Access times updated for returned memories -``` - -#### Operation: Apply Decay (Async) - -``` -apply_decay(strategy: DecayStrategy) -> Result - -Input: - DecayStrategy { - age_weight: float # Weight for time since creation (0.0-1.0) - access_weight: float # Weight for access frequency (0.0-1.0) - min_score: float # Minimum score to keep (0.0-1.0) - } - -Output (Success): - DecayResult { - memories_decayed: int - memories_pruned: int # Fell below min_score - average_age_days: float - } - -Output (Error): - DecayError { - code: enum [INVALID_STRATEGY, DB_LOCKED] - message: string - } - -Pre-conditions: - - age_weight + access_weight <= 1.0 - -Post-conditions: - - Memory scores updated - - Low-score memories deleted or marked -``` - ---- - -### 3. Handoff Serializer - -**Interface Name:** `HandoffSerializer` - -**Operations:** - -#### Operation: Serialize - -``` -serialize(handoff: HandoffData, format: HandoffFormat) -> Result - -Input: - HandoffData { - session: SessionMetadata - task: TaskInfo - decisions: [Decision] - artifacts: ArtifactReferences - resume: ResumeInstructions - } - - format: enum [YAML, MARKDOWN] - -Output (Success): - SerializedHandoff { - content: string # Serialized content - format: HandoffFormat - byte_size: int - token_estimate: int # Estimated tokens - } - -Output (Error): - SerializeError { - code: enum [VALIDATION_FAILED, SERIALIZATION_ERROR] - message: string - validation_errors: [ValidationError]? - } - -Pre-conditions: - - handoff passes schema validation - -Post-conditions: - - Content is valid YAML or Markdown - - Deserializing content returns equivalent data -``` - -#### Operation: Deserialize - -``` -deserialize(content: string, format: HandoffFormat?) -> Result - -Input: - content: string # Serialized handoff content - format: HandoffFormat? # If null, auto-detect - -Output (Success): - HandoffData { - session: SessionMetadata - task: TaskInfo - decisions: [Decision] - artifacts: ArtifactReferences - resume: ResumeInstructions - source_format: HandoffFormat # Detected or specified - } - -Output (Error): - DeserializeError { - code: enum [PARSE_ERROR, UNKNOWN_FORMAT, SCHEMA_MISMATCH] - message: string - line_number: int? # If parse error - } - -Pre-conditions: - - content is non-empty - -Post-conditions: - - Returns structured handoff data - - source_format indicates actual format used -``` - -#### Operation: Validate Schema - -``` -validate_schema(handoff: HandoffData) -> Result - -Input: - HandoffData (see above) - -Output (Success): - ValidationSuccess { - valid: true - warnings: [string] # Non-blocking warnings - } - -Output (Error): - ValidationErrors { - valid: false - errors: [ValidationError] - } - - ValidationError { - field: string # JSON path to field - constraint: string # Which constraint failed - message: string - } - -Pre-conditions: - - handoff object exists - -Post-conditions: - - All required fields checked - - Type constraints validated - - Returns detailed error locations -``` - -#### Operation: Migrate - -``` -migrate(source_path: FilePath) -> Result - -Input: - source_path: FilePath # Path to Markdown handoff - -Output (Success): - MigrationResult { - source: FilePath - destination: FilePath - format_from: MARKDOWN - format_to: YAML - preserved_original: boolean - } - -Output (Error): - MigrationError { - code: enum [SOURCE_INVALID, CONVERSION_FAILED, WRITE_FAILED] - message: string - partial_result: HandoffData? - } - -Pre-conditions: - - source_path exists and is valid Markdown handoff - -Post-conditions: - - Original file unchanged - - YAML copy created (if successful) -``` - ---- - -### 4. Skill Activator - -**Interface Name:** `SkillActivator` - -**Operations:** - -#### Operation: Load Rules - -``` -load_rules(paths: [FilePath]) -> Result - -Input: - paths: [FilePath] # Ordered by precedence (project, then global) - -Output (Success): - ActivationRules { - skills: {skill_name: SkillRule} - agents: {agent_name: SkillRule} - version: string - source_paths: [FilePath] - } - - SkillRule { - skill: string # ring:skill-name - type: RuleType # enum [GUARDRAIL, DOMAIN, WORKFLOW] - enforcement: Enforcement # enum [BLOCK, SUGGEST, WARN] - priority: Priority # enum [CRITICAL, HIGH, MEDIUM, LOW] - triggers: TriggerConfig - } - -Output (Error): - LoadError { - code: enum [FILE_NOT_FOUND, PARSE_ERROR, SCHEMA_INVALID] - message: string - file_path: FilePath - } - -Pre-conditions: - - At least one path exists - -Post-conditions: - - Rules merged with project overriding global - - All rules validated against schema -``` - -#### Operation: Match Skills - -``` -match_skills(prompt: string, rules: ActivationRules) -> Result - -Input: - prompt: string # User's prompt text - rules: ActivationRules # Loaded activation rules - -Output (Success): - SkillMatches { - matches: [SkillMatch] - total_checked: int - match_time_ms: int - } - - SkillMatch { - skill: string # ring:skill-name - enforcement: Enforcement - priority: Priority - match_reason: MatchReason - confidence: float # 0.0 to 1.0 - } - - MatchReason { - matched_keywords: [string] - matched_patterns: [string] - excluded_by: [string]? # Negative patterns that didn't match - } - -Output (Error): - MatchError { - code: enum [INVALID_PROMPT, RULES_NOT_LOADED] - message: string - } - -Pre-conditions: - - prompt is non-empty - - rules loaded and validated - -Post-conditions: - - Matches sorted by priority then confidence - - No duplicate skill recommendations -``` - -#### Operation: Apply Enforcement - -``` -apply_enforcement(matches: [SkillMatch]) -> EnforcementAction - -Input: - matches: [SkillMatch] # From match_skills() - -Output: - EnforcementAction { - action: enum [BLOCK, SUGGEST, WARN, CONTINUE] - skills: [string] # Skill names to present - message: string # User-facing message - blocking_skills: [string]? # If action=BLOCK - } - -Pre-conditions: - - matches is non-empty - -Post-conditions: - - BLOCK if any match has enforcement=BLOCK - - SUGGEST if highest priority is SUGGEST - - WARN if only WARN matches - - CONTINUE if no matches -``` - ---- - -### 5. Confidence Marker - -**Interface Name:** `ConfidenceMarker` - -**Operations:** - -#### Operation: Mark Finding - -``` -mark_finding(finding: Finding, evidence: [Evidence]) -> MarkedFinding - -Input: - Finding { - claim: string - location: CodeLocation? - importance: enum [CRITICAL, IMPORTANT, MINOR] - } - - Evidence { - type: EvidenceType # enum [FILE_READ, GREP_MATCH, AST_PARSE, ASSUMPTION, TRACE] - description: string - source: FilePath? # If file-based evidence - line_range: Range? # If specific lines - } - -Output: - MarkedFinding { - finding: Finding - confidence: Confidence - marker: string # Symbol: ✓, ?, ✗ - evidence_chain: [Evidence] - requires_review: boolean - } - - Confidence { - level: enum [VERIFIED, INFERRED, UNCERTAIN] - score: float # 0.0 to 1.0 - reasoning: string - } - -Pre-conditions: - - finding.claim is non-empty - - evidence is non-empty array - -Post-conditions: - - Marker matches confidence level - - Evidence chain is ordered by strength -``` - -#### Operation: Infer Confidence - -``` -infer_confidence(evidence: [Evidence]) -> Confidence - -Input: - evidence: [Evidence] # Evidence supporting a claim - -Output: - Confidence { - level: enum [VERIFIED, INFERRED, UNCERTAIN] - score: float # 0.0 to 1.0 - reasoning: string - } - -Algorithm: - IF any(evidence.type == FILE_READ) AND any(evidence.type == TRACE): - level = VERIFIED, score = 0.9 - - ELSE IF any(evidence.type == FILE_READ): - level = VERIFIED, score = 0.8 - - ELSE IF all(evidence.type == GREP_MATCH): - level = INFERRED, score = 0.5 - - ELSE IF any(evidence.type == ASSUMPTION): - level = UNCERTAIN, score = 0.2 - - ELSE: - level = UNCERTAIN, score = 0.3 - -Pre-conditions: - - evidence is non-empty - -Post-conditions: - - Score matches level consistently -``` - -#### Operation: Format Output - -``` -format_output(findings: [MarkedFinding]) -> FormattedOutput - -Input: - findings: [MarkedFinding] - -Output: - FormattedOutput { - sections: [OutputSection] - total_findings: int - verified_count: int - inferred_count: int - uncertain_count: int - } - - OutputSection { - title: string - findings: [MarkedFinding] - confidence_summary: string - } - -Pre-conditions: - - findings is non-empty - -Post-conditions: - - Findings grouped by confidence level - - Summary statistics accurate -``` - ---- - -### 6. Hook Orchestrator - -**Interface Name:** `HookOrchestrator` - -**Operations:** - -#### Operation: On Session Start - -``` -on_session_start(context: SessionContext) -> Result - -Input: - SessionContext { - session_id: string - mode: enum [STARTUP, RESUME, CLEAR, COMPACT] - project_path: FilePath - previous_session_id: string? - } - -Output (Success): - HookResponse { - additional_context: string # Content to inject - metadata: { - memories_loaded: int - config_source: string - ledger_loaded: boolean - } - } - -Output (Error): - HookError { - code: enum [TIMEOUT, EXECUTION_FAILED, INVALID_JSON] - message: string - partial_context: string? - } - -Behavior: - 1. Load configuration (global + project) - 2. Search memories relevant to project - 3. Load active continuity ledger - 4. Compile context string - 5. Return as JSON - -Pre-conditions: - - session_id is valid - - project_path exists - -Post-conditions: - - Context injected into session - - Memories marked as accessed -``` - -#### Operation: On User Prompt Submit - -``` -on_user_prompt_submit(prompt: UserPrompt) -> Result - -Input: - UserPrompt { - text: string - session_id: string - turn_number: int - } - -Output (Success): - HookResponse { - action: enum [BLOCK, SUGGEST, CONTINUE] - suggestions: [SkillSuggestion]? - blocking_reason: string? - additional_context: string? - } - - SkillSuggestion { - skill: string - reason: string - confidence: float - } - -Output (Error): - HookError { - code: enum [TIMEOUT, ACTIVATION_FAILED] - message: string - } - -Behavior: - 1. Match prompt against skill rules - 2. Rank matches by priority - 3. Apply enforcement levels - 4. Return suggestions or block - -Pre-conditions: - - prompt.text is non-empty - -Post-conditions: - - If BLOCK, must include blocking_reason - - If SUGGEST, includes skill suggestions -``` - -#### Operation: On Post Write - -``` -on_post_write(file_info: FileInfo) -> Result - -Input: - FileInfo { - path: FilePath - content_preview: string # First 500 chars - file_type: FileType # Inferred from extension/content - session_id: string - } - -Output (Success): - HookResponse { - indexed: boolean - artifact_type: ArtifactType? # If indexed - metadata: { - index_time_ms: int - } - } - -Output (Error): - HookError { - code: enum [INDEX_FAILED, FILE_UNREADABLE] - message: string - } - -Behavior: - 1. Detect if file is handoff/plan/ledger - 2. If artifact, index in FTS5 - 3. Extract metadata for search - 4. Return index result - -Pre-conditions: - - file_info.path exists and is readable - -Post-conditions: - - Artifact indexed if applicable - - Index updated atomically -``` - ---- - -## Data Transfer Objects (DTOs) - -### Core Types - -``` -# Learning Types -enum LearningType { - FAILED_APPROACH - WORKING_SOLUTION - USER_PREFERENCE - CODEBASE_PATTERN - ARCHITECTURAL_DECISION - ERROR_FIX - OPEN_THREAD -} - -# Confidence Levels -enum ConfidenceLevel { - HIGH # Empirically verified - MEDIUM # Logically sound but not tested - LOW # Assumption or inference -} - -# Verification Status -enum VerificationStatus { - VERIFIED # ✓ - Read files, traced code - INFERRED # ? - Based on grep/search - UNCERTAIN # ✗ - Not checked -} - -# Enforcement Levels -enum Enforcement { - BLOCK # Must use skill - SUGGEST # Recommend skill - WARN # Log availability -} - -# Priority Levels -enum Priority { - CRITICAL # Always trigger - HIGH # Trigger for most matches - MEDIUM # Trigger for clear matches - LOW # Trigger only explicit -} -``` - -### Handoff Schema - -``` -HandoffData { - # Required fields - session: SessionMetadata { - id: string - started_at: Timestamp - ended_at: Timestamp? - duration_seconds: int? - } - - task: TaskInfo { - description: string - status: enum [COMPLETED, PAUSED, BLOCKED, FAILED] - skills_used: [string] - } - - context: ContextInfo { - project_path: FilePath - git_commit: string - git_branch: string - key_files: [FilePath] # Max 10 most relevant - } - - # Optional fields - decisions: [Decision] { - decision: string - rationale: string - alternatives: [string] - } - - artifacts: ArtifactReferences { - created: [FilePath] - modified: [FilePath] - deleted: [FilePath] - } - - learnings: [Learning] { - type: LearningType - content: string - confidence: VerificationStatus - } - - blockers: [Blocker] { - type: enum [TECHNICAL, CLARIFICATION, EXTERNAL] - description: string - severity: enum [CRITICAL, HIGH, MEDIUM] - } - - resume: ResumeInstructions { - next_steps: [string] - context_needed: [string] - warnings: [string] - } -} -``` - -### Memory Schema - -``` -Learning { - id: string # Unique identifier - session_id: string # Source session - created_at: Timestamp - accessed_at: Timestamp - access_count: int - - content: string # The learning text - type: LearningType - tags: [string] - confidence: ConfidenceLevel - - context: string? # Optional context - expires_at: Timestamp? # Optional expiration - - metadata: { - source: string # Where learning came from - relevance_score: float # Current relevance (decays) - } -} -``` - -### Skill Rule Schema - -``` -SkillRule { - skill: string # ring:skill-name - type: RuleType - enforcement: Enforcement - priority: Priority - description: string - - triggers: TriggerConfig { - keywords: [string] # Exact matches - intent_patterns: [string] # Regex patterns - negative_patterns: [string]? # Exclusions - } -} -``` - ---- - -## Error Handling Specification - -### Result Type Pattern - -All operations return `Result` type: - -``` -Result = - | Ok(value: T) - | Err(error: E) - -Usage: - result = operation() - match result: - case Ok(value): - # Handle success - case Err(error): - # Handle error -``` - -### Error Codes Catalog - -| Code | Component | Meaning | Recovery | -|------|-----------|---------|----------| -| `PERMISSION_DENIED` | RingHomeManager | No write access | Use fallback path | -| `DB_LOCKED` | MemoryRepository | Concurrent access | Retry with backoff | -| `VALIDATION_FAILED` | HandoffSerializer | Schema violation | Return detailed errors | -| `QUERY_INVALID` | MemoryRepository | Malformed query | Sanitize and retry | -| `TIMEOUT` | HookOrchestrator | Hook exceeded limit | Return partial result | -| `DUPLICATE_DETECTED` | MemoryRepository | Similar learning exists | Return existing ID | -| `PARSE_ERROR` | HandoffSerializer | Invalid YAML/Markdown | Try alternate format | - -### Error Response Format - -``` -ErrorResponse { - code: ErrorCode - message: string # Human-readable description - context: { - component: string - operation: string - timestamp: Timestamp - } - recovery: RecoveryInfo? { - suggested_action: string - fallback_available: boolean - retry_recommended: boolean - } - details: any? # Operation-specific error details -} -``` - ---- - -## Integration Specifications - -### Integration 1: Hook → Memory Repository - -**Protocol:** Function call (in-process) - -**Flow:** -``` -SessionStart Hook - │ - ▼ -MemoryRepository.search_memories(query=project_context) - │ - ▼ -Return [SearchResult] - │ - ▼ -Format as context string - │ - ▼ -Include in hook JSON response -``` - -**Contract:** -- Hook calls repository synchronously -- Timeout: 2 seconds max for search -- If timeout: Return empty results, don't block session - ---- - -### Integration 2: Hook → Skill Activator - -**Protocol:** Function call (in-process) - -**Flow:** -``` -UserPromptSubmit Hook - │ - ▼ -SkillActivator.load_rules() - │ - ▼ -SkillActivator.match_skills(prompt) - │ - ▼ -SkillActivator.apply_enforcement(matches) - │ - ▼ -Return EnforcementAction in hook JSON -``` - -**Contract:** -- Hook calls activator synchronously -- Timeout: 1 second max for matching -- If timeout: Return CONTINUE (no blocking) - ---- - -### Integration 3: Skill → Handoff Serializer - -**Protocol:** Command invocation - -**Flow:** -``` -User invokes: /ring:create-handoff - │ - ▼ -Skill gathers handoff data - │ - ▼ -HandoffSerializer.validate_schema(data) - │ - ▼ -HandoffSerializer.serialize(data, format=YAML) - │ - ▼ -Write to ~/.ring/handoffs/{session}/{timestamp}.yaml - │ - ▼ -Trigger PostToolUse hook for indexing -``` - -**Contract:** -- Skill calls serializer via Python script -- Validation before write (fail fast) -- Returns file path on success - ---- - -### Integration 4: Agent → Confidence Marker - -**Protocol:** Template injection - -**Flow:** -``` -Agent Output Template - │ - ▼ -For each finding: - ├─> Classify evidence type - ├─> ConfidenceMarker.infer_confidence(evidence) - ├─> ConfidenceMarker.mark_finding(finding, evidence) - └─> Include in output section - │ - ▼ -Formatted output with markers -``` - -**Contract:** -- Agent includes "Verification" section in output -- Each finding has confidence marker -- Evidence chain documented - ---- - -### Integration 5: PostToolUse Hook → Indexing - -**Protocol:** Event trigger - -**Flow:** -``` -User writes file via Write tool - │ - ▼ -Claude Code triggers PostToolUse hook - │ - ▼ -Hook checks file type (handoff/plan/ledger?) - │ - ├─> YES: Call indexer - │ └─> Update FTS5 index - │ - └─> NO: Skip indexing -``` - -**Contract:** -- Hook runs after Write completes -- Indexing is best-effort (non-blocking) -- Failures logged but don't interrupt session - ---- - -## Request/Response Formats - -### Hook Input Format (stdin JSON) - -```json -{ - "event": "SessionStart|UserPromptSubmit|PostToolUse|PreCompact|Stop", - "session_id": "unique-id", - "timestamp": "2026-01-12T10:30:00Z", - "data": { - // Event-specific data - } -} -``` - -### Hook Output Format (stdout JSON) - -```json -{ - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": "Context string to inject" - } -} -``` - -Or for blocking: - -```json -{ - "result": "block", - "reason": "Must use ring:test-driven-development for test tasks" -} -``` - -Or for continuation: - -```json -{ - "result": "continue" -} -``` - -### Memory Search Request - -```json -{ - "query": "typescript async await patterns", - "filters": { - "types": ["WORKING_SOLUTION", "CODEBASE_PATTERN"], - "confidence": "MEDIUM", - "tags": ["typescript"] - }, - "limit": 10 -} -``` - -### Memory Search Response - -```json -{ - "results": [ - { - "id": "learning-123", - "content": "Use async/await instead of .then() for better readability", - "type": "WORKING_SOLUTION", - "confidence": "HIGH", - "relevance_score": 0.85, - "highlights": ["async/await", "readability"], - "created_at": "2025-12-15T14:30:00Z", - "accessed_at": "2026-01-12T10:30:00Z" - } - ], - "total_count": 15, - "query_time_ms": 45 -} -``` - -### Handoff YAML Format - -```yaml ---- -# Schema metadata -version: "1.0" -schema: ring-handoff-v1 - -# Session metadata -session: - id: context-management-2026-01-12 - started_at: 2026-01-12T09:00:00Z - ended_at: 2026-01-12T11:30:00Z - duration_seconds: 9000 - -# Task information -task: - description: Implement artifact indexing with FTS5 - status: completed - skills_used: - - ring:test-driven-development - - ring:requesting-code-review - -# Context -context: - project_path: /Users/user/repos/ring - git_commit: abc123def - git_branch: feature/artifact-index - key_files: - - default/lib/artifact-index/artifact_index.py - - default/lib/artifact-index/artifact_schema.sql - -# Decisions made -decisions: - - decision: Use FTS5 instead of FTS3 - rationale: FTS5 supports BM25 ranking - alternatives: - - FTS3 (lacks ranking) - - External search engine (too heavy) - -# Artifacts -artifacts: - created: - - default/lib/artifact-index/artifact_index.py - modified: - - default/lib/artifact-index/artifact_schema.sql - -# Learnings -learnings: - - type: WORKING_SOLUTION - content: FTS5 triggers keep index in sync automatically - confidence: verified - - - type: FAILED_APPROACH - content: Tried FTS3, lacks BM25 ranking - confidence: verified - -# Resume instructions -resume: - next_steps: - - Add query script for artifact search - - Test with real handoff documents - context_needed: - - Review artifact_schema.sql for table structure - warnings: - - FTS5 requires SQLite 3.35+ ---- - -# Session Notes - -## Summary -Implemented artifact indexing using SQLite FTS5... - -## Open Questions -- Should we support external search engines in future? -``` - ---- - -## Gate 4 Validation Checklist - -- [x] All component interfaces defined (6 components) -- [x] Contracts are clear and complete (operations, DTOs, errors) -- [x] Error cases covered (error codes catalog, recovery strategies) -- [x] Protocol-agnostic (no REST/gRPC, just data contracts) -- [x] Request/response formats specified -- [x] Integration specifications documented (5 integrations) -- [x] DTOs with explicit schemas -- [x] Pre/post-conditions for operations - ---- - -## Appendix: Interface Catalog - -| Interface | Operations | Dependencies | -|-----------|------------|--------------| -| RingHomeManager | initialize, get_config, migrate_legacy_data | File system, Config parser | -| MemoryRepository | store_learning, search_memories, apply_decay | FTS engine, Timestamp provider | -| HandoffSerializer | serialize, deserialize, validate_schema, migrate | YAML parser, Markdown parser, Schema validator | -| SkillActivator | load_rules, match_skills, apply_enforcement | Pattern matcher, Rule validator | -| ConfidenceMarker | mark_finding, infer_confidence, format_output | Evidence classifier | -| HookOrchestrator | on_session_start, on_user_prompt_submit, on_post_write | All above components | diff --git a/docs/pre-dev/ring-continuity/data-model.md b/docs/pre-dev/ring-continuity/data-model.md deleted file mode 100644 index 8a529217..00000000 --- a/docs/pre-dev/ring-continuity/data-model.md +++ /dev/null @@ -1,1040 +0,0 @@ -# Ring Continuity - Data Model (Gate 5) - -> **Version:** 1.0 -> **Date:** 2026-01-12 -> **Status:** Draft -> **Based on:** API Design v1.0 (docs/pre-dev/ring-continuity/api-design.md) - ---- - -## Executive Summary - -The Ring Continuity data model consists of **7 core entities** organized into **3 data ownership domains**: User Domain (global preferences), Session Domain (ephemeral state), and Artifact Domain (persistent documents). The model uses **event sourcing** for audit trails, **soft deletion** for data safety, and **denormalized search indexes** for performance. - -**Key Patterns:** -- **Event Sourcing** for memory creation/access tracking -- **Soft Deletion** for recoverable data removal -- **Denormalized Indexes** for full-text search -- **Hierarchical Configuration** for precedence resolution - ---- - -## Entity-Relationship Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ USER DOMAIN │ -│ ┌──────────────┐ ┌──────────────────┐ │ -│ │ UserConfig │ │ ActivationRule │ │ -│ └──────────────┘ └──────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ - │ influences - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ SESSION DOMAIN │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ Session │────────>│ SessionLog │ │ -│ └──────┬───────┘ 1:N └──────────────┘ │ -│ │ │ -│ │ 1:N │ -│ ▼ │ -│ ┌──────────────┐ │ -│ │ Learning │ │ -│ └──────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ - │ references - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ARTIFACT DOMAIN │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ Handoff │ │ Plan │ │ -│ └──────┬───────┘ └──────────────┘ │ -│ │ │ -│ │ 1:N │ -│ ▼ │ -│ ┌──────────────┐ │ -│ │ Artifact │<───────────────────┐ │ -│ │ Reference │ │ │ -│ └──────────────┘ │ │ -│ │ │ -│ ┌──────────────────────────────────┴────┐ │ -│ │ Full-Text Search Index │ │ -│ │ (Denormalized for performance) │ │ -│ └───────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Core Entities - -### Entity 1: Learning - -**Purpose:** Stores a single piece of knowledge extracted from sessions. - -**Attributes:** - -| Attribute | Type | Required | Description | -|-----------|------|----------|-------------| -| id | Identifier | Yes | Unique learning identifier | -| session_id | Identifier | Yes | Source session | -| created_at | Timestamp | Yes | When learning was created | -| accessed_at | Timestamp | Yes | Last access time | -| access_count | Integer | Yes | Number of times accessed | -| content | Text | Yes | The learning content | -| type | LearningType | Yes | Classification (enum) | -| tags | List | No | Searchable tags | -| confidence | ConfidenceLevel | Yes | HIGH, MEDIUM, LOW | -| context | Text | No | Additional context | -| expires_at | Timestamp | No | Auto-deletion time | -| relevance_score | Float | Yes | Current relevance (decays) | -| source | String | Yes | Where learning came from | -| deleted_at | Timestamp | No | Soft deletion timestamp | - -**Relationships:** -- `session_id` → Session (M:1) - -**Constraints:** -- `type` must be in LearningType enum -- `confidence` must be in ConfidenceLevel enum -- `relevance_score` between 0.0 and 1.0 -- `access_count` >= 0 -- If `deleted_at` is set, learning excluded from searches - -**Indexes:** -- Primary: `id` -- Search: Full-text index on `content`, `context`, `tags` -- Filter: `type`, `session_id`, `confidence` -- Cleanup: `expires_at`, `deleted_at` - ---- - -### Entity 2: Session - -**Purpose:** Tracks a Ring session for traceability. - -**Attributes:** - -| Attribute | Type | Required | Description | -|-----------|------|----------|-------------| -| id | Identifier | Yes | Unique session identifier | -| project_path | FilePath | Yes | Project directory | -| started_at | Timestamp | Yes | Session start time | -| ended_at | Timestamp | No | Session end time | -| duration_seconds | Integer | No | Calculated duration | -| mode | SessionMode | Yes | STARTUP, RESUME, CLEAR, COMPACT | -| git_commit | String | No | Git commit at start | -| git_branch | String | No | Git branch at start | -| outcome | Outcome | No | Session outcome | - -**Relationships:** -- 1:N with Learning -- 1:N with SessionLog -- 1:1 with Handoff (optional) - -**Constraints:** -- `ended_at` >= `started_at` if both set -- `duration_seconds` = `ended_at` - `started_at` -- `mode` must be in SessionMode enum - -**Indexes:** -- Primary: `id` -- Query: `project_path`, `started_at` - ---- - -### Entity 3: Handoff - -**Purpose:** Captures session state for cross-session continuity. - -**Attributes:** - -| Attribute | Type | Required | Description | -|-----------|------|----------|-------------| -| id | Identifier | Yes | Unique handoff identifier | -| session_id | Identifier | Yes | Source session | -| created_at | Timestamp | Yes | When handoff created | -| format | HandoffFormat | Yes | YAML or MARKDOWN | -| file_path | FilePath | Yes | Storage location | -| task_summary | Text | Yes | 1-2 sentence summary | -| status | TaskStatus | Yes | COMPLETED, PAUSED, BLOCKED, FAILED | -| outcome | Outcome | No | SUCCEEDED, PARTIAL_PLUS, etc. | -| outcome_marked_at | Timestamp | No | When outcome was marked | -| skills_used | List | No | Skills invoked | -| git_commit | String | Yes | Git state at handoff | -| git_branch | String | Yes | Git branch | -| key_files | List | No | Most relevant files (max 10) | -| next_steps | List | Yes | Resume instructions | -| token_count | Integer | No | Estimated tokens in handoff | - -**Relationships:** -- `session_id` → Session (M:1) -- N:M with ArtifactReference - -**Constraints:** -- `format` must be YAML or MARKDOWN -- `status` must be in TaskStatus enum -- `key_files` max length 10 -- `next_steps` must be non-empty -- `token_count` estimated from file size - -**Indexes:** -- Primary: `id` -- Search: Full-text index on `task_summary`, decisions, learnings -- Filter: `session_id`, `status`, `outcome`, `created_at` - ---- - -### Entity 4: UserConfig - -**Purpose:** Stores user preferences and settings. - -**Attributes:** - -| Attribute | Type | Required | Description | -|-----------|------|----------|-------------| -| scope | ConfigScope | Yes | GLOBAL or PROJECT | -| key | String | Yes | Config key (dot notation) | -| value | Any | Yes | Config value (JSON-serializable) | -| source | FilePath | Yes | Which config file | -| precedence | Integer | Yes | 0=default, 1=global, 2=project | -| description | String | No | Human-readable description | -| updated_at | Timestamp | Yes | Last update time | - -**Relationships:** -- None (flat key-value store) - -**Constraints:** -- `key` format: `category.subcategory.setting` (dot notation) -- `precedence` must match `scope` (GLOBAL=1, PROJECT=2) -- `value` must be JSON-serializable - -**Indexes:** -- Primary: `(scope, key)` -- Query: `key`, `scope` - -**Access Patterns:** -- Read: `get_config(scope, key)` → merge with precedence -- Write: `set_config(scope, key, value)` → update or insert -- Query: `list_config(scope)` → all keys in scope - ---- - -### Entity 5: ActivationRule - -**Purpose:** Defines patterns for automatic skill suggestion. - -**Attributes:** - -| Attribute | Type | Required | Description | -|-----------|------|----------|-------------| -| skill | String | Yes | ring:skill-name | -| type | RuleType | Yes | GUARDRAIL, DOMAIN, WORKFLOW | -| enforcement | Enforcement | Yes | BLOCK, SUGGEST, WARN | -| priority | Priority | Yes | CRITICAL, HIGH, MEDIUM, LOW | -| description | String | No | Human-readable purpose | -| keywords | List | No | Exact keyword matches | -| intent_patterns | List | No | Regex patterns | -| negative_patterns | List | No | Exclusion patterns | -| scope | ConfigScope | Yes | GLOBAL or PROJECT | -| enabled | Boolean | Yes | Whether rule is active | - -**Relationships:** -- None (independent rules) - -**Constraints:** -- `skill` must exist in Ring registry -- `enforcement` must be in Enforcement enum -- At least one of `keywords` or `intent_patterns` must be non-empty -- Regex patterns must be valid - -**Indexes:** -- Primary: `skill` -- Query: `priority`, `enforcement`, `enabled` - -**Access Patterns:** -- Load: Load all enabled rules, merge project > global -- Match: For each rule, check keywords and patterns against prompt - ---- - -### Entity 6: ArtifactReference - -**Purpose:** Links handoffs to related artifacts (plans, files). - -**Attributes:** - -| Attribute | Type | Required | Description | -|-----------|------|----------|-------------| -| id | Identifier | Yes | Unique reference ID | -| handoff_id | Identifier | Yes | Source handoff | -| artifact_type | ArtifactType | Yes | PLAN, FILE, RESEARCH, LEDGER | -| artifact_path | FilePath | Yes | Path to artifact | -| relevance | String | Yes | Why this artifact is relevant | -| created_at | Timestamp | Yes | When reference added | - -**Relationships:** -- `handoff_id` → Handoff (M:1) - -**Constraints:** -- `artifact_type` must be in ArtifactType enum -- `artifact_path` must be relative or absolute valid path - -**Indexes:** -- Primary: `id` -- Foreign: `handoff_id` -- Query: `artifact_type`, `artifact_path` - ---- - -### Entity 7: SessionLog - -**Purpose:** Audit trail for session events. - -**Attributes:** - -| Attribute | Type | Required | Description | -|-----------|------|----------|-------------| -| id | Identifier | Yes | Unique log entry ID | -| session_id | Identifier | Yes | Session this log belongs to | -| timestamp | Timestamp | Yes | Event time | -| event_type | EventType | Yes | HOOK_EXECUTED, SKILL_ACTIVATED, etc. | -| component | String | Yes | Which component logged | -| level | LogLevel | Yes | DEBUG, INFO, WARN, ERROR | -| message | Text | Yes | Log message | -| metadata | JSON | No | Additional structured data | - -**Relationships:** -- `session_id` → Session (M:1) - -**Constraints:** -- `event_type` must be in EventType enum -- `level` must be in LogLevel enum -- Logs retained for 30 days, then pruned - -**Indexes:** -- Primary: `id` -- Query: `session_id`, `timestamp`, `level` - -**Access Patterns:** -- Append-only (no updates) -- Query by session for debugging -- Prune old logs periodically - ---- - -## Entity Schemas (Database-Agnostic) - -### Schema: Learning - -``` -Learning { - # Identity - id: UUID - - # Ownership - session_id: UUID - - # Temporal - created_at: DateTime (ISO 8601) - accessed_at: DateTime (ISO 8601) - access_count: UInt32 - expires_at: DateTime? (ISO 8601) - deleted_at: DateTime? (ISO 8601) - - # Content - content: String (max 10000 chars) - type: Enum { - FAILED_APPROACH - WORKING_SOLUTION - USER_PREFERENCE - CODEBASE_PATTERN - ARCHITECTURAL_DECISION - ERROR_FIX - OPEN_THREAD - } - tags: Array (max 20) - confidence: Enum { HIGH, MEDIUM, LOW } - context: String? (max 5000 chars) - - # Metadata - relevance_score: Float (0.0 to 1.0) - source: String (max 200 chars) -} -``` - -**Validation Rules:** -- `content` required, non-empty, max 10000 chars -- `type` required, must be in enum -- `created_at` <= `accessed_at` -- `relevance_score` between 0.0 and 1.0 -- If `deleted_at` set, must be >= `created_at` -- `tags` array max 20 items, each max 50 chars - ---- - -### Schema: Handoff - -``` -Handoff { - # Identity - id: UUID - - # Ownership - session_id: UUID - - # Temporal - created_at: DateTime (ISO 8601) - outcome_marked_at: DateTime? (ISO 8601) - - # Storage - format: Enum { YAML, MARKDOWN } - file_path: FilePath (absolute) - token_count: UInt32? - - # Session Context - task_summary: String (max 500 chars) - status: Enum { COMPLETED, PAUSED, BLOCKED, FAILED } - outcome: Enum { SUCCEEDED, PARTIAL_PLUS, PARTIAL_MINUS, FAILED, UNKNOWN } - skills_used: Array - - # Git Context - git_commit: String (40 chars) - git_branch: String (max 200 chars) - - # Resume Data - key_files: Array (max 10) - next_steps: Array (min 1) - - # Statistics - learnings_count: UInt32 - decisions_count: UInt32 - blockers_count: UInt32 -} -``` - -**Validation Rules:** -- `task_summary` required, max 500 chars -- `status` required, must be in enum -- `git_commit` format: 40 hex characters -- `key_files` max 10 items -- `next_steps` min 1 item, each max 500 chars -- `skills_used` items format: `ring:skill-name` - ---- - -### Schema: UserConfig - -``` -UserConfig { - # Identity - scope: Enum { GLOBAL, PROJECT } - key: String (max 200 chars) - - # Value - value: JSON - - # Metadata - source: FilePath - precedence: UInt8 (0, 1, or 2) - description: String? - updated_at: DateTime (ISO 8601) -} -``` - -**Validation Rules:** -- `key` format: `^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$` (dot notation) -- `precedence` must match `scope`: DEFAULT=0, GLOBAL=1, PROJECT=2 -- `value` must be valid JSON -- `source` must exist and be readable - ---- - -### Schema: ActivationRule - -``` -ActivationRule { - # Identity - skill: String (format: ring:skill-name) - scope: Enum { GLOBAL, PROJECT } - - # Classification - type: Enum { GUARDRAIL, DOMAIN, WORKFLOW, PROCESS, EXPLORATION } - enforcement: Enum { BLOCK, SUGGEST, WARN } - priority: Enum { CRITICAL, HIGH, MEDIUM, LOW } - - # Matching - keywords: Array - intent_patterns: Array - negative_patterns: Array - - # Metadata - description: String? - enabled: Boolean - - # Statistics - match_count: UInt32 # Times this rule matched - success_count: UInt32 # Times user used suggested skill -} -``` - -**Validation Rules:** -- `skill` format: `^ring:[a-z0-9-]+$` -- At least one of `keywords` or `intent_patterns` must be non-empty -- Regex patterns must compile without errors -- `match_count` >= `success_count` - ---- - -### Schema: Session - -``` -Session { - # Identity - id: UUID - - # Context - project_path: FilePath (absolute) - started_at: DateTime (ISO 8601) - ended_at: DateTime? (ISO 8601) - duration_seconds: UInt32? - - # Mode - mode: Enum { STARTUP, RESUME, CLEAR, COMPACT } - - # Git Context - git_commit: String? (40 chars) - git_branch: String? - git_remote: String? - - # Outcome - outcome: Enum { SUCCEEDED, PARTIAL_PLUS, PARTIAL_MINUS, FAILED, UNKNOWN } - outcome_confidence: Enum { HIGH, LOW } - - # Statistics - turns_count: UInt32 - tools_used: Array - skills_invoked: Array -} -``` - -**Validation Rules:** -- `project_path` must be absolute -- `ended_at` >= `started_at` if both set -- `duration_seconds` = (`ended_at` - `started_at`) if both set -- `git_commit` format: 40 hex chars or empty - ---- - -### Schema: ArtifactReference - -``` -ArtifactReference { - # Identity - id: UUID - handoff_id: UUID - - # Artifact Info - artifact_type: Enum { PLAN, FILE, RESEARCH, LEDGER } - artifact_path: FilePath - relevance: String (max 200 chars) - - # Temporal - created_at: DateTime (ISO 8601) - - # Verification - exists_at_reference: Boolean # Did file exist when referenced? - last_verified_at: DateTime? # Last existence check -} -``` - -**Validation Rules:** -- `artifact_type` must be in enum -- `relevance` required, non-empty, max 200 chars -- `artifact_path` can be relative or absolute -- If `last_verified_at` set, must be >= `created_at` - ---- - -### Schema: SessionLog - -``` -SessionLog { - # Identity - id: UUID - session_id: UUID - - # Event - timestamp: DateTime (ISO 8601) - event_type: Enum { - HOOK_EXECUTED, - SKILL_ACTIVATED, - MEMORY_STORED, - MEMORY_RETRIEVED, - CONFIG_LOADED, - ERROR_OCCURRED - } - - # Details - component: String (max 100 chars) - level: Enum { DEBUG, INFO, WARN, ERROR } - message: String (max 5000 chars) - metadata: JSON? -} -``` - -**Validation Rules:** -- `timestamp` must be valid ISO 8601 -- `event_type` must be in enum -- `level` must be in enum -- `metadata` must be valid JSON if present - ---- - -## Data Ownership - -### Ownership Model - -| Entity | Owner | Lifecycle | -|--------|-------|-----------| -| **Learning** | User (global) | Persistent, expires or pruned | -| **Session** | Project + User | Ephemeral (logs only) | -| **Handoff** | User (global) or Project | Persistent until deleted | -| **UserConfig** | User or Project | Persistent until modified | -| **ActivationRule** | User or Project | Persistent until modified | -| **ArtifactReference** | Handoff owner | Tied to handoff lifecycle | -| **SessionLog** | User (debugging) | Auto-pruned after 30 days | - -### Scope Boundaries - -``` -GLOBAL SCOPE (~/.ring/) -├─ Learning (cross-project) -├─ UserConfig (user preferences) -├─ ActivationRule (global skill patterns) -├─ Handoff (cross-project handoffs) -└─ SessionLog (debugging) - -PROJECT SCOPE (.ring/) -├─ UserConfig (project overrides) -├─ ActivationRule (project-specific patterns) -├─ Handoff (project-specific handoffs) -└─ Session state (ephemeral) -``` - -**Merge Strategy:** -- Config: Project overrides Global -- Rules: Project extends Global (both active) -- Handoffs: Both searched (project first) -- Learning: Global only (shared across projects) - ---- - -## Access Patterns - -### Pattern 1: Session Start - Memory Injection - -``` -Query: - SELECT content, type, confidence, relevance_score - FROM Learning - WHERE deleted_at IS NULL - AND (expires_at IS NULL OR expires_at > NOW()) - AND project_context_match(tags, context, $PROJECT_PATH) - ORDER BY relevance_score DESC - LIMIT 10 - -Update: - UPDATE Learning - SET accessed_at = NOW(), - access_count = access_count + 1 - WHERE id IN (returned_ids) -``` - -**Performance:** <200ms for 10K memories - ---- - -### Pattern 2: User Prompt - Skill Activation - -``` -Query: - FOR EACH ActivationRule WHERE enabled = true: - IF prompt CONTAINS ANY(keywords) - OR prompt MATCHES ANY(intent_patterns): - IF prompt NOT MATCHES ANY(negative_patterns): - YIELD rule - -Sort: - ORDER BY priority DESC, match_confidence DESC - -Filter: - LIMIT 5 # Top 5 suggestions max -``` - -**Performance:** <100ms for 100 rules - ---- - -### Pattern 3: Handoff Creation - Store and Index - -``` -Insert: - 1. Validate handoff data against schema - 2. Serialize to YAML - 3. Write to file: ~/.ring/handoffs/{session}/{timestamp}.yaml - 4. INSERT INTO Handoff (metadata) - 5. Trigger FTS index update - -Index: - Extract searchable text: - - task_summary (weight: 10) - - decisions content (weight: 5) - - learnings content (weight: 3) - - next_steps (weight: 1) - - INSERT INTO handoffs_fts (searchable_text) -``` - -**Performance:** <500ms including file write and index - ---- - -### Pattern 4: Handoff Resume - Search and Load - -``` -Query: - SELECT * - FROM Handoff - WHERE session_id = $SESSION_NAME - OR file_path LIKE '%/$SESSION_NAME/%' - ORDER BY created_at DESC - LIMIT 1 - -Load: - 1. Read file from file_path - 2. Deserialize YAML or Markdown - 3. Load referenced artifacts - 4. Return structured handoff data - -Update: - UPDATE Session - SET mode = 'RESUME', - previous_session_id = $LOADED_HANDOFF_SESSION_ID - WHERE id = $CURRENT_SESSION_ID -``` - -**Performance:** <1s including file reads - ---- - -### Pattern 5: Memory Decay - Relevance Update - -``` -Update: - UPDATE Learning - SET relevance_score = calculate_decay( - age_days = (NOW() - created_at) / 86400, - access_count = access_count, - confidence = confidence - ) - WHERE deleted_at IS NULL - -Delete: - UPDATE Learning - SET deleted_at = NOW() - WHERE relevance_score < 0.1 - OR (expires_at IS NOT NULL AND expires_at < NOW()) -``` - -**Decay Function:** -``` -relevance = base_score × age_factor × access_factor - -age_factor = exp(-age_days / 180) # Half-life: 180 days -access_factor = min(access_count / 10, 1.0) # Saturates at 10 accesses -base_score = confidence_to_score(confidence) # HIGH=1.0, MEDIUM=0.7, LOW=0.4 -``` - -**Performance:** Batch operation, run offline or during idle - ---- - -## Data Migration Strategy - -### Migration 1: Markdown to YAML Handoffs - -**Source:** `docs/handoffs/**/*.md` (Markdown format) - -**Destination:** `~/.ring/handoffs/**/*.yaml` (YAML format) - -**Process:** -``` -FOR EACH markdown_handoff: - 1. Parse Markdown frontmatter - 2. Extract structured sections: - - Task Summary → task_summary - - Key Decisions → decisions[] - - Files Modified → artifacts.modified - - Next Steps → resume.next_steps - 3. Validate against Handoff schema - 4. Serialize to YAML - 5. Write to ~/.ring/handoffs/ - 6. Preserve original in docs/handoffs/ - 7. Create backward reference link -``` - -**Data Mapping:** - -| Markdown Section | YAML Field | -|------------------|------------| -| YAML frontmatter `status` | `task.status` | -| YAML frontmatter `outcome` | `outcome` | -| ## Task Summary | `task_summary` | -| ## Key Decisions | `decisions[]` | -| ## Files Modified | `artifacts.modified[]` | -| ## Action Items & Next Steps | `resume.next_steps[]` | -| ## Learnings → What Worked | `learnings[]` type=WORKING_SOLUTION | -| ## Learnings → What Failed | `learnings[]` type=FAILED_APPROACH | - -**Validation:** -- Ensure all required fields present -- Convert timestamps to ISO 8601 -- Limit arrays to schema maximums -- Estimate token count from byte size - ---- - -### Migration 2: Project .ring/ to ~/.ring/ - -**Source:** `$PROJECT/.ring/` (project-local) - -**Destination:** `~/.ring/` (global) - -**What to Migrate:** - -| Data Type | Migrate? | Reason | -|-----------|----------|--------| -| Handoffs | Yes | Cross-session continuity | -| Ledgers | No | Project-specific, keep local | -| State files | No | Ephemeral, discard | -| Artifact index | Merge | Combine into global index | - -**Process:** -``` -FOR EACH project with .ring/: - 1. Scan .ring/handoffs/ for handoff files - 2. Migrate handoffs to ~/.ring/handoffs/{project-name}/ - 3. Update handoff.project_path to reference original project - 4. Add project tag to migrated handoffs - 5. Reindex in global FTS5 -``` - ---- - -### Migration 3: Existing Skills to Activation Rules - -**Source:** Skill YAML frontmatter `trigger` and `skip_when` fields - -**Destination:** `~/.ring/skill-rules.json` - -**Process:** -``` -FOR EACH skill in Ring: - 1. Parse SKILL.md YAML frontmatter - 2. Extract trigger conditions → keywords + intent_patterns - 3. Extract skip_when conditions → negative_patterns - 4. Infer enforcement level: - - Workflow skills → SUGGEST - - Guardrail skills → BLOCK - - Other → WARN - 5. Infer priority from skill category - 6. Generate ActivationRule entry - 7. Add to skill-rules.json -``` - -**Data Mapping:** - -| Skill Frontmatter | ActivationRule Field | -|-------------------|---------------------| -| `trigger` lines | Extract keywords and patterns | -| `skip_when` lines | `negative_patterns[]` | -| Skill category | `type` (workflow/guardrail/domain) | -| Skill importance | `priority` (critical/high/medium/low) | - ---- - -## Denormalized Indexes - -### Index 1: Full-Text Search (Memories) - -**Purpose:** Fast text search across learning content. - -**Structure:** -``` -memories_fts { - rowid: Integer → Learning.id - content: Text (from Learning.content) - context: Text (from Learning.context) - tags: Text (space-separated from Learning.tags) -} -``` - -**Tokenization:** Porter stemming + Unicode normalization - -**Ranking:** BM25 with weights `(content: 1.0, context: 0.5, tags: 2.0)` - -**Synchronization:** Automatic triggers on INSERT/UPDATE/DELETE - ---- - -### Index 2: Full-Text Search (Handoffs) - -**Purpose:** Search past handoffs for relevant context. - -**Structure:** -``` -handoffs_fts { - rowid: Integer → Handoff.id - task_summary: Text (from Handoff.task_summary) - decisions: Text (extracted from file content) - learnings: Text (extracted from file content) - next_steps: Text (extracted from file content) -} -``` - -**Tokenization:** Porter stemming + Unicode normalization - -**Ranking:** BM25 with weights `(task_summary: 10, decisions: 5, learnings: 3, next_steps: 1)` - -**Synchronization:** Triggered on PostToolUse hook after Write - ---- - -### Index 3: Skill Activation Lookup - -**Purpose:** Fast lookup of skills by keyword. - -**Structure:** -``` -activation_index { - keyword: String → [ActivationRule.skill] -} -``` - -**Example:** -``` -{ - "tdd": ["ring:test-driven-development"], - "review": ["ring:requesting-code-review", "ring:receiving-code-review"], - "debug": ["ring:systematic-debugging", "ring:root-cause-tracing"] -} -``` - -**Synchronization:** Rebuilt when skill-rules.json changes - ---- - -## Data Retention Policies - -### Policy 1: Learning Retention - -| Condition | Action | Reason | -|-----------|--------|--------| -| `access_count = 0` AND `age > 180 days` | Soft delete | Unused for 6 months | -| `expires_at < NOW()` | Soft delete | Explicit expiration | -| `relevance_score < 0.1` | Soft delete | Decayed below threshold | -| `deleted_at < NOW() - 30 days` | Hard delete | Soft delete grace period expired | - -### Policy 2: Handoff Retention - -| Condition | Action | Reason | -|-----------|--------|--------| -| `created_at < NOW() - 1 year` AND `outcome = FAILED` | Archive | Old failed attempts | -| `created_at < NOW() - 2 years` | Archive | Very old handoffs | -| User-triggered | Delete | Manual cleanup | - -**Archive:** Move to `~/.ring/archive/` (still searchable, not in default results) - -### Policy 3: Session Log Retention - -| Condition | Action | -|-----------|--------| -| `timestamp < NOW() - 30 days` | Delete | -| `level = DEBUG` AND `timestamp < NOW() - 7 days` | Delete | - ---- - -## Data Consistency Rules - -### Consistency 1: Handoff ↔ File - -**Rule:** Handoff entity `file_path` must point to existing file. - -**Enforcement:** -- On handoff creation: Write file first, then create entity -- On handoff read: If file missing, mark handoff as `file_missing` -- On cleanup: Delete entity if file permanently missing - ---- - -### Consistency 2: Session ↔ Learning - -**Rule:** Learnings reference valid sessions. - -**Enforcement:** -- On learning creation: Validate session_id exists or is null -- On session deletion: Keep learnings (orphan relationship OK) -- Learnings can exist without sessions (imported, migrated) - ---- - -### Consistency 3: Config Precedence - -**Rule:** Project config overrides global config for same key. - -**Enforcement:** -- On config read: Merge in precedence order -- Never delete higher-precedence on lower update -- Return source attribution with value - ---- - -### Consistency 4: FTS Index Sync - -**Rule:** FTS index reflects current entity state. - -**Enforcement:** -- Triggers: Automatic sync on entity changes -- Manual: Rebuild index command available -- Validation: Check rowid exists in main table - ---- - -## Gate 5 Validation Checklist - -- [x] All entities defined with relationships (7 entities) -- [x] Data ownership is clear (User/Session/Artifact domains) -- [x] Access patterns documented (5 primary patterns) -- [x] Database-agnostic (no SQLite/PostgreSQL specifics) -- [x] Entity schemas include validation rules -- [x] Indexes designed for performance -- [x] Migration strategy for 3 data sources -- [x] Retention policies defined -- [x] Consistency rules enforced - ---- - -## Appendix: Entity Catalog - -| Entity | Domain | Key Attributes | Relationships | -|--------|--------|----------------|---------------| -| Learning | User | content, type, confidence | Session (M:1) | -| Session | Session | project_path, started_at | Learning (1:N), SessionLog (1:N) | -| Handoff | Artifact | task_summary, status, file_path | Session (M:1), ArtifactReference (1:N) | -| UserConfig | User | scope, key, value | None | -| ActivationRule | User | skill, enforcement, triggers | None | -| ArtifactReference | Artifact | artifact_type, artifact_path | Handoff (M:1) | -| SessionLog | Session | event_type, message | Session (M:1) | diff --git a/docs/pre-dev/ring-continuity/dependency-map.md b/docs/pre-dev/ring-continuity/dependency-map.md deleted file mode 100644 index efdbab3a..00000000 --- a/docs/pre-dev/ring-continuity/dependency-map.md +++ /dev/null @@ -1,893 +0,0 @@ -# Ring Continuity - Dependency Map (Gate 6) - -> **Version:** 1.0 -> **Date:** 2026-01-12 -> **Status:** Draft -> **Based on:** Data Model v1.0 (docs/pre-dev/ring-continuity/data-model.md) - ---- - -## Executive Summary - -Ring Continuity uses **Python stdlib** as much as possible to minimize external dependencies. The only required external package is **PyYAML 6.0+** for YAML serialization. All other functionality (SQLite FTS5, JSON Schema validation, file operations) uses Python 3.9+ standard library. - -**Total External Dependencies:** 1 (PyYAML) - -**Total Optional Dependencies:** 2 (jsonschema for validation, ruamel.yaml for round-trip) - ---- - -## Technology Stack - -### Core Runtime - -| Technology | Version | Pinned | Rationale | Alternatives Evaluated | -|------------|---------|--------|-----------|----------------------| -| **Python** | 3.9+ | No (minimum) | Stdlib sqlite3 with FTS5, match statements, type hints | Python 3.8 (no match), Python 3.10+ (reduces compatibility) | -| **Bash** | 4.0+ | No (minimum) | Hook scripts, associative arrays | Shell (no arrays), Zsh (not default on Linux) | -| **SQLite** | 3.35+ | No (via Python) | FTS5 support, included in Python stdlib | PostgreSQL (too heavy), Custom file format (reinventing wheel) | - -**Version Pinning Strategy:** -- **Minimum versions specified** (>=3.9, >=4.0, >=3.35) -- **No upper bounds** (allow newer versions) -- **Rationale:** Maximum compatibility, users benefit from platform updates - ---- - -### Python Packages - -| Package | Version | Required | Purpose | Alternatives Evaluated | -|---------|---------|----------|---------|----------------------| -| **PyYAML** | >=6.0,<7.0 | Yes | YAML parsing and serialization | ruamel.yaml (heavier, more features), pyyaml-include (not needed) | -| **jsonschema** | >=4.0,<5.0 | No | Validate skill-rules.json and handoff schemas | Custom validation (reinventing wheel), JSON Schema (no Python impl) | -| **ruamel.yaml** | >=0.17 | No | Round-trip YAML with comment preservation | PyYAML (loses comments), YAML 1.2 parsers (overkill) | - -**Installation:** -```bash -# Required only -pip install 'PyYAML>=6.0,<7.0' - -# With optional validation -pip install 'PyYAML>=6.0,<7.0' 'jsonschema>=4.0,<5.0' - -# With round-trip support -pip install 'PyYAML>=6.0,<7.0' 'ruamel.yaml>=0.17' -``` - -**Rationale for PyYAML 6.0+:** -1. **Security:** Safe loading by default (no `yaml.load()` footgun) -2. **Stability:** Mature, 15+ years of development -3. **Size:** Minimal (~100KB), no transitive dependencies -4. **Speed:** Fast C implementation available (LibYAML) -5. **Compatibility:** Works on all platforms - -**Alternatives Rejected:** - -| Alternative | Why Rejected | -|-------------|--------------| -| **ruamel.yaml as primary** | Heavier (500KB+), comment preservation not needed for most use cases | -| **Custom YAML parser** | Reinventing wheel, security risks | -| **TOML instead of YAML** | Less human-readable for nested data, no stdlib support | -| **JSON instead of YAML** | Lacks comments, less readable for humans | - ---- - -### Bash Dependencies - -| Dependency | Version | Required | Purpose | Alternatives | -|------------|---------|----------|---------|--------------| -| **jq** | 1.6+ | No | JSON escaping in hooks | Python fallback, sed fallback | -| **GNU date** | Any | No (macOS has BSD date) | ISO 8601 timestamps | Python datetime fallback | -| **GNU grep** | Any | No | Pattern matching in hooks | Built-in grep OK | - -**Fallback Strategy:** -```bash -# JSON escaping -if command -v jq &>/dev/null; then - escaped=$(echo "$content" | jq -Rs .) -else - # Fallback to sed - escaped=$(echo "$content" | sed 's/\\/\\\\/g; s/"/\\"/g') -fi -``` - -**Rationale:** -- **Prefer stdlib/builtin** where possible -- **Graceful degradation** if optional tools missing -- **No hard dependencies** on non-standard tools - ---- - -## Database Technology - -### Selected: SQLite 3.35+ - -**Version:** 3.35.0 or higher (for FTS5 support) - -**Why SQLite:** - -| Criterion | Rating | Notes | -|-----------|--------|-------| -| **Zero configuration** | ✓✓✓ | No server setup, just a file | -| **Included in Python** | ✓✓✓ | No separate installation | -| **FTS5 support** | ✓✓✓ | Built-in full-text search | -| **Performance** | ✓✓ | Fast for <100K rows (sufficient) | -| **Portability** | ✓✓✓ | Works everywhere Python works | -| **Size** | ✓✓✓ | Minimal footprint (<1MB for 10K learnings) | - -**Alternatives Evaluated:** - -| Alternative | Pros | Cons | Verdict | -|-------------|------|------|---------| -| **PostgreSQL** | Better FTS, pgvector for embeddings | Requires server, heavy setup | ✗ Rejected (too heavy) | -| **File-based (JSON/YAML)** | Simple, no DB | No indexing, slow search | ✗ Rejected (performance) | -| **In-memory only** | Fast | No persistence | ✗ Rejected (defeats purpose) | -| **Redis** | Fast, good for caching | Requires server | ✗ Rejected (external service) | -| **TinyDB** | Pure Python | No FTS, slow | ✗ Rejected (no search) | - -**Version Pinning:** -``` -SQLite: >=3.35.0 (no upper bound) -``` - -**Feature Requirements:** -- FTS5 virtual tables -- Triggers for automatic index sync -- JSON1 extension for metadata queries -- Window functions for ranking - -**Verification Command:** -```bash -python3 -c "import sqlite3; print(sqlite3.sqlite_version)" -# Must output: 3.35.0 or higher -``` - ---- - -## YAML Processing - -### Selected: PyYAML 6.0+ - -**Version:** >=6.0,<7.0 - -**Why PyYAML:** - -| Criterion | Rating | Notes | -|-----------|--------|-------| -| **Maturity** | ✓✓✓ | 15+ years, stable API | -| **Security** | ✓✓✓ | Safe loading by default in 6.0+ | -| **Performance** | ✓✓✓ | C extension (LibYAML) | -| **Size** | ✓✓✓ | Minimal, no transitive deps | -| **Compatibility** | ✓✓✓ | Python 3.9+ support | -| **Features** | ✓✓ | Full YAML 1.1 support | - -**Alternatives Evaluated:** - -| Alternative | Pros | Cons | Verdict | -|-------------|------|------|---------| -| **ruamel.yaml** | YAML 1.2, round-trip, comments | Heavier, more complex API | ✗ Use for optional round-trip only | -| **strictyaml** | Type-safe, no tags | Limited features, smaller ecosystem | ✗ Rejected (too restrictive) | -| **pyyaml-include** | Include directives | Niche feature, adds complexity | ✗ Rejected (not needed) | - -**Version Pinning:** -``` -PyYAML>=6.0,<7.0 -``` - -**Usage:** -```python -import yaml - -# Loading (always safe_load) -with open('handoff.yaml') as f: - data = yaml.safe_load(f) - -# Dumping -with open('handoff.yaml', 'w') as f: - yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True) -``` - -**Security Considerations:** -- **Never use `yaml.load()`** (arbitrary code execution) -- **Always use `yaml.safe_load()`** (safe types only) -- **Validate schemas** after loading - ---- - -## Schema Validation - -### Selected: jsonschema 4.0+ - -**Version:** >=4.0,<5.0 - -**Why jsonschema:** - -| Criterion | Rating | Notes | -|-----------|--------|-------| -| **Standard support** | ✓✓✓ | Draft 2020-12 support | -| **Validation features** | ✓✓✓ | Full JSON Schema spec | -| **Error messages** | ✓✓ | Detailed validation errors | -| **Performance** | ✓ | Adequate for <1000 rules | -| **Optional** | ✓✓✓ | Ring works without it | - -**Alternatives Evaluated:** - -| Alternative | Pros | Cons | Verdict | -|-------------|------|------|---------| -| **Custom validation** | No dependency | Reinventing wheel, bugs | ✗ Rejected (maintenance burden) | -| **Pydantic** | Type hints, great DX | Heavy (15+ deps), overkill | ✗ Rejected (too heavy) | -| **python-jsonschema-objects** | Class generation | Unmaintained | ✗ Rejected (stale) | - -**Version Pinning:** -``` -jsonschema>=4.0,<5.0 -``` - -**Usage:** -```python -from jsonschema import validate, ValidationError - -schema = { - "type": "object", - "required": ["session", "task"], - "properties": { - "session": {"type": "object"}, - "task": {"type": "object"} - } -} - -try: - validate(instance=data, schema=schema) -except ValidationError as e: - print(f"Validation failed: {e.message}") -``` - -**Graceful Degradation:** -- If jsonschema not installed, skip validation -- Log warning about missing validation -- Rely on YAML parser for basic type checking - ---- - -## Hook System Technologies - -### Selected: Bash 4.0+ with Python Helpers - -**Bash Version:** >=4.0 - -**Why Bash for Hooks:** - -| Criterion | Rating | Notes | -|-----------|--------|-------| -| **Startup speed** | ✓✓✓ | <10ms for simple hooks | -| **Simplicity** | ✓✓ | Good for file ops, JSON output | -| **Compatibility** | ✓✓✓ | Available on all Unix systems | -| **Integration** | ✓✓✓ | Easy to call Python when needed | - -**Bash Features Required:** -- Associative arrays (Bash 4.0+) -- Process substitution -- Command substitution -- Here-documents for JSON - -**Python Helper Usage:** -```bash -# For complex operations, delegate to Python -result=$(python3 "${PLUGIN_ROOT}/lib/memory_search.py" "$query") -``` - -**Alternatives Evaluated:** - -| Alternative | Pros | Cons | Verdict | -|-------------|------|------|---------| -| **Pure Python hooks** | Better error handling | Slower startup (~50ms) | ✗ Use Python only when needed | -| **TypeScript hooks** | Type safety, better tooling | Compilation step, Node.js dep | ✗ Rejected (complexity) | -| **Shell (sh)** | Maximum compatibility | No arrays, limited features | ✗ Rejected (too limited) | - ---- - -## File Formats - -### Configuration: YAML - -**Selected:** YAML 1.1 (PyYAML default) - -**Rationale:** -- Human-editable -- Comments supported -- Hierarchical structure -- Python stdlib-compatible (via PyYAML) - -**Example:** -```yaml -# ~/.ring/config.yaml -memory: - enabled: true - max_memories: 10000 - decay: - enabled: true - half_life_days: 180 - -skill_activation: - enabled: true - enforcement_default: suggest - -handoffs: - format: yaml # yaml or markdown - auto_index: true -``` - ---- - -### Skill Rules: JSON - -**Selected:** JSON (not YAML) for skill-rules.json - -**Rationale:** -- Schema validation with jsonschema -- Faster parsing than YAML -- No ambiguity (YAML has edge cases) -- Standard for rule engines - -**Example:** -```json -{ - "version": "1.0", - "skills": { - "ring:test-driven-development": { - "type": "guardrail", - "enforcement": "block", - "priority": "critical", - "promptTriggers": { - "keywords": ["tdd", "test driven"], - "intentPatterns": ["write.*test.*first", "red.*green.*refactor"] - } - } - } -} -``` - ---- - -### Handoffs: YAML with Frontmatter - -**Selected:** YAML with --- delimiters - -**Rationale:** -- Machine-parseable frontmatter -- Human-readable body -- Compatible with existing Markdown frontmatter pattern -- Token-efficient - -**Example:** -```yaml ---- -version: "1.0" -schema: ring-handoff-v1 -session: - id: context-management - started_at: 2026-01-12T10:00:00Z -task: - description: Implement memory system - status: completed ---- - -# Session Notes - -Additional context in Markdown format... -``` - ---- - -## Platform Dependencies - -### Operating System - -| Platform | Support Level | Notes | -|----------|---------------|-------| -| **macOS** | ✓✓✓ Primary | Darwin kernel, BSD userland | -| **Linux** | ✓✓✓ Primary | GNU userland | -| **Windows (WSL)** | ✓✓ Compatible | Via WSL2 with Linux tools | -| **Windows (native)** | ✗ Not supported | Bash hooks incompatible | - -**Platform Detection:** -```bash -case "$(uname -s)" in - Darwin*) PLATFORM="macos" ;; - Linux*) PLATFORM="linux" ;; - *) PLATFORM="unknown" ;; -esac -``` - ---- - -### File System Requirements - -| Requirement | Purpose | Fallback | -|-------------|---------|----------| -| **Home directory writable** | ~/.ring/ creation | Use .ring/ in project | -| **Symlink support** | Avoid duplication | Copy files if symlinks unsupported | -| **UTF-8 filenames** | Unicode session names | ASCII-safe fallback | -| **POSIX paths** | Cross-platform compatibility | - | - ---- - -## Development Dependencies - -### Testing - -| Package | Version | Purpose | -|---------|---------|---------| -| **pytest** | >=7.0,<8.0 | Unit and integration tests | -| **pytest-asyncio** | >=0.21 | Async test support (if needed) | -| **coverage** | >=7.0 | Code coverage tracking | - -**Why pytest:** -- Industry standard -- Fixture system for test data -- Parametrized tests -- Plugin ecosystem - -**Installation:** -```bash -pip install 'pytest>=7.0,<8.0' 'coverage>=7.0' -``` - ---- - -### Linting & Formatting - -| Tool | Version | Purpose | Config | -|------|---------|---------|--------| -| **shellcheck** | latest | Bash linting | .shellcheckrc | -| **black** | >=24.0 | Python formatting | pyproject.toml | -| **ruff** | >=0.1 | Python linting | pyproject.toml | - -**Why These Tools:** -- **shellcheck:** Catches Bash bugs before execution -- **black:** Consistent formatting, zero config -- **ruff:** Fast (Rust-based), replaces flake8/pylint/isort - -**Installation:** -```bash -# Homebrew (macOS) -brew install shellcheck - -# Python tools -pip install 'black>=24.0' 'ruff>=0.1' -``` - ---- - -## Optional Enhancements - -### Optional: Round-Trip YAML (ruamel.yaml) - -**Use Case:** Preserve comments when updating config files programmatically. - -**When Needed:** -- Auto-updating skill-rules.json while keeping user comments -- Config migration with comment preservation - -**Installation:** -```bash -pip install 'ruamel.yaml>=0.17' -``` - -**Usage:** -```python -from ruamel.yaml import YAML - -yaml = YAML() -yaml.preserve_quotes = True -yaml.default_flow_style = False - -with open('config.yaml') as f: - data = yaml.load(f) - -# Modify data -data['memory']['enabled'] = True - -with open('config.yaml', 'w') as f: - yaml.dump(data, f) -# Comments preserved! -``` - ---- - -### Optional: Schema Validation (jsonschema) - -**Use Case:** Validate skill-rules.json and handoff YAML against schemas. - -**When Needed:** -- Development: Catch schema errors early -- Production: Validate user-edited configs -- Testing: Ensure test data is valid - -**Installation:** -```bash -pip install 'jsonschema>=4.0,<5.0' -``` - -**Usage:** -```python -from jsonschema import validate, Draft202012Validator - -validator = Draft202012Validator(schema) -errors = list(validator.iter_errors(data)) -if errors: - for error in errors: - print(f"{error.json_path}: {error.message}") -``` - -**Graceful Degradation:** -- If not installed, skip schema validation -- Basic type checking via YAML parser -- Runtime errors caught during execution - ---- - -## Version Compatibility Matrix - -### Python Version Support - -| Python Version | Support | Notes | -|----------------|---------|-------| -| **3.9** | ✓✓✓ Minimum | Match statements, type hints | -| **3.10** | ✓✓✓ Recommended | Pattern matching improvements | -| **3.11** | ✓✓✓ Supported | Performance improvements | -| **3.12** | ✓✓✓ Supported | Latest stable | -| **3.8** | ✗ Not supported | No match statements | -| **3.13+** | ✓ Untested | Should work | - ---- - -### SQLite Version Support - -| SQLite Version | FTS5 | JSON1 | Window Functions | Verdict | -|----------------|------|-------|------------------|---------| -| **3.35+** | ✓ | ✓ | ✓ | ✓✓✓ Recommended | -| **3.32-3.34** | ✓ | ✓ | ✓ | ✓✓ Compatible (missing some FTS5 features) | -| **3.24-3.31** | ✓ | ✓ | ✗ | ✓ Partial (no window functions) | -| **<3.24** | ✗ | - | - | ✗ Not supported | - -**Version Detection:** -```python -import sqlite3 -version = tuple(map(int, sqlite3.sqlite_version.split('.'))) -if version < (3, 35, 0): - raise RuntimeError(f"SQLite 3.35+ required, found {sqlite3.sqlite_version}") -``` - ---- - -### PyYAML Version Support - -| PyYAML Version | Python 3.9 | Python 3.12 | safe_load default | Verdict | -|----------------|------------|-------------|-------------------|---------| -| **6.0+** | ✓ | ✓ | ✓ | ✓✓✓ Recommended | -| **5.4** | ✓ | ✓ | ✗ | ✓ Compatible (manual safe_load) | -| **5.3** | ✓ | ✗ | ✗ | ✗ Not compatible with Python 3.12 | -| **<5.3** | ✗ | ✗ | ✗ | ✗ Not supported | - ---- - -## Installation Strategy - -### Installation Methods - -| Method | Target User | Command | -|--------|-------------|---------| -| **Install script** | End users | `curl ... \| bash` | -| **Manual pip** | Developers | `pip install -r requirements.txt` | -| **System package** | Enterprise | `apt install ring-plugin` (future) | - ---- - -### Install Script Flow - -```bash -#!/usr/bin/env bash -# install-ring-continuity.sh - -set -euo pipefail - -echo "🔄 Installing Ring Continuity..." - -# 1. Check Python version -python_version=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))') -if [[ "${python_version}" < "3.9" ]]; then - echo "❌ Python 3.9+ required, found ${python_version}" - exit 1 -fi - -# 2. Check SQLite version -sqlite_version=$(python3 -c 'import sqlite3; print(sqlite3.sqlite_version)') -if [[ "${sqlite_version}" < "3.35.0" ]]; then - echo "⚠️ SQLite 3.35+ recommended, found ${sqlite_version}" - echo " FTS5 features may be limited" -fi - -# 3. Install PyYAML -echo "📦 Installing PyYAML..." -pip3 install --user 'PyYAML>=6.0,<7.0' - -# 4. Optional: Install jsonschema -read -p "Install jsonschema for validation? (y/N) " -n 1 -r -if [[ $REPLY =~ ^[Yy]$ ]]; then - pip3 install --user 'jsonschema>=4.0,<5.0' -fi - -# 5. Create ~/.ring/ structure -echo "📁 Creating ~/.ring/ directory..." -mkdir -p ~/.ring/{handoffs,logs,cache,state} - -# 6. Initialize databases -echo "💾 Initializing memory database..." -python3 - <<'EOF' -import sqlite3 -from pathlib import Path - -db_path = Path.home() / '.ring' / 'memory.db' -conn = sqlite3.connect(str(db_path)) -# Execute schema creation... -conn.close() -EOF - -# 7. Create default config -if [[ ! -f ~/.ring/config.yaml ]]; then - cat > ~/.ring/config.yaml <<'EOF' -memory: - enabled: true - max_memories: 10000 - -skill_activation: - enabled: true - enforcement_default: suggest - -handoffs: - format: yaml - auto_index: true -EOF -fi - -# 8. Create default skill rules -if [[ ! -f ~/.ring/skill-rules.json ]]; then - echo '{"version": "1.0", "skills": {}}' > ~/.ring/skill-rules.json -fi - -echo "✅ Ring Continuity installed successfully!" -echo " Location: ~/.ring/" -echo " Config: ~/.ring/config.yaml" -echo " Memory: ~/.ring/memory.db" -``` - ---- - -### Requirements File - -**File:** `requirements.txt` - -```txt -# Ring Continuity Requirements -# Python 3.9+ required - -# Core dependencies (required) -PyYAML>=6.0,<7.0 - -# Optional dependencies -jsonschema>=4.0,<5.0 # For schema validation -ruamel.yaml>=0.17 # For round-trip YAML with comments - -# Development dependencies -pytest>=7.0,<8.0 -coverage>=7.0,<8.0 -black>=24.0 -ruff>=0.1 -``` - ---- - -## Dependency Justification - -### Why Minimize Dependencies? - -| Reason | Impact | -|--------|--------| -| **Easier installation** | Users don't need to manage many packages | -| **Fewer conflicts** | Less chance of version conflicts | -| **Faster startup** | Fewer imports = faster hook execution | -| **Better reliability** | Fewer points of failure | -| **Simpler maintenance** | Less to update and test | - -### Why PyYAML is Worth It - -| Benefit | Value | -|---------|-------| -| **YAML ubiquity** | Industry standard for config | -| **Human-editable** | Users can edit handoffs manually | -| **Structured data** | Better than JSON for readability | -| **Comment support** | Document configs inline | -| **5x token savings** | From ~2000 (Markdown) to ~400 (YAML) | - -**Token savings calculation:** -``` -Markdown handoff: ~2000 tokens (measured from examples) -YAML handoff: ~400 tokens (estimated from schema) -Savings: 1600 tokens per handoff -Cost: 1 dependency (PyYAML) -ROI: 1600 tokens / 1 dependency = Excellent -``` - ---- - -## Build & Distribution - -### Build Process - -**No build step required** for end users: -- Bash scripts are interpreted -- Python scripts are interpreted -- YAML/JSON configs are data files - -**Development build:** -```bash -# Lint Bash hooks -shellcheck default/hooks/*.sh - -# Format Python scripts -black default/lib/ - -# Lint Python scripts -ruff check default/lib/ - -# Run tests -pytest tests/ -``` - ---- - -### Distribution Format - -**Method:** Git repository + install script - -**Structure:** -``` -ring/ -├── default/ -│ ├── hooks/ -│ │ ├── session-start.sh -│ │ ├── user-prompt-submit.sh -│ │ └── ... -│ ├── lib/ -│ │ ├── memory_repository.py -│ │ ├── handoff_serializer.py -│ │ ├── skill_activator.py -│ │ └── ... -│ └── skills/ -│ └── ring:handoff-tracking/ -│ └── SKILL.md (updated) -├── install-ring-continuity.sh -└── requirements.txt -``` - -**No packaging needed:** -- Scripts run in place -- No compilation -- No bundling - ---- - -## Dependency Security - -### Security Considerations - -| Package | Supply Chain Risk | Mitigation | -|---------|-------------------|------------| -| **PyYAML** | Low (mature, widely used) | Pin major version, use safe_load only | -| **jsonschema** | Low (optional, validation only) | Pin major version, optional install | -| **sqlite3** | None (stdlib) | Use parameterized queries | - -### Security Best Practices - -1. **Pin major versions** to prevent breaking changes -2. **Use safe APIs** (yaml.safe_load, not yaml.load) -3. **Parameterize queries** (prevent SQL injection) -4. **Validate inputs** before processing -5. **Regular updates** for security patches - ---- - -## Runtime Environment - -### Environment Variables - -| Variable | Required | Default | Purpose | -|----------|----------|---------|---------| -| `CLAUDE_PLUGIN_ROOT` | Yes | - | Plugin directory | -| `CLAUDE_PROJECT_DIR` | Yes | - | Current project | -| `CLAUDE_SESSION_ID` | Yes | - | Session identifier | -| `RING_HOME` | No | `~/.ring` | Override home directory | -| `RING_LOG_LEVEL` | No | `INFO` | Logging verbosity | - -**Detection Script:** -```bash -# Detect Ring home directory -if [[ -n "${RING_HOME:-}" ]]; then - RING_HOME="${RING_HOME}" -elif [[ -d "${HOME}/.ring" ]]; then - RING_HOME="${HOME}/.ring" -else - echo "Creating ~/.ring/ directory..." - mkdir -p "${HOME}/.ring" - RING_HOME="${HOME}/.ring" -fi -``` - ---- - -### System Requirements - -| Resource | Minimum | Recommended | Purpose | -|----------|---------|-------------|---------| -| **Disk space** | 10 MB | 100 MB | Memory DB, handoffs, logs | -| **Memory (RAM)** | 50 MB | 200 MB | Python processes, SQLite cache | -| **Python heap** | Default | Default | No large allocations | - ---- - -## Gate 6 Validation Checklist - -- [x] All technologies selected with versions pinned -- [x] Rationale provided for each selection (Why this? Why not alternatives?) -- [x] Alternatives evaluated with pros/cons tables -- [x] Version compatibility matrix documented -- [x] Installation strategy defined (install script + requirements.txt) -- [x] Platform support specified (macOS, Linux) -- [x] Build process documented (none for users, lint/test for devs) -- [x] Security considerations addressed -- [x] Tech stack is complete and actionable - ---- - -## Appendix: Dependency Catalog - -### Required Dependencies - -| Dependency | Version | Type | Installation | -|------------|---------|------|--------------| -| Python | >=3.9 | Runtime | System package manager | -| Bash | >=4.0 | Runtime | Included with OS | -| PyYAML | >=6.0,<7.0 | Library | pip install | -| SQLite | >=3.35 | Runtime | Included with Python | - -### Optional Dependencies - -| Dependency | Version | Type | Purpose | -|------------|---------|------|---------| -| jsonschema | >=4.0,<5.0 | Library | Schema validation | -| ruamel.yaml | >=0.17 | Library | Round-trip YAML | -| jq | >=1.6 | CLI Tool | JSON processing in hooks | - -### Development Dependencies - -| Dependency | Version | Type | Purpose | -|------------|---------|------|---------| -| pytest | >=7.0,<8.0 | Library | Testing | -| coverage | >=7.0,<8.0 | Library | Coverage tracking | -| black | >=24.0 | Tool | Code formatting | -| ruff | >=0.1 | Tool | Linting | -| shellcheck | latest | Tool | Bash linting | - ---- - -## Technology Decision Summary - -| Decision | Rationale | Risk | -|----------|-----------|------| -| **SQLite over PostgreSQL** | Zero config, included in Python | Limited for very large datasets (>100K memories) | -| **PyYAML over ruamel.yaml** | Lighter, simpler API | No round-trip support (acceptable trade-off) | -| **JSON for rules, YAML for config** | JSON has better schema validation | Slightly less readable | -| **Bash for hooks** | Fast startup, good for simple ops | Complex operations delegate to Python | -| **FTS5 over embeddings** | Sufficient for keyword search, no deps | Weaker semantic search (acceptable for MVP) | -| **Python 3.9 minimum** | Match statements, modern typing | Excludes older systems (acceptable) | diff --git a/docs/pre-dev/ring-continuity/feature-map.md b/docs/pre-dev/ring-continuity/feature-map.md deleted file mode 100644 index 10927b54..00000000 --- a/docs/pre-dev/ring-continuity/feature-map.md +++ /dev/null @@ -1,423 +0,0 @@ -# Ring Continuity - Feature Map (Gate 2) - -> **Version:** 1.0 -> **Date:** 2026-01-12 -> **Status:** Draft -> **Based on:** PRD v1.0 (docs/pre-dev/ring-continuity/prd.md) - ---- - -## Executive Summary - -Ring Continuity consists of **5 interconnected feature domains** that work together to create an intelligent, learning assistant. The feature map reveals a **layered dependency structure**: Foundation layer (~/.ring/), Storage layer (Memory + Handoffs), Enhancement layer (Confidence + Activation), and Integration layer (Hooks + Skills). - ---- - -## Feature Domain Map - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ INTEGRATION LAYER │ -│ ┌──────────────────────────┐ ┌──────────────────────────┐ │ -│ │ Hook Integration │ │ Skill Enhancement │ │ -│ │ • SessionStart │ │ • Agent outputs │ │ -│ │ • UserPromptSubmit │ │ • Skill definitions │ │ -│ │ • PostToolUse │ │ • Command wrappers │ │ -│ └──────────────────────────┘ └──────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ▲ - │ -┌─────────────────────────────────────────────────────────────────┐ -│ ENHANCEMENT LAYER │ -│ ┌──────────────────────────┐ ┌──────────────────────────┐ │ -│ │ Confidence Markers │ │ Skill Activation │ │ -│ │ • Verification status │ │ • Rules engine │ │ -│ │ • Evidence chains │ │ • Pattern matching │ │ -│ │ • Trust calibration │ │ • Auto-suggestion │ │ -│ └──────────────────────────┘ └──────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ▲ - │ -┌─────────────────────────────────────────────────────────────────┐ -│ STORAGE LAYER │ -│ ┌──────────────────────────┐ ┌──────────────────────────┐ │ -│ │ Persistent Memory │ │ YAML Handoffs │ │ -│ │ • SQLite + FTS5 │ │ • Compact format │ │ -│ │ • Learning types │ │ • Schema validation │ │ -│ │ • Decay mechanism │ │ • Backward compat │ │ -│ └──────────────────────────┘ └──────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ▲ - │ -┌─────────────────────────────────────────────────────────────────┐ -│ FOUNDATION LAYER │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Global Ring Directory (~/.ring/) │ │ -│ │ • Directory structure │ │ -│ │ • Configuration management │ │ -│ │ • Migration utilities │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Feature Domain Breakdown - -### Domain 1: Foundation - Global Ring Directory - -**Purpose:** Establish ~/.ring/ as the global home for Ring data and configuration. - -**Features:** - -| Feature | Description | Priority | -|---------|-------------|----------| -| F1.1 - Directory Creation | Create ~/.ring/ on first use | P0 | -| F1.2 - Config Management | Load/merge global and project configs | P0 | -| F1.3 - Migration Utilities | Move existing data to ~/.ring/ | P1 | -| F1.4 - Cleanup Utilities | Remove old/stale data | P2 | - -**Scope:** -- IN: Global directory structure, config precedence, migration -- OUT: Cloud sync, multi-user support - -**Dependencies:** -- None (foundation layer) - ---- - -### Domain 2: Storage - Persistent Memory - -**Purpose:** Store and retrieve learnings across sessions using SQLite FTS5. - -**Features:** - -| Feature | Description | Priority | -|---------|-------------|----------| -| F2.1 - Memory Database | SQLite with FTS5 schema | P0 | -| F2.2 - Memory Storage | Store learnings with type classification | P0 | -| F2.3 - Memory Retrieval | FTS5 search with BM25 ranking | P0 | -| F2.4 - Memory Awareness | Hook to inject relevant memories | P1 | -| F2.5 - Memory Decay | Reduce relevance over time | P2 | -| F2.6 - Memory Expiration | Auto-delete expired memories | P2 | - -**Scope:** -- IN: 7 learning types, FTS5 search, decay mechanism -- OUT: Embeddings, semantic search, real-time updates - -**Dependencies:** -- F1.1 (Directory Creation) - needs ~/.ring/memory.db location - ---- - -### Domain 3: Storage - YAML Handoffs - -**Purpose:** Replace Markdown handoffs with token-efficient YAML format. - -**Features:** - -| Feature | Description | Priority | -|---------|-------------|----------| -| F3.1 - YAML Schema | Define handoff YAML structure | P0 | -| F3.2 - YAML Writer | Create handoffs in YAML format | P0 | -| F3.3 - YAML Reader | Parse YAML handoffs | P0 | -| F3.4 - Schema Validation | Validate against JSON Schema | P0 | -| F3.5 - Backward Compatibility | Read legacy Markdown handoffs | P1 | -| F3.6 - Migration Tool | Convert Markdown to YAML | P1 | -| F3.7 - Auto-Indexing | Index YAML handoffs in FTS5 | P1 | - -**Scope:** -- IN: YAML format, validation, migration, FTS5 indexing -- OUT: Markdown generation, HTML export - -**Dependencies:** -- F1.1 (Directory Creation) - needs ~/.ring/handoffs/ location -- Existing ring:handoff-tracking skill - extends, not replaces - ---- - -### Domain 4: Enhancement - Confidence Markers - -**Purpose:** Add verification status to agent outputs to reduce false claims. - -**Features:** - -| Feature | Description | Priority | -|---------|-------------|----------| -| F4.1 - Marker Vocabulary | Define ✓/?/✗ symbols and meanings | P0 | -| F4.2 - Agent Output Schema | Add confidence section to schemas | P0 | -| F4.3 - Explorer Integration | Add markers to ring:codebase-explorer | P0 | -| F4.4 - Evidence Chains | Track how conclusions were reached | P1 | -| F4.5 - Trust Calibration | Display confidence visually | P2 | - -**Scope:** -- IN: Structured markers, evidence chains, schema updates -- OUT: Natural language epistemic markers, confidence scores - -**Dependencies:** -- Existing agent output schemas (docs/AGENT_DESIGN.md) - ---- - -### Domain 5: Enhancement - Skill Activation - -**Purpose:** Auto-suggest relevant skills based on user prompt patterns. - -**Features:** - -| Feature | Description | Priority | -|---------|-------------|----------| -| F5.1 - Rules Schema | Define skill-rules.json structure | P0 | -| F5.2 - Keyword Matching | Match prompt keywords to skills | P0 | -| F5.3 - Intent Matching | Regex pattern matching | P1 | -| F5.4 - Activation Hook | UserPromptSubmit hook integration | P0 | -| F5.5 - Enforcement Levels | Block/suggest/warn behaviors | P0 | -| F5.6 - Negative Patterns | Exclude false positives | P1 | -| F5.7 - Project Overrides | Project rules override global | P1 | - -**Scope:** -- IN: JSON Schema rules, three-tier enforcement, pattern matching -- OUT: Machine learning, semantic embeddings, user training - -**Dependencies:** -- F1.1 (Directory Creation) - needs ~/.ring/skill-rules.json location -- Existing skill frontmatter (trigger/skip_when fields) - ---- - -## Feature Relationships - -### Dependency Graph - -``` -F1.1 (Directory Creation) - ├─> F2.1 (Memory Database) - │ └─> F2.2 (Memory Storage) - │ └─> F2.3 (Memory Retrieval) - │ └─> F2.4 (Memory Awareness Hook) - │ └─> F2.5 (Memory Decay) - │ └─> F2.6 (Memory Expiration) - │ - ├─> F3.1 (YAML Schema) - │ ├─> F3.2 (YAML Writer) - │ └─> F3.3 (YAML Reader) - │ ├─> F3.4 (Schema Validation) - │ ├─> F3.5 (Backward Compatibility) - │ └─> F3.6 (Migration Tool) - │ └─> F3.7 (Auto-Indexing) - │ - └─> F5.1 (Rules Schema) - ├─> F5.2 (Keyword Matching) - ├─> F5.3 (Intent Matching) - └─> F5.4 (Activation Hook) - ├─> F5.5 (Enforcement Levels) - ├─> F5.6 (Negative Patterns) - └─> F5.7 (Project Overrides) - -F4.1 (Marker Vocabulary) - └─> F4.2 (Agent Output Schema) - └─> F4.3 (Explorer Integration) - ├─> F4.4 (Evidence Chains) - └─> F4.5 (Trust Calibration) -``` - -### Critical Path - -The minimum viable feature set for Ring Continuity: - -1. **F1.1** - Directory Creation (foundation) -2. **F3.1** - YAML Schema (handoff format) -3. **F3.2** - YAML Writer (handoff creation) -4. **F4.1** - Marker Vocabulary (confidence markers) -5. **F4.2** - Agent Output Schema (apply markers) -6. **F5.1** - Rules Schema (skill activation) -7. **F5.2** - Keyword Matching (basic activation) - -**Estimated:** 2-3 days for MVP - -### Optional Extensions - -Can be deferred without breaking core functionality: - -- F2.4 (Memory Awareness Hook) -- F2.5 (Memory Decay) -- F3.5 (Backward Compatibility) -- F5.3 (Intent Matching - regex) -- F5.6 (Negative Patterns) - ---- - -## Domain Boundaries - -### Physical Boundaries - -| Domain | Files/Directories | Responsibility | -|--------|-------------------|----------------| -| **Foundation** | `~/.ring/`, `~/.ring/config.yaml` | Directory structure, config | -| **Memory** | `~/.ring/memory.db` | Persistent learning storage | -| **Handoffs** | `~/.ring/handoffs/`, `docs/handoffs/` | Session continuity | -| **Activation** | `~/.ring/skill-rules.json` | Skill routing | -| **Confidence** | Agent schemas in `docs/AGENT_DESIGN.md` | Output verification | - -### Logical Boundaries - -| Domain | Concerns | NOT Responsible For | -|--------|----------|---------------------| -| **Foundation** | Directory creation, config loading | Data storage, processing | -| **Memory** | Storage, retrieval, decay | Hook integration, display | -| **Handoffs** | YAML serialization, validation | Handoff creation logic (in skill) | -| **Activation** | Pattern matching, suggestion | Skill execution | -| **Confidence** | Schema definition, markers | Agent implementation | - ---- - -## Integration Points - -### Hook Integration Points - -| Hook Event | Features Integrated | Purpose | -|------------|---------------------|---------| -| **SessionStart** | F1.2 (Config), F2.4 (Memory Awareness) | Load config, inject memories | -| **UserPromptSubmit** | F5.4 (Activation Hook) | Suggest relevant skills | -| **PostToolUse (Write)** | F3.7 (Auto-Indexing) | Index new handoffs | -| **PreCompact** | (Future) Auto-handoff creation | Save state before compact | -| **Stop** | (Future) Learning extraction | Extract session learnings | - -### Skill Integration Points - -| Skill | Features Integrated | Changes Required | -|-------|---------------------|------------------| -| **ring:handoff-tracking** | F3.2 (YAML Writer), F3.4 (Validation) | Output YAML instead of Markdown | -| **continuity-ledger** | F3.2 (YAML Writer) | Optional YAML format | -| **artifact-query** | F2.3 (Memory Retrieval) | Query both artifacts and memories | -| **ring:codebase-explorer** | F4.3 (Explorer Integration) | Add confidence markers to output | - -### Agent Integration Points - -| Agent | Features Integrated | Changes Required | -|-------|---------------------|------------------| -| **All exploration agents** | F4.2 (Agent Output Schema) | Add "Verification" section | -| **ring:codebase-explorer** | F4.3, F4.4 (Markers + Evidence) | Include evidence chain | - ---- - -## Feature Interactions - -### Positive Interactions (Synergies) - -| Feature A | Feature B | Synergy | -|-----------|-----------|---------| -| F2.3 (Memory Retrieval) | F5.4 (Activation Hook) | Memory informs which skills to suggest | -| F3.7 (Auto-Indexing) | F2.3 (Memory Retrieval) | Handoffs become searchable memories | -| F4.3 (Explorer Integration) | F2.2 (Memory Storage) | Verified findings stored as high-confidence memories | -| F5.5 (Enforcement Levels) | F4.1 (Marker Vocabulary) | Block-level skills can require verification | - -### Negative Interactions (Conflicts) - -| Feature A | Feature B | Conflict | Resolution | -|-----------|-----------|----------|------------| -| F3.5 (Backward Compat) | F3.2 (YAML Writer) | Dual format support | Indexer supports both | -| F2.5 (Memory Decay) | F2.2 (Memory Storage) | When to apply decay? | Apply on retrieval, not storage | -| F5.7 (Project Overrides) | F1.2 (Config Management) | Precedence rules | Document: project > global > default | - ---- - -## Scope Visualization - -### Feature Distribution by Phase - -``` -Phase 1 (Foundation): -├─ F1.1 ■■■■■■■■■■ Directory Creation -├─ F1.2 ■■■■■■■□□□ Config Management (partial) -└─ F1.3 ■■■■■□□□□□ Migration Utilities (basic) - -Phase 2 (YAML Handoffs): -├─ F3.1 ■■■■■■■■■■ YAML Schema -├─ F3.2 ■■■■■■■■■■ YAML Writer -├─ F3.3 ■■■■■■■■■■ YAML Reader -├─ F3.4 ■■■■■■■■■■ Schema Validation -└─ F3.5 ■■■■■■■□□□ Backward Compatibility (partial) - -Phase 3 (Confidence Markers): -├─ F4.1 ■■■■■■■■■■ Marker Vocabulary -├─ F4.2 ■■■■■■■■■■ Agent Output Schema -└─ F4.3 ■■■■■■■■■■ Explorer Integration - -Phase 4 (Skill Activation): -├─ F5.1 ■■■■■■■■■■ Rules Schema -├─ F5.2 ■■■■■■■■■■ Keyword Matching -├─ F5.4 ■■■■■■■■■■ Activation Hook -├─ F5.5 ■■■■■■■■■■ Enforcement Levels -└─ F5.3 ■■■■■□□□□□ Intent Matching (defer) - -Phase 5 (Persistent Memory): -├─ F2.1 ■■■■■■■■■■ Memory Database -├─ F2.2 ■■■■■■■■■■ Memory Storage -├─ F2.3 ■■■■■■■■■■ Memory Retrieval -├─ F2.4 ■■■■■■■□□□ Memory Awareness Hook -└─ F2.5 ■■■■■□□□□□ Memory Decay (defer) -``` - -### Risk by Feature - -| Feature | Complexity | Risk Level | Mitigation | -|---------|------------|------------|------------| -| F1.1 | Low | Low | Straightforward directory creation | -| F2.1 | Medium | Medium | Use existing FTS5 patterns | -| F3.2 | Low | Low | YAML serialization is stdlib | -| F4.2 | Low | Low | Schema addition only | -| F5.4 | High | High | Complex hook timing, false positives | - ---- - -## Gate 2 Validation Checklist - -- [x] All features from PRD mapped to domains -- [x] Feature relationships documented (dependency graph) -- [x] Domain boundaries defined (physical and logical) -- [x] Feature interactions identified (synergies and conflicts) -- [x] Integration points specified (hooks, skills, agents) -- [x] Scope visualization provided (phase distribution) -- [x] Critical path identified for MVP -- [x] Risk assessment per feature - ---- - -## Appendix: Feature Catalog - -### Complete Feature List - -| ID | Feature Name | Domain | Priority | Dependencies | -|----|--------------|--------|----------|--------------| -| F1.1 | Directory Creation | Foundation | P0 | None | -| F1.2 | Config Management | Foundation | P0 | F1.1 | -| F1.3 | Migration Utilities | Foundation | P1 | F1.1 | -| F1.4 | Cleanup Utilities | Foundation | P2 | F1.1 | -| F2.1 | Memory Database | Memory | P0 | F1.1 | -| F2.2 | Memory Storage | Memory | P0 | F2.1 | -| F2.3 | Memory Retrieval | Memory | P0 | F2.2 | -| F2.4 | Memory Awareness | Memory | P1 | F2.3 | -| F2.5 | Memory Decay | Memory | P2 | F2.3 | -| F2.6 | Memory Expiration | Memory | P2 | F2.5 | -| F3.1 | YAML Schema | Handoffs | P0 | F1.1 | -| F3.2 | YAML Writer | Handoffs | P0 | F3.1 | -| F3.3 | YAML Reader | Handoffs | P0 | F3.1 | -| F3.4 | Schema Validation | Handoffs | P0 | F3.3 | -| F3.5 | Backward Compatibility | Handoffs | P1 | F3.3 | -| F3.6 | Migration Tool | Handoffs | P1 | F3.5 | -| F3.7 | Auto-Indexing | Handoffs | P1 | F3.6 | -| F4.1 | Marker Vocabulary | Confidence | P0 | None | -| F4.2 | Agent Output Schema | Confidence | P0 | F4.1 | -| F4.3 | Explorer Integration | Confidence | P0 | F4.2 | -| F4.4 | Evidence Chains | Confidence | P1 | F4.3 | -| F4.5 | Trust Calibration | Confidence | P2 | F4.4 | -| F5.1 | Rules Schema | Activation | P0 | F1.1 | -| F5.2 | Keyword Matching | Activation | P0 | F5.1 | -| F5.3 | Intent Matching | Activation | P1 | F5.2 | -| F5.4 | Activation Hook | Activation | P0 | F5.2 | -| F5.5 | Enforcement Levels | Activation | P0 | F5.4 | -| F5.6 | Negative Patterns | Activation | P1 | F5.5 | -| F5.7 | Project Overrides | Activation | P1 | F1.2 | - -**Total:** 27 features across 5 domains diff --git a/docs/pre-dev/ring-continuity/prd.md b/docs/pre-dev/ring-continuity/prd.md deleted file mode 100644 index 9ce67539..00000000 --- a/docs/pre-dev/ring-continuity/prd.md +++ /dev/null @@ -1,308 +0,0 @@ -# Ring Continuity - Product Requirements Document (Gate 1) - -> **Version:** 1.0 -> **Date:** 2026-01-12 -> **Status:** Draft -> **Stakeholder:** Ring Plugin Users & Maintainers - ---- - -## Problem Statement - -### The Problem - -AI coding assistants like Claude Code suffer from **session amnesia** - they cannot remember learnings, preferences, or context across sessions. This leads to: - -1. **Repeated explanations:** Users re-explain the same patterns, preferences, and project context every session -2. **Repeated mistakes:** AI makes the same errors it learned to avoid in previous sessions -3. **Wasted tokens:** Handoff documents consume ~2000 tokens in Markdown format when ~400 would suffice -4. **Manual skill invocation:** Users must remember and manually invoke relevant skills instead of automatic suggestion -5. **Unverified claims:** AI makes assertions without clear indication of verification status, leading to hallucination-based errors - -### Who Is Affected - -| User Type | Pain Point | Frequency | -|-----------|------------|-----------| -| **Daily Ring users** | Re-explaining preferences every session | Daily | -| **Long-running project teams** | Lost context between work sessions | Weekly | -| **Multi-project developers** | No cross-project learning transfer | Ongoing | -| **New Ring adopters** | Discovering which skills to use | Every task | - -### Business Value - -| Metric | Current State | Target State | Value | -|--------|---------------|--------------|-------| -| **Session startup time** | 5-10 min context rebuild | <1 min auto-load | 80% reduction | -| **Handoff token cost** | ~2000 tokens | ~400 tokens | 5x efficiency | -| **Skill discovery** | Manual lookup | Auto-suggest | Better adoption | -| **False claim rate** | ~80% (unverified grep) | <20% (with markers) | 4x improvement | - ---- - -## Vision - -**Ring Continuity** transforms Ring from a stateless skill library into an **intelligent, learning assistant** that: - -- **Remembers** user preferences, project patterns, and successful approaches -- **Learns** from failures and avoids repeating mistakes -- **Suggests** relevant skills based on user intent -- **Verifies** claims with explicit confidence markers -- **Persists** state efficiently across sessions and projects - -All while maintaining Ring's core principle: **lightweight, accessible, no heavy infrastructure**. - ---- - -## User Stories - -### Epic 1: Global Ring Directory (~/.ring/) - -**US-1.1: As a Ring user, I want a global ~/.ring/ directory so that my preferences and learnings persist across all projects.** - -- Acceptance Criteria: - - [ ] `~/.ring/` directory is created on first Ring use - - [ ] Directory contains `config.yaml`, `memory.db`, `skill-rules.json` - - [ ] Works alongside project-local `.ring/` directory - - [ ] Migration path from existing `.ring/` artifacts - -**US-1.2: As a Ring user, I want my global settings to be separate from project-specific settings so that I can have different behaviors per project.** - -- Acceptance Criteria: - - [ ] Global `~/.ring/config.yaml` for user preferences - - [ ] Project `.ring/config.yaml` overrides global settings - - [ ] Clear precedence: project > global > defaults - -### Epic 2: YAML Handoff Format - -**US-2.1: As a Ring user, I want handoffs in YAML format so that they consume fewer tokens and can be machine-parsed.** - -- Acceptance Criteria: - - [ ] New `/ring:create-handoff` produces YAML format - - [ ] YAML handoffs are ~400 tokens (vs ~2000 markdown) - - [ ] Schema validation ensures required fields present - - [ ] `/ring:resume-handoff` reads both YAML and legacy Markdown - -**US-2.2: As a Ring user, I want handoffs to be auto-indexed so that I can search past sessions.** - -- Acceptance Criteria: - - [ ] PostToolUse hook indexes YAML handoffs - - [ ] FTS5 search via `/query-artifacts` - - [ ] BM25 ranking for relevance - -### Epic 3: Confidence Markers - -**US-3.1: As a Ring user, I want agent outputs to include confidence markers so that I know which claims are verified vs assumed.** - -- Acceptance Criteria: - - [ ] Agents output `✓ VERIFIED` for claims backed by file reads - - [ ] Agents output `? INFERRED` for claims based on grep/search - - [ ] Agents output `✗ UNCERTAIN` for unverified assumptions - - [ ] Confidence markers appear in Findings/Claims sections - -**US-3.2: As a Ring user, I want the ring:codebase-explorer to distinguish verified facts from inferences so that I can trust its findings.** - -- Acceptance Criteria: - - [ ] Explorer agent marks each finding with confidence level - - [ ] Evidence chain shows how conclusion was reached - - [ ] Human review flag for low-confidence claims - -### Epic 4: Skill Activation Rules - -**US-4.1: As a Ring user, I want skills to be automatically suggested based on my prompt so that I don't have to remember all skill names.** - -- Acceptance Criteria: - - [ ] `~/.ring/skill-rules.json` defines activation patterns - - [ ] UserPromptSubmit hook matches prompt against rules - - [ ] Matching skills appear as suggestions in response - - [ ] Three enforcement levels: block, suggest, warn - -**US-4.2: As a Ring user, I want to customize which skills are suggested for which patterns so that I can tune the system to my workflow.** - -- Acceptance Criteria: - - [ ] User can edit `skill-rules.json` directly - - [ ] Project-level rules override global rules - - [ ] Schema validation prevents invalid rules - -### Epic 5: Persistent Memory System - -**US-5.1: As a Ring user, I want the AI to remember what worked and what failed so that it doesn't repeat mistakes.** - -- Acceptance Criteria: - - [ ] Learnings stored in `~/.ring/memory.db` (SQLite + FTS5) - - [ ] 7 learning types: WORKING_SOLUTION, FAILED_APPROACH, etc. - - [ ] Memory recalled at session start via hook - - [ ] Relevant memories injected into context - -**US-5.2: As a Ring user, I want to store user preferences that persist across sessions so that the AI adapts to my style.** - -- Acceptance Criteria: - - [ ] USER_PREFERENCE learning type for style/preferences - - [ ] Preferences searchable and retrievable - - [ ] Explicit `/remember` command to store preferences - - [ ] Automatic preference extraction from sessions - -**US-5.3: As a Ring user, I want memories to have expiration and relevance decay so that the system doesn't get cluttered with stale information.** - -- Acceptance Criteria: - - [ ] Memories have `created_at` and `accessed_at` timestamps - - [ ] Unused memories decay in relevance over time - - [ ] Optional `expires_at` for temporary learnings - - [ ] Maintenance task to prune old, low-relevance memories - ---- - -## Acceptance Criteria Summary - -### Must Have (P0) - -| ID | Requirement | Validation | -|----|-------------|------------| -| P0-1 | `~/.ring/` directory created on first use | Directory exists with config.yaml | -| P0-2 | YAML handoff format produces <500 tokens | Token count measured | -| P0-3 | Confidence markers in explorer agent output | Markers visible in output | -| P0-4 | skill-rules.json with keyword matching | Skills suggested on matching prompts | -| P0-5 | SQLite FTS5 memory storage | Memories searchable via FTS5 | - -### Should Have (P1) - -| ID | Requirement | Validation | -|----|-------------|------------| -| P1-1 | Memory awareness hook at session start | Relevant memories injected | -| P1-2 | Intent pattern matching (regex) in skill rules | Patterns match fuzzy intents | -| P1-3 | Legacy Markdown handoff migration | Old handoffs still readable | -| P1-4 | Project-level config overrides | Project settings take precedence | - -### Nice to Have (P2) - -| ID | Requirement | Validation | -|----|-------------|------------| -| P2-1 | Memory relevance decay over time | Old unused memories deprioritized | -| P2-2 | `/remember` command for explicit storage | Command stores user preference | -| P2-3 | Automatic preference extraction | Preferences detected from behavior | -| P2-4 | Cross-project memory queries | Memories from other projects searchable | - ---- - -## Success Metrics - -### Quantitative Metrics - -| Metric | Baseline | Target | Measurement | -|--------|----------|--------|-------------| -| **Handoff token count** | ~2000 | <500 | Token counter on handoff | -| **Session startup context** | Manual rebuild | Auto-injected | User feedback | -| **Skill discovery rate** | Manual lookup | 80% auto-suggested | Usage analytics | -| **False claim rate** | ~80% unverified | <20% | Sample audit | - -### Qualitative Metrics - -| Metric | Measurement Method | -|--------|-------------------| -| **User satisfaction** | Feedback in GitHub issues | -| **Adoption rate** | ~/.ring/ directory creation stats | -| **Workflow improvement** | Before/after user testimonials | - ---- - -## Out of Scope - -### Explicitly NOT Included - -| Item | Reason | -|------|--------| -| **PostgreSQL/pgvector** | Conflicts with "lightweight, no dependencies" principle | -| **Cloud sync** | Privacy concerns, complexity | -| **Multi-user collaboration** | Ring is single-user tool | -| **Real-time memory updates** | Batch updates at session boundaries sufficient | -| **Embedding-based semantic search** | FTS5 with BM25 sufficient for MVP | -| **GUI configuration** | CLI/file-based config is sufficient | - -### Future Considerations (Post-MVP) - -| Item | Potential Value | -|------|-----------------| -| **Optional embedding support** | Better semantic search for power users | -| **Memory export/import** | Backup and portability | -| **Memory visualization** | Understanding what Ring remembers | -| **Collaborative memory** | Team-shared learnings | - ---- - -## Constraints - -### Technical Constraints - -| Constraint | Impact | -|------------|--------| -| **No new runtime dependencies** | Must use Python stdlib (sqlite3, json, yaml via PyYAML) | -| **Bash hooks** | Performance-sensitive hooks stay in bash | -| **Backward compatibility** | Existing handoffs must remain readable | -| **Cross-platform** | macOS and Linux support required | - -### Business Constraints - -| Constraint | Impact | -|------------|--------| -| **Open source** | All code MIT licensed | -| **Self-contained** | No external services required | -| **Privacy-first** | All data stays local | - ---- - -## Dependencies - -### Internal Dependencies - -| Dependency | Status | Impact | -|------------|--------|--------| -| Existing handoff tracking skill | Stable | Must extend, not replace | -| Existing FTS5 infrastructure | Stable | Reuse patterns | -| Hook system | Stable | Add new hooks | -| Agent output schema | Stable | Add confidence section | - -### External Dependencies - -| Dependency | Version | Purpose | -|------------|---------|---------| -| Python | 3.9+ | Memory scripts | -| SQLite | 3.35+ | FTS5 support | -| PyYAML | >=6.0 | YAML parsing | -| Bash | 4.0+ | Hook scripts | - ---- - -## Risks - -| Risk | Probability | Impact | Mitigation | -|------|-------------|--------|------------| -| **FTS5 insufficient for semantic search** | Medium | Medium | Design for optional embedding layer | -| **Memory bloat over time** | Medium | Low | Decay mechanism, expiration | -| **Skill activation false positives** | High | Low | Negative patterns, confidence thresholds | -| **Breaking existing handoffs** | Low | High | Dual-format support, migration tool | -| **Performance impact from hooks** | Medium | Medium | Timeout limits, async processing | - ---- - -## Gate 1 Validation Checklist - -- [x] Problem is clearly defined (session amnesia, repeated context, wasted tokens) -- [x] User value is measurable (80% startup time reduction, 5x token efficiency) -- [x] Acceptance criteria are testable (specific validation per requirement) -- [x] Scope is explicitly bounded (out of scope section) -- [x] Success metrics defined (quantitative and qualitative) -- [x] Risks identified with mitigations -- [x] Dependencies documented - ---- - -## Appendix: Glossary - -| Term | Definition | -|------|------------| -| **Handoff** | Document capturing session state for resume | -| **Ledger** | In-session state that survives /clear | -| **Memory** | Persistent learning stored in SQLite | -| **Skill activation** | Automatic suggestion of relevant skills | -| **Confidence marker** | Indicator of verification status (✓/?/✗) | -| **FTS5** | SQLite Full-Text Search version 5 | -| **BM25** | Best Match 25 ranking algorithm | diff --git a/docs/pre-dev/ring-continuity/research.md b/docs/pre-dev/ring-continuity/research.md deleted file mode 100644 index 672673e0..00000000 --- a/docs/pre-dev/ring-continuity/research.md +++ /dev/null @@ -1,505 +0,0 @@ -# Ring Continuity - Research Phase (Gate 0) - -> **Generated:** 2026-01-12 -> **Research Mode:** Modification (extending existing Ring functionality) -> **Agents Used:** ring:repo-research-analyst, ring:best-practices-researcher, ring:framework-docs-researcher - ---- - -## Executive Summary - -Ring already has significant infrastructure for continuity (handoff tracking, continuity ledger, SQLite FTS5 indexing). The implementation will extend these patterns to add: -1. **~/.ring/** global directory (currently only project-local `.ring/`) -2. **Persistent memory** with SQLite FTS5 (lightweight, no dependencies) -3. **YAML handoffs** (~400 tokens vs ~2000 markdown) -4. **skill-rules.json** for activation routing -5. **Confidence markers** in agent outputs - ---- - -## Codebase Research Findings - -### Existing Handoff System - -| Aspect | Current State | File Reference | -|--------|---------------|----------------| -| **Skill Location** | `default/skills/handoff-tracking/SKILL.md` | Lines 1-208 | -| **Format** | Markdown with YAML frontmatter | Lines 57-123 | -| **Token Cost** | ~1200-2000 tokens per handoff | Measured from examples | -| **Storage Path** | `docs/handoffs/{session-name}/*.md` | Lines 46-55 | -| **Indexing** | Auto-indexed via PostToolUse hook | `default/hooks/artifact-index-write.sh` | - -**Current Handoff Template (frontmatter):** -```yaml ---- -date: {ISO timestamp} -session_name: {session-name} -git_commit: {hash} -branch: {branch} -repository: {repo} -topic: "{description}" -tags: [implementation, ...] -status: {complete|in_progress|blocked} -outcome: UNKNOWN ---- -``` - -### Skill Discovery & Activation - -| Aspect | Current State | File Reference | -|--------|---------------|----------------| -| **Discovery Script** | `default/hooks/generate-skills-ref.py` | Lines 1-308 | -| **Frontmatter Fields** | name, description, trigger, skip_when, sequence, related | Lines 1-15 | -| **Session Start** | `default/hooks/session-start.sh` | Lines 1-299 | -| **No skill-rules.json** | Manual skill invocation only | Opportunity identified | - -**Existing Skill Frontmatter Schema:** -```yaml ---- -name: ring:skill-name -description: | - What the skill does -trigger: | - - When to use condition 1 -skip_when: | - - Skip condition 1 -sequence: - before: [skill1] - after: [skill2] -related: - similar: [skill-a] ---- -``` - -### Agent Output Schemas - -**Location:** `docs/AGENT_DESIGN.md:1-275` - -| Schema Type | Purpose | Key Sections | -|-------------|---------|--------------| -| Implementation | Code changes | Summary, Implementation, Files Changed, Testing | -| Analysis | Research findings | Analysis, Findings, Recommendations | -| Reviewer | Code review | VERDICT, Issues Found, What Was Done Well | -| Exploration | Codebase analysis | Key Findings, Architecture Insights | -| Planning | Implementation plans | Goal, Architecture, Tasks | - -**Existing Confidence Field:** -```sql --- artifact_schema.sql:37-40 -confidence TEXT CHECK(confidence IN ('HIGH', 'INFERRED')) DEFAULT 'INFERRED' -``` - -### Hook System Architecture - -**Location:** `default/hooks/hooks.json:1-90` - -| Event | Hooks | Purpose | -|-------|-------|---------| -| SessionStart | session-start.sh | Load ledgers, generate skills overview | -| UserPromptSubmit | claude-md-reminder.sh, context-usage-check.sh | Context injection | -| PostToolUse | artifact-index-write.sh, task-completion-check.sh | Indexing, validation | -| PreCompact | ledger-save.sh | State preservation | -| Stop | ledger-save.sh, outcome-inference.sh, learning-extract.sh | Session end | - -**Hook Output Schema:** -```json -{ - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": "escaped content string" - } -} -``` - -### Directory Conventions - -| Directory | Purpose | Scope | -|-----------|---------|-------| -| `.ring/ledgers/` | Continuity ledger files | Project-local | -| `.ring/cache/artifact-index/` | SQLite database (context.db) | Project-local | -| `.ring/state/` | Session state files | Project-local | -| `docs/handoffs/` | Handoff documents | Project-local | -| **~/.ring/** | **NOT YET IMPLEMENTED** | Global (new) | - -### SQLite FTS5 Implementation - -**Schema Location:** `default/lib/artifact-index/artifact_schema.sql:1-343` - -| Table | Purpose | FTS5 Index | -|-------|---------|------------| -| handoffs | Task records | handoffs_fts | -| plans | Design documents | plans_fts | -| continuity | Session snapshots | continuity_fts | -| queries | Compound learning Q&A | queries_fts | -| learnings | Extracted patterns | learnings_fts | - ---- - -## Best Practices Research Findings - -### Memory System Design - -**Memory Type Taxonomy (Industry Standard):** - -| Memory Type | Purpose | Storage Strategy | -|-------------|---------|------------------| -| **Episodic** | Interaction events | Timestamped logs | -| **Semantic** | Extracted knowledge | Key-value pairs | -| **Procedural** | Behavioral patterns | Policy rules | - -**Key Recommendations:** -- Never rely on LLM implicit weights for facts -- Implement utility-based deletion (10% performance gain) -- Define explicit retention policies (what, how long, when to delete) -- Use semantic deduplication (0.85 similarity threshold) - -**Learning Types for Ring:** -```python -LEARNING_TYPES = [ - "FAILED_APPROACH", # Things that didn't work - "WORKING_SOLUTION", # Successful approaches - "USER_PREFERENCE", # User style/preferences - "CODEBASE_PATTERN", # Discovered code patterns - "ARCHITECTURAL_DECISION", # Design choices made - "ERROR_FIX", # Error->solution pairs - "OPEN_THREAD", # Unfinished work/TODOs -] -``` - -### Session Continuity Patterns - -**YAML vs Markdown:** -- YAML: Machine-parseable, ~400 tokens, schema-validatable -- Markdown: Human-readable, ~2000 tokens, flexible -- **Hybrid recommended:** YAML frontmatter + Markdown body - -**Essential Handoff Fields:** - -| Field | Purpose | Required | -|-------|---------|----------| -| session_id | Unique identifier | Yes | -| timestamp | Creation time | Yes | -| task_summary | 1-2 sentence summary | Yes | -| current_state | planning/implementing/testing/blocked | Yes | -| key_decisions | Architectural choices | Yes | -| blockers | What prevented completion | If applicable | -| next_steps | What to do on resume | Yes | - -**Anthropic's Context Engineering:** -1. Treat context as finite "attention budget" -2. Just-in-time loading (identifiers, not content) -3. Compaction (summarize, preserve decisions) -4. Progressive disclosure - -### Skill Activation Patterns - -**Three-Tier Enforcement:** - -| Level | Behavior | Use Case | -|-------|----------|----------| -| **Block** | Must use skill | Critical workflows (TDD) | -| **Suggest** | Offer, allow skip | Helpful optimizations | -| **Warn** | Log availability | Analytics/learning | - -**Layered Matching Approach:** -1. Rules/Regex (fast, deterministic) -2. Lemmatization (word forms) -3. Fuzzy matching (typos) -4. Semantic fallback (novel expressions) - -**False Positive Reduction:** -- Negative patterns (exclude non-matches) -- Context-aware matching (verb+noun, not just nouns) -- Confidence thresholds (>0.85 auto, 0.6-0.85 suggest) - -### Confidence Markers - -**Critical Finding:** Natural language epistemic markers are unreliable in out-of-distribution scenarios. - -**Recommended Approach:** Structured verification status - -```yaml -verification: - status: verified|unverified|partially_verified|needs_review - evidence: - - type: test_passed|code_review|manual_check|assumption - description: string - confidence_factors: - code_exists: boolean - tests_pass: boolean - reviewed: boolean -``` - -**Confidence Markers for Ring:** - -| Marker | Symbol | Meaning | -|--------|--------|---------| -| VERIFIED | ✓ | Read the file, traced the code | -| INFERRED | ? | Based on grep/search | -| UNCERTAIN | ✗ | Haven't checked | - -### SQLite FTS5 Best Practices - -**When FTS5 is Sufficient:** -- Keyword search -- Exact phrase matching -- Known vocabulary -- Typo tolerance (with trigram) - -**When Embeddings Needed:** -- Semantic similarity -- Cross-language search -- Synonym expansion - -**Recommended Tokenizer:** -```sql -CREATE VIRTUAL TABLE memories USING fts5( - content, - category, - tags, - tokenize = 'porter unicode61 remove_diacritics 1', - prefix = '2 3' -); -``` - -**BM25 Column Weighting:** -```sql -SELECT *, bm25(memories, 10.0, 5.0, 1.0) as relevance --- title:10x, tags:5x, content:1x -FROM memories WHERE memories MATCH 'query' -ORDER BY relevance; -``` - ---- - -## Framework Documentation Findings - -### Claude Code Hook System - -**Input Format (stdin):** -```json -{ - "session_id": "string", - "transcript_path": "string", - "cwd": "string", - "permission_mode": "string", - "prompt": "string" -} -``` - -**Output Format (stdout):** -```json -{ - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": "Content injected into context" - } -} -``` - -**Blocking Response (PreToolUse):** -```json -{ - "result": "block", - "reason": "Reason for blocking" -} -``` - -**Environment Variables:** -- `CLAUDE_PLUGIN_ROOT` - Plugin directory -- `CLAUDE_PROJECT_DIR` - Project directory -- `CLAUDE_SESSION_ID` - Session identifier - -### SQLite FTS5 Python Implementation - -```python -import sqlite3 - -def create_memory_database(db_path: str): - conn = sqlite3.connect(db_path) - - # Main table - conn.execute(""" - CREATE TABLE IF NOT EXISTS memories ( - id INTEGER PRIMARY KEY, - session_id TEXT NOT NULL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - memory_type TEXT NOT NULL, - content TEXT NOT NULL, - context TEXT, - tags TEXT, - confidence TEXT DEFAULT 'medium' - ) - """) - - # FTS5 virtual table - conn.execute(""" - CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( - content, context, tags, - content_rowid='id', - tokenize='porter unicode61' - ) - """) - - # Auto-sync triggers - conn.execute(""" - CREATE TRIGGER IF NOT EXISTS memories_ai - AFTER INSERT ON memories BEGIN - INSERT INTO memories_fts(rowid, content, context, tags) - VALUES (new.id, new.content, new.context, new.tags); - END - """) - - return conn -``` - -### YAML Schema Design - -**PyYAML Safe Loading:** -```python -import yaml - -def load_handoff(file_path: str) -> dict: - with open(file_path, 'r') as f: - content = f.read() - - if content.startswith('---'): - parts = content.split('---', 2) - if len(parts) >= 3: - frontmatter = yaml.safe_load(parts[1]) - body = parts[2].strip() - return {'frontmatter': frontmatter, 'body': body} - - return yaml.safe_load(content) -``` - -### JSON Schema for Skill Rules - -```json -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["version", "skills"], - "properties": { - "version": {"type": "string"}, - "skills": { - "type": "object", - "additionalProperties": { - "type": "object", - "required": ["type", "enforcement", "priority"], - "properties": { - "type": {"enum": ["guardrail", "domain", "workflow"]}, - "enforcement": {"enum": ["block", "suggest", "warn"]}, - "priority": {"enum": ["critical", "high", "medium", "low"]}, - "promptTriggers": { - "type": "object", - "properties": { - "keywords": {"type": "array", "items": {"type": "string"}}, - "intentPatterns": {"type": "array", "items": {"type": "string"}} - } - } - } - } - } - } -} -``` - -### Version Constraints - -| Dependency | Version | Notes | -|------------|---------|-------| -| Python | 3.9+ | match statements, type hints | -| PyYAML | >=6.0 | safe_load by default | -| SQLite | 3.35+ | FTS5 in stdlib | -| jsonschema | >=4.0 | Draft 2020-12 support | -| Bash | 4.0+ | associative arrays | - ---- - -## Potential Conflicts & Risks - -### Database Location Conflict -- **Current:** `.ring/cache/artifact-index/context.db` (project-local) -- **New:** Need both project-local AND `~/.ring/` global databases -- **Resolution:** Dual database support with merge queries - -### Handoff Format Change -- **Current:** Markdown with YAML frontmatter -- **New:** Pure YAML -- **Resolution:** Migration script + dual-format indexer support - -### Hook Timing -- **Current:** Stop hook runs outcome-inference.sh, learning-extract.sh sequentially -- **Risk:** Adding memory writes may cause timeout -- **Resolution:** Background write or async processing - -### Session Isolation -- **Current:** State files use `${SESSION_ID}` in filenames -- **Risk:** Global memory needs cross-session identifier strategy -- **Resolution:** User-scoped vs project-scoped memory distinction - ---- - -## Recommended Architecture - -### ~/.ring/ Directory Structure - -``` -~/.ring/ -├── config.yaml # User preferences -├── memory.db # Global SQLite + FTS5 -├── skill-rules.json # Activation rules -├── skill-rules-schema.json # JSON Schema for validation -├── state/ # Session state -│ └── context-usage-{session}.json -├── ledgers/ # Global continuity ledgers -│ └── CONTINUITY-{name}.md -├── handoffs/ # Global handoffs -│ └── {session}/ -│ └── {timestamp}.yaml -└── logs/ # Debug logs - └── {date}.log -``` - -### Phased Implementation - -| Phase | Components | Effort | Impact | -|-------|------------|--------|--------| -| **1** | ~/.ring/ structure + migration | Low | Foundation | -| **2** | YAML handoff format | Medium | High (5x token efficiency) | -| **3** | Confidence markers in agents | Low | High (reduces false claims) | -| **4** | skill-rules.json + activation hook | Medium | Medium (better routing) | -| **5** | Global memory system | High | High (cross-session learning) | - ---- - -## Gate 0 Validation Checklist - -- [x] Research mode determined: **Modification** (extending existing functionality) -- [x] All 3 agents dispatched and returned -- [x] File:line references included (see Codebase Research Findings) -- [x] External URLs included (see Best Practices Research Findings) -- [x] Tech stack versions documented (Python 3.9+, SQLite 3.35+, etc.) -- [x] Existing patterns identified for reuse -- [x] Potential conflicts documented with resolutions - ---- - -## Sources - -### Codebase References -- `default/skills/handoff-tracking/SKILL.md:1-208` -- `default/hooks/generate-skills-ref.py:1-308` -- `default/hooks/session-start.sh:1-299` -- `default/hooks/hooks.json:1-90` -- `default/lib/artifact-index/artifact_schema.sql:1-343` -- `docs/AGENT_DESIGN.md:1-275` - -### External Sources -- [SQLite FTS5 Documentation](https://sqlite.org/fts5.html) -- [Anthropic - Effective Context Engineering](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents) -- [Mem0 Research Paper](https://arxiv.org/abs/2504.19413) -- [IBM - What Is AI Agent Memory](https://www.ibm.com/think/topics/ai-agent-memory) -- [Skywork - AI Agent Orchestration Best Practices](https://skywork.ai/blog/ai-agent-orchestration-best-practices-handoffs/) -- [ACL 2025 - Epistemic Markers](https://aclanthology.org/2025.acl-short.18/) diff --git a/docs/pre-dev/ring-continuity/subtasks.md b/docs/pre-dev/ring-continuity/subtasks.md deleted file mode 100644 index 1523fe96..00000000 --- a/docs/pre-dev/ring-continuity/subtasks.md +++ /dev/null @@ -1,1509 +0,0 @@ -# Ring Continuity - Subtask Breakdown (Gate 8) - -> **Version:** 1.0 -> **Date:** 2026-01-12 -> **Status:** Draft -> **Based on:** Tasks v1.0 (docs/pre-dev/ring-continuity/tasks.md) - ---- - -## Executive Summary - -This document breaks down the 15 tasks from Gate 7 into **78 bite-sized subtasks** (2-5 minutes each). Each subtask follows the **TDD pattern** (test first, then implementation) and includes **complete code** with no placeholders. Subtasks are grouped by phase for parallel ring:write-plan agent execution. - -**Implementation Strategy:** Spawn 5 ring:write-plan agents (one per phase) with comprehensive context from Gates 0-7. - ---- - -## Phase 1: Foundation (3 Tasks → 16 Subtasks) - -**Context for ring:write-plan agent:** -- Read: `docs/pre-dev/ring-continuity/research.md` (Gate 0) -- Read: `docs/pre-dev/ring-continuity/trd.md` (Gate 3) -- Read: `docs/pre-dev/ring-continuity/api-design.md` (Gate 4) -- Read: `docs/pre-dev/ring-continuity/dependency-map.md` (Gate 6) -- Focus: Establish ~/.ring/ directory with config precedence -- Output: `default/lib/ring_home.py`, install script, tests - ---- - -### Task 1.1: Create ~/.ring/ Directory Structure (6 subtasks) - -#### Subtask 1.1.1: Write test for directory creation (RED) - -**Time:** 2 min - -**What:** Create test that verifies ~/.ring/ directory exists with correct structure. - -**TDD Phase:** RED (test fails, directory doesn't exist yet) - -**File:** `tests/test_ring_home.py` - -**Code:** -```python -import pytest -from pathlib import Path -from default.lib.ring_home import RingHomeManager - -def test_ensure_directory_exists(): - """Test that ~/.ring/ is created with correct structure.""" - manager = RingHomeManager() - - result = manager.ensure_directory_exists() - - assert result.success is True - assert result.path == Path.home() / '.ring' - assert (result.path / 'handoffs').exists() - assert (result.path / 'cache').exists() - assert (result.path / 'logs').exists() - assert (result.path / 'state').exists() - -def test_directory_already_exists(): - """Test idempotency - safe to run multiple times.""" - manager = RingHomeManager() - - # First call - result1 = manager.ensure_directory_exists() - - # Second call - result2 = manager.ensure_directory_exists() - - assert result2.success is True - assert result2.created is False # Not newly created -``` - -**Verification:** -```bash -pytest tests/test_ring_home.py::test_ensure_directory_exists -v -# Expected: FAIL (ring_home.py doesn't exist) -``` - ---- - -#### Subtask 1.1.2: Implement RingHome data class (GREEN) - -**Time:** 3 min - -**What:** Create data class for RingHome result. - -**TDD Phase:** GREEN (minimal implementation) - -**File:** `default/lib/ring_home.py` - -**Code:** -```python -"""Ring home directory management.""" -from dataclasses import dataclass -from pathlib import Path -from typing import Optional - -@dataclass -class RingHome: - """Result of directory initialization.""" - success: bool - path: Path - created: bool # True if newly created - config_path: Path - memory_path: Path - handoffs_path: Path - error: Optional[str] = None - -class RingHomeManager: - """Manages the ~/.ring/ directory.""" - - def __init__(self, home_dir: Optional[Path] = None): - """Initialize manager. - - Args: - home_dir: Override home directory (for testing) - """ - self.home_dir = home_dir or Path.home() - self.ring_dir = self.home_dir / '.ring' -``` - -**Verification:** -```bash -python3 -c "from default.lib.ring_home import RingHomeManager; print('Import OK')" -# Expected: SUCCESS -``` - ---- - -#### Subtask 1.1.3: Implement ensure_directory_exists (GREEN) - -**Time:** 4 min - -**What:** Create ~/.ring/ with subdirectories. - -**TDD Phase:** GREEN - -**File:** `default/lib/ring_home.py` (add method) - -**Code:** -```python - def ensure_directory_exists(self) -> RingHome: - """Ensure ~/.ring/ exists with standard structure. - - Returns: - RingHome with success status and paths - """ - created = False - - try: - # Create main directory - if not self.ring_dir.exists(): - self.ring_dir.mkdir(mode=0o755, parents=True) - created = True - - # Create subdirectories - subdirs = ['handoffs', 'cache', 'logs', 'state'] - for subdir in subdirs: - subdir_path = self.ring_dir / subdir - subdir_path.mkdir(mode=0o755, exist_ok=True) - - return RingHome( - success=True, - path=self.ring_dir, - created=created, - config_path=self.ring_dir / 'config.yaml', - memory_path=self.ring_dir / 'memory.db', - handoffs_path=self.ring_dir / 'handoffs' - ) - - except PermissionError as e: - return RingHome( - success=False, - path=self.ring_dir, - created=False, - config_path=Path(), - memory_path=Path(), - handoffs_path=Path(), - error=f"Permission denied: {e}" - ) -``` - -**Verification:** -```bash -pytest tests/test_ring_home.py::test_ensure_directory_exists -v -# Expected: PASS (green) -``` - ---- - -#### Subtask 1.1.4: Write test for permission error fallback (RED) - -**Time:** 2 min - -**What:** Test fallback to project .ring/ if ~/.ring/ creation fails. - -**TDD Phase:** RED - -**File:** `tests/test_ring_home.py` (add test) - -**Code:** -```python -def test_permission_denied_fallback(tmp_path, monkeypatch): - """Test fallback to project .ring/ if home not writable.""" - import os - - # Mock Path.home() to return unwritable directory - unwritable = tmp_path / 'unwritable' - unwritable.mkdir(mode=0o000) - - monkeypatch.setattr('pathlib.Path.home', lambda: unwritable) - - manager = RingHomeManager() - result = manager.ensure_directory_exists() - - # Should fail with error - assert result.success is False - assert 'Permission denied' in result.error - - # Should suggest fallback - assert result.fallback_path == Path.cwd() / '.ring' -``` - -**Verification:** -```bash -pytest tests/test_ring_home.py::test_permission_denied_fallback -v -# Expected: FAIL (fallback_path not implemented) -``` - ---- - -#### Subtask 1.1.5: Implement fallback to project .ring/ (GREEN) - -**Time:** 3 min - -**What:** Add fallback_path to RingHome and suggest alternative. - -**TDD Phase:** GREEN - -**File:** `default/lib/ring_home.py` (modify) - -**Code:** -```python -@dataclass -class RingHome: - """Result of directory initialization.""" - success: bool - path: Path - created: bool - config_path: Path - memory_path: Path - handoffs_path: Path - error: Optional[str] = None - fallback_path: Optional[Path] = None # NEW - - def ensure_directory_exists(self) -> RingHome: - """Ensure ~/.ring/ exists with standard structure.""" - created = False - - try: - # ... existing code ... - - except PermissionError as e: - # Suggest fallback to project .ring/ - project_ring = Path.cwd() / '.ring' - - return RingHome( - success=False, - path=self.ring_dir, - created=False, - config_path=Path(), - memory_path=Path(), - handoffs_path=Path(), - error=f"Permission denied: {e}", - fallback_path=project_ring # NEW - ) -``` - -**Verification:** -```bash -pytest tests/test_ring_home.py::test_permission_denied_fallback -v -# Expected: PASS (green) -``` - ---- - -#### Subtask 1.1.6: Write install script (INTEGRATION) - -**Time:** 5 min - -**What:** Bash script that creates ~/.ring/ and reports status. - -**File:** `scripts/install-ring-home.sh` - -**Code:** -```bash -#!/usr/bin/env bash -set -euo pipefail - -echo "🔄 Initializing Ring home directory..." - -# Determine Ring home -RING_HOME="${RING_HOME:-${HOME}/.ring}" - -# Create directory structure -if mkdir -p "${RING_HOME}"/{handoffs,cache,logs,state} 2>/dev/null; then - echo "✅ Created ${RING_HOME}" - - # Create default config if missing - if [[ ! -f "${RING_HOME}/config.yaml" ]]; then - cat > "${RING_HOME}/config.yaml" <<'EOF' -# Ring Configuration -memory: - enabled: true - max_memories: 10000 - -skill_activation: - enabled: true - -handoffs: - format: yaml -EOF - echo "📝 Created default config: ${RING_HOME}/config.yaml" - fi - - echo "" - echo "Ring home directory initialized successfully!" - echo "Location: ${RING_HOME}" -else - echo "⚠️ Could not create ${RING_HOME}" - echo " Fallback: Ring will use .ring/ in each project" -fi -``` - -**Verification:** -```bash -bash scripts/install-ring-home.sh -test -d ~/.ring/handoffs || exit 1 -test -f ~/.ring/config.yaml || exit 1 -echo "✅ Install script works" -``` - ---- - -### Task 1.2: Implement Config Precedence System (5 subtasks) - -#### Subtask 1.2.1: Write test for config loading (RED) - -**Time:** 3 min - -**File:** `tests/test_config_loader.py` - -**Code:** -```python -import pytest -from pathlib import Path -from default.lib.config_loader import ConfigLoader - -def test_load_global_config(): - """Test loading global config from ~/.ring/config.yaml.""" - loader = ConfigLoader() - - config = loader.load_config(scope='global') - - assert config is not None - assert 'memory' in config - assert config['memory']['enabled'] is True - -def test_config_precedence(): - """Test project config overrides global config.""" - loader = ConfigLoader() - - # Get value that exists in both global and project - value = loader.get_config_value('memory.enabled') - - # Should show source - assert value.value in [True, False] - assert value.source in ['global', 'project', 'default'] - assert value.precedence >= 0 -``` - -**Verification:** -```bash -pytest tests/test_config_loader.py -v -# Expected: FAIL (config_loader.py doesn't exist) -``` - ---- - -#### Subtask 1.2.2: Implement ConfigValue data class (GREEN) - -**Time:** 2 min - -**File:** `default/lib/config_loader.py` - -**Code:** -```python -"""Configuration loading with precedence.""" -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Optional -import yaml - -@dataclass -class ConfigValue: - """Configuration value with source attribution.""" - value: Any - source: str # 'global', 'project', 'default' - precedence: int # 0=default, 1=global, 2=project - file_path: Optional[Path] = None - -class ConfigLoader: - """Loads configuration with precedence: project > global > default.""" - - DEFAULT_CONFIG = { - 'memory': { - 'enabled': True, - 'max_memories': 10000, - 'decay': {'enabled': True, 'half_life_days': 180} - }, - 'skill_activation': { - 'enabled': True, - 'enforcement_default': 'suggest' - }, - 'handoffs': { - 'format': 'yaml', - 'auto_index': True - } - } - - def __init__(self, ring_home: Optional[Path] = None, project_dir: Optional[Path] = None): - self.ring_home = ring_home or (Path.home() / '.ring') - self.project_dir = project_dir or Path.cwd() -``` - -**Verification:** -```bash -python3 -c "from default.lib.config_loader import ConfigLoader; print('Import OK')" -``` - ---- - -#### Subtask 1.2.3: Implement load_config method (GREEN) - -**Time:** 4 min - -**File:** `default/lib/config_loader.py` (add method) - -**Code:** -```python - def load_config(self, scope: str) -> dict: - """Load configuration from specified scope. - - Args: - scope: 'global', 'project', or 'default' - - Returns: - Configuration dictionary - """ - if scope == 'default': - return self.DEFAULT_CONFIG.copy() - - if scope == 'global': - config_path = self.ring_home / 'config.yaml' - elif scope == 'project': - config_path = self.project_dir / '.ring' / 'config.yaml' - else: - raise ValueError(f"Invalid scope: {scope}") - - if not config_path.exists(): - return {} - - try: - with open(config_path, 'r') as f: - return yaml.safe_load(f) or {} - except Exception as e: - print(f"Warning: Failed to load {config_path}: {e}") - return {} -``` - -**Verification:** -```bash -pytest tests/test_config_loader.py::test_load_global_config -v -# Expected: PASS if ~/.ring/config.yaml exists -``` - ---- - -#### Subtask 1.2.4: Implement merge_configs method (GREEN) - -**Time:** 4 min - -**File:** `default/lib/config_loader.py` (add method) - -**Code:** -```python - def merge_configs(self, *configs: dict) -> dict: - """Merge configurations with later configs overriding earlier. - - Args: - *configs: Config dictionaries in precedence order - - Returns: - Merged configuration - """ - result = {} - - for config in configs: - result = self._deep_merge(result, config) - - return result - - def _deep_merge(self, base: dict, override: dict) -> dict: - """Deep merge two dictionaries.""" - result = base.copy() - - for key, value in override.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): - result[key] = self._deep_merge(result[key], value) - else: - result[key] = value - - return result -``` - -**Verification:** -```bash -python3 -c " -from default.lib.config_loader import ConfigLoader -loader = ConfigLoader() -merged = loader.merge_configs({'a': 1}, {'a': 2, 'b': 3}) -assert merged == {'a': 2, 'b': 3} -print('Merge OK') -" -``` - ---- - -#### Subtask 1.2.5: Implement get_config_value with precedence (GREEN) - -**Time:** 5 min - -**File:** `default/lib/config_loader.py` (add method) - -**Code:** -```python - def get_config_value(self, key: str) -> ConfigValue: - """Get config value with source attribution. - - Args: - key: Dot-notation key (e.g., 'memory.enabled') - - Returns: - ConfigValue with source and precedence - """ - # Load all configs - default_config = self.load_config('default') - global_config = self.load_config('global') - project_config = self.load_config('project') - - # Check project first (highest precedence) - value = self._get_nested_value(project_config, key) - if value is not None: - return ConfigValue(value=value, source='project', precedence=2, - file_path=self.project_dir / '.ring' / 'config.yaml') - - # Then global - value = self._get_nested_value(global_config, key) - if value is not None: - return ConfigValue(value=value, source='global', precedence=1, - file_path=self.ring_home / 'config.yaml') - - # Finally default - value = self._get_nested_value(default_config, key) - return ConfigValue(value=value, source='default', precedence=0) - - def _get_nested_value(self, config: dict, key: str) -> Any: - """Get value from nested dict using dot notation.""" - keys = key.split('.') - value = config - - for k in keys: - if isinstance(value, dict) and k in value: - value = value[k] - else: - return None - - return value -``` - -**Verification:** -```bash -pytest tests/test_config_loader.py::test_config_precedence -v -# Expected: PASS (green) -``` - ---- - -#### Subtask 1.2.6: Add integration test for full config system (INTEGRATION) - -**Time:** 3 min - -**File:** `tests/test_config_loader.py` (add test) - -**Code:** -```python -def test_full_config_precedence(tmp_path): - """Integration test: project overrides global.""" - # Setup global config - global_ring = tmp_path / '.ring' - global_ring.mkdir() - (global_ring / 'config.yaml').write_text(""" -memory: - enabled: true - max_memories: 5000 -""") - - # Setup project config - project_ring = tmp_path / 'project' / '.ring' - project_ring.mkdir(parents=True) - (project_ring / 'config.yaml').write_text(""" -memory: - enabled: false -""") - - # Load with precedence - loader = ConfigLoader(ring_home=global_ring, project_dir=tmp_path / 'project') - - # memory.enabled should be False (project wins) - value = loader.get_config_value('memory.enabled') - assert value.value is False - assert value.source == 'project' - - # memory.max_memories should be 5000 (from global, not in project) - value = loader.get_config_value('memory.max_memories') - assert value.value == 5000 - assert value.source == 'global' -``` - -**Verification:** -```bash -pytest tests/test_config_loader.py::test_full_config_precedence -v -# Expected: PASS -``` - ---- - -### Task 1.3: Create Migration Utilities (5 subtasks) - -#### Subtask 1.3.1: Write test for handoff migration (RED) - -**Time:** 3 min - -**File:** `tests/test_migration.py` - -**Code:** -```python -import pytest -from pathlib import Path -from default.lib.migration import migrate_handoffs - -def test_migrate_handoffs_to_ring_home(tmp_path): - """Test migrating project handoffs to ~/.ring/.""" - # Setup source - project_handoffs = tmp_path / 'project' / 'docs' / 'handoffs' / 'session1' - project_handoffs.mkdir(parents=True) - (project_handoffs / '2026-01-12_handoff.md').write_text("""--- -session_name: session1 ---- -Test handoff -""") - - # Setup destination - ring_home = tmp_path / '.ring' - ring_home.mkdir() - - # Migrate - result = migrate_handoffs( - source=project_handoffs, - ring_home=ring_home, - project_name='test-project' - ) - - assert result.success is True - assert result.items_migrated == 1 - assert (ring_home / 'handoffs' / 'test-project' / 'session1' / '2026-01-12_handoff.md').exists() - - # Original should still exist - assert (project_handoffs / '2026-01-12_handoff.md').exists() -``` - -**Verification:** -```bash -pytest tests/test_migration.py::test_migrate_handoffs_to_ring_home -v -# Expected: FAIL (migration.py doesn't exist) -``` - ---- - -#### Subtask 1.3.2: Implement MigrationResult data class (GREEN) - -**Time:** 2 min - -**File:** `default/lib/migration.py` - -**Code:** -```python -"""Migration utilities for Ring data.""" -from dataclasses import dataclass -from pathlib import Path -from typing import List -import shutil - -@dataclass -class MigrationResult: - """Result of migration operation.""" - success: bool - source: Path - destination: Path - items_migrated: int - items_skipped: int - warnings: List[str] - error: str = "" -``` - -**Verification:** -```bash -python3 -c "from default.lib.migration import MigrationResult; print('Import OK')" -``` - ---- - -#### Subtask 1.3.3: Implement migrate_handoffs function (GREEN) - -**Time:** 5 min - -**File:** `default/lib/migration.py` (add function) - -**Code:** -```python -def migrate_handoffs(source: Path, ring_home: Path, project_name: str) -> MigrationResult: - """Migrate handoffs from project to ~/.ring/. - - Args: - source: Source directory (e.g., docs/handoffs/) - ring_home: Ring home directory (e.g., ~/.ring/) - project_name: Project identifier for namespacing - - Returns: - MigrationResult with counts and status - """ - warnings = [] - migrated = 0 - skipped = 0 - - try: - # Create destination - dest_base = ring_home / 'handoffs' / project_name - dest_base.mkdir(parents=True, exist_ok=True) - - # Find all handoff files - for handoff_file in source.rglob('*.md'): - if handoff_file.is_file(): - # Preserve directory structure - relative = handoff_file.relative_to(source) - dest_file = dest_base / relative - dest_file.parent.mkdir(parents=True, exist_ok=True) - - # Copy (preserve original) - shutil.copy2(handoff_file, dest_file) - migrated += 1 - - return MigrationResult( - success=True, - source=source, - destination=dest_base, - items_migrated=migrated, - items_skipped=skipped, - warnings=warnings - ) - - except Exception as e: - return MigrationResult( - success=False, - source=source, - destination=ring_home / 'handoffs' / project_name, - items_migrated=migrated, - items_skipped=skipped, - warnings=warnings, - error=str(e) - ) -``` - -**Verification:** -```bash -pytest tests/test_migration.py::test_migrate_handoffs_to_ring_home -v -# Expected: PASS (green) -``` - ---- - -#### Subtask 1.3.4: Create migration CLI script (INTEGRATION) - -**Time:** 4 min - -**File:** `scripts/migrate-ring-data.sh` - -**Code:** -```bash -#!/usr/bin/env bash -set -euo pipefail - -PROJECT_DIR="${1:-.}" -RING_HOME="${RING_HOME:-${HOME}/.ring}" - -echo "🔄 Migrating Ring data to ~/.ring/..." -echo " Project: ${PROJECT_DIR}" -echo " Destination: ${RING_HOME}" -echo "" - -# Detect project name from directory -PROJECT_NAME=$(basename "$(cd "${PROJECT_DIR}" && pwd)") - -# Migrate handoffs -if [[ -d "${PROJECT_DIR}/docs/handoffs" ]]; then - python3 -c " -from pathlib import Path -from default.lib.migration import migrate_handoffs - -result = migrate_handoffs( - source=Path('${PROJECT_DIR}/docs/handoffs'), - ring_home=Path('${RING_HOME}'), - project_name='${PROJECT_NAME}' -) - -if result.success: - print(f'✅ Migrated {result.items_migrated} handoffs') -else: - print(f'❌ Migration failed: {result.error}') - exit(1) -" -else - echo "ℹ️ No handoffs to migrate" -fi - -echo "" -echo "Migration complete!" -``` - -**Verification:** -```bash -# Test migration -cd test-project -bash scripts/migrate-ring-data.sh -test -d ~/.ring/handoffs/test-project || exit 1 -``` - ---- - -#### Subtask 1.3.5: Write rollback script (SAFETY) - -**Time:** 3 min - -**File:** `scripts/rollback-ring-migration.sh` - -**Code:** -```bash -#!/usr/bin/env bash -set -euo pipefail - -RING_HOME="${RING_HOME:-${HOME}/.ring}" - -echo "⚠️ Rolling back Ring migration..." -echo " This will remove ${RING_HOME}" -echo "" - -read -p "Are you sure? (yes/NO) " -r -if [[ ! "$REPLY" =~ ^yes$ ]]; then - echo "Cancelled." - exit 0 -fi - -# Backup first -BACKUP="${RING_HOME}.backup-$(date +%Y%m%d-%H%M%S)" -if [[ -d "${RING_HOME}" ]]; then - mv "${RING_HOME}" "${BACKUP}" - echo "✅ Backed up to ${BACKUP}" - echo " To restore: mv ${BACKUP} ${RING_HOME}" -else - echo "ℹ️ ${RING_HOME} doesn't exist, nothing to rollback" -fi -``` - -**Verification:** -```bash -# Test rollback -bash scripts/rollback-ring-migration.sh -# Type "yes" when prompted -test ! -d ~/.ring || exit 1 -test -d ~/.ring.backup-* || exit 1 -``` - ---- - -## Phase 2: YAML Handoffs (4 Tasks → 18 Subtasks) - -**Context for ring:write-plan agent:** -- Read all previous gates (research.md through dependency-map.md) -- Read: `default/skills/handoff-tracking/SKILL.md` (existing implementation) -- Read: `default/lib/artifact-index/artifact_schema.sql` (existing FTS5 schema) -- Focus: Replace Markdown with YAML, maintain backward compatibility -- Output: Serializer, schema, skill update, indexer update - ---- - -### Task 2.1: Define YAML Handoff Schema (4 subtasks) - -#### Subtask 2.1.1: Create JSON Schema for handoff validation (SCHEMA) - -**Time:** 5 min - -**File:** `default/schemas/handoff-schema-v1.json` - -**Code:** -```json -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ring.lerianstudio.com/schemas/handoff-v1.json", - "title": "Ring Handoff", - "description": "Schema for Ring session handoff documents", - "type": "object", - "required": ["version", "schema", "session", "task", "resume"], - "properties": { - "version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+$", - "description": "Schema version" - }, - "schema": { - "type": "string", - "const": "ring-handoff-v1", - "description": "Schema identifier" - }, - "session": { - "type": "object", - "required": ["id", "started_at"], - "properties": { - "id": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$", - "maxLength": 100 - }, - "started_at": { - "type": "string", - "format": "date-time" - }, - "ended_at": { - "type": "string", - "format": "date-time" - }, - "duration_seconds": { - "type": "integer", - "minimum": 0 - } - } - }, - "task": { - "type": "object", - "required": ["description", "status"], - "properties": { - "description": { - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "status": { - "type": "string", - "enum": ["completed", "paused", "blocked", "failed"] - }, - "skills_used": { - "type": "array", - "items": { - "type": "string", - "pattern": "^ring:[a-z0-9-]+$" - } - } - } - }, - "context": { - "type": "object", - "properties": { - "project_path": {"type": "string"}, - "git_commit": { - "type": "string", - "pattern": "^[0-9a-f]{40}$" - }, - "git_branch": {"type": "string"}, - "key_files": { - "type": "array", - "items": {"type": "string"}, - "maxItems": 10 - } - } - }, - "decisions": { - "type": "array", - "items": { - "type": "object", - "required": ["decision", "rationale"], - "properties": { - "decision": {"type": "string"}, - "rationale": {"type": "string"}, - "alternatives": { - "type": "array", - "items": {"type": "string"} - } - } - } - }, - "artifacts": { - "type": "object", - "properties": { - "created": { - "type": "array", - "items": {"type": "string"} - }, - "modified": { - "type": "array", - "items": {"type": "string"} - }, - "deleted": { - "type": "array", - "items": {"type": "string"} - } - } - }, - "learnings": { - "type": "array", - "items": { - "type": "object", - "required": ["type", "content", "confidence"], - "properties": { - "type": { - "type": "string", - "enum": ["FAILED_APPROACH", "WORKING_SOLUTION", "USER_PREFERENCE", "CODEBASE_PATTERN", "ARCHITECTURAL_DECISION", "ERROR_FIX", "OPEN_THREAD"] - }, - "content": {"type": "string"}, - "confidence": { - "type": "string", - "enum": ["verified", "inferred", "uncertain"] - } - } - } - }, - "blockers": { - "type": "array", - "items": { - "type": "object", - "required": ["type", "description", "severity"], - "properties": { - "type": { - "type": "string", - "enum": ["technical", "clarification", "external"] - }, - "description": {"type": "string"}, - "severity": { - "type": "string", - "enum": ["critical", "high", "medium"] - } - } - } - }, - "resume": { - "type": "object", - "required": ["next_steps"], - "properties": { - "next_steps": { - "type": "array", - "items": {"type": "string"}, - "minItems": 1 - }, - "context_needed": { - "type": "array", - "items": {"type": "string"} - }, - "warnings": { - "type": "array", - "items": {"type": "string"} - } - } - } - } -} -``` - -**Verification:** -```bash -python3 -c "import json; json.load(open('default/schemas/handoff-schema-v1.json')); print('Valid JSON')" -``` - ---- - -#### Subtask 2.1.2: Create example YAML handoff (DOCUMENTATION) - -**Time:** 3 min - -**File:** `default/schemas/handoff-example.yaml` - -**Code:** -```yaml ---- -version: "1.0" -schema: ring-handoff-v1 - -session: - id: example-session - started_at: 2026-01-12T10:00:00Z - ended_at: 2026-01-12T11:30:00Z - duration_seconds: 5400 - -task: - description: Implement artifact indexing with FTS5 - status: completed - skills_used: - - ring:test-driven-development - - ring:requesting-code-review - -context: - project_path: /Users/user/repos/ring - git_commit: abc123def456789012345678901234567890abcd - git_branch: feature/artifact-index - key_files: - - default/lib/artifact-index/artifact_index.py - - default/lib/artifact-index/artifact_schema.sql - -decisions: - - decision: Use FTS5 instead of FTS3 - rationale: FTS5 supports BM25 ranking for better relevance - alternatives: - - FTS3 (lacks ranking) - - External search (too heavy) - -artifacts: - created: - - default/lib/artifact-index/artifact_index.py - modified: - - default/lib/artifact-index/artifact_schema.sql - -learnings: - - type: WORKING_SOLUTION - content: FTS5 triggers keep index synchronized automatically - confidence: verified - - - type: FAILED_APPROACH - content: Tried FTS3, but it doesn't support BM25 ranking - confidence: verified - -blockers: [] - -resume: - next_steps: - - Add query script for artifact search - - Test with real handoff documents - context_needed: - - Review artifact_schema.sql for table structure - warnings: - - FTS5 requires SQLite 3.35+ ---- - -# Session Notes - -## Summary -Implemented artifact indexing using SQLite FTS5. Created schema with automatic trigger-based synchronization. - -## Additional Context -The implementation follows the pattern from continuous-claude-v3 research... -``` - -**Verification:** -```bash -python3 -c "import yaml; yaml.safe_load(open('default/schemas/handoff-example.yaml')); print('Valid YAML')" -``` - ---- - -#### Subtask 2.1.3: Validate example against schema (TEST) - -**Time:** 2 min - -**File:** `tests/test_handoff_schema.py` - -**Code:** -```python -import json -import yaml -import pytest -from jsonschema import validate, ValidationError - -def test_example_validates(): - """Example handoff should pass schema validation.""" - with open('default/schemas/handoff-schema-v1.json') as f: - schema = json.load(f) - - with open('default/schemas/handoff-example.yaml') as f: - example = yaml.safe_load(f) - - # Should not raise ValidationError - validate(instance=example, schema=schema) - -def test_missing_required_fields(): - """Missing required fields should fail validation.""" - with open('default/schemas/handoff-schema-v1.json') as f: - schema = json.load(f) - - invalid_handoff = { - 'version': '1.0', - 'schema': 'ring-handoff-v1', - # Missing session, task, resume - } - - with pytest.raises(ValidationError): - validate(instance=invalid_handoff, schema=schema) -``` - -**Verification:** -```bash -pytest tests/test_handoff_schema.py -v -# Expected: PASS -``` - ---- - -#### Subtask 2.1.4: Document schema in README (DOCUMENTATION) - -**Time:** 2 min - -**File:** `default/schemas/README.md` - -**Code:** -```markdown -# Ring Schemas - -This directory contains JSON Schemas for Ring data structures. - -## handoff-schema-v1.json - -**Purpose:** Validates Ring session handoff documents. - -**Usage:** -\`\`\`python -from jsonschema import validate -import yaml, json - -# Load schema -with open('handoff-schema-v1.json') as f: - schema = json.load(f) - -# Validate handoff -with open('handoff.yaml') as f: - handoff = yaml.safe_load(f) - -validate(instance=handoff, schema=schema) -\`\`\` - -**Required Fields:** -- `version`: Schema version (e.g., "1.0") -- `schema`: Must be "ring-handoff-v1" -- `session.id`: Session identifier -- `task.description`: What was worked on -- `task.status`: completed|paused|blocked|failed -- `resume.next_steps`: Array of next steps (min 1) - -**See:** `handoff-example.yaml` for a complete example. -``` - -**Verification:** -```bash -test -f default/schemas/README.md || exit 1 -``` - ---- - -### Task 2.2: Implement YAML Serialization (4 subtasks) - -*(Details for 2.2.1 through 2.2.4 follow same pattern: test first, implement, verify)* - -#### Subtask 2.2.1: Write serialization tests (RED) - 3 min -#### Subtask 2.2.2: Implement serialize function (GREEN) - 4 min -#### Subtask 2.2.3: Implement deserialize function (GREEN) - 4 min -#### Subtask 2.2.4: Add token counting (ENHANCEMENT) - 2 min - ---- - -### Task 2.3: Update ring:handoff-tracking Skill (5 subtasks) - -#### Subtask 2.3.1: Create YAML handoff template - 3 min -#### Subtask 2.3.2: Update skill to use serializer - 4 min -#### Subtask 2.3.3: Add format parameter - 3 min -#### Subtask 2.3.4: Update skill documentation - 2 min -#### Subtask 2.3.5: Test skill with YAML output - 3 min - ---- - -### Task 2.4: Implement Dual-Format Indexing (5 subtasks) - -#### Subtask 2.4.1: Write test for YAML indexing (RED) - 3 min -#### Subtask 2.4.2: Add YAML parser to indexer (GREEN) - 4 min -#### Subtask 2.4.3: Update FTS extraction for YAML (GREEN) - 3 min -#### Subtask 2.4.4: Test backward compatibility (TEST) - 3 min -#### Subtask 2.4.5: Add format detection (ENHANCEMENT) - 2 min - ---- - -## Phase 3: Confidence Markers (2 Tasks → 12 Subtasks) - -**Context for ring:write-plan agent:** -- Read: `docs/pre-dev/ring-continuity/api-design.md` (confidence interfaces) -- Read: `docs/AGENT_DESIGN.md` (existing schemas) -- Read: `default/agents/codebase-explorer.md` (target agent) -- Focus: Add verification status to agent outputs -- Output: Updated schemas, updated agent, tests - ---- - -### Task 3.1: Update Agent Output Schemas (6 subtasks) - -#### Subtask 3.1.1: Add Verification section to Implementation schema - 3 min -#### Subtask 3.1.2: Add Verification section to Analysis schema - 3 min -#### Subtask 3.1.3: Add Verification section to Reviewer schema - 3 min -#### Subtask 3.1.4: Add Verification section to Exploration schema - 3 min -#### Subtask 3.1.5: Add Verification section to Planning schema - 3 min -#### Subtask 3.1.6: Create examples with confidence markers - 4 min - ---- - -### Task 3.2: Integrate in ring:codebase-explorer (6 subtasks) - -#### Subtask 3.2.1: Write test for confidence markers in output (RED) - 3 min -#### Subtask 3.2.2: Add Verification section requirement to agent prompt - 3 min -#### Subtask 3.2.3: Add evidence classification logic to prompt - 4 min -#### Subtask 3.2.4: Add anti-rationalization table for skipping verification - 3 min -#### Subtask 3.2.5: Update agent examples with markers - 2 min -#### Subtask 3.2.6: Test agent output contains markers - 3 min - ---- - -## Phase 4: Skill Activation (3 Tasks → 17 Subtasks) - -**Context for ring:write-plan agent:** -- Read: `docs/pre-dev/ring-continuity/api-design.md` (SkillActivator interface) -- Read: `default/hooks/generate-skills-ref.py` (skill parsing logic) -- Read: `.references/continuous-claude-v3/.claude/skills/skill-rules.json` (reference implementation) -- Focus: Auto-suggest skills based on user prompts -- Output: skill-rules.json, matcher, hook - ---- - -### Task 4.1: Create skill-rules.json (6 subtasks) - -#### Subtask 4.1.1: Write test for rules generation (RED) - 3 min -#### Subtask 4.1.2: Implement skill frontmatter parser - 4 min -#### Subtask 4.1.3: Implement keyword extractor from trigger field - 4 min -#### Subtask 4.1.4: Implement intent pattern generator - 4 min -#### Subtask 4.1.5: Generate initial skill-rules.json for 20 core skills - 5 min -#### Subtask 4.1.6: Validate generated rules against schema - 2 min - ---- - -### Task 4.2: Implement Skill Matching Engine (6 subtasks) - -#### Subtask 4.2.1: Write tests for keyword matching (RED) - 3 min -#### Subtask 4.2.2: Implement keyword matcher - 4 min -#### Subtask 4.2.3: Implement regex pattern matcher - 4 min -#### Subtask 4.2.4: Implement negative pattern filter - 3 min -#### Subtask 4.2.5: Implement ranking by priority - 3 min -#### Subtask 4.2.6: Add confidence scoring - 3 min - ---- - -### Task 4.3: Create Activation Hook (5 subtasks) - -#### Subtask 4.3.1: Write hook integration test (RED) - 3 min -#### Subtask 4.3.2: Create bash hook wrapper - 4 min -#### Subtask 4.3.3: Create Python matcher caller - 4 min -#### Subtask 4.3.4: Register hook in hooks.json - 2 min -#### Subtask 4.3.5: Test end-to-end activation flow - 4 min - ---- - -## Phase 5: Persistent Memory (3 Tasks → 15 Subtasks) - -**Context for ring:write-plan agent:** -- Read: `docs/pre-dev/ring-continuity/data-model.md` (Learning entity schema) -- Read: `docs/pre-dev/ring-continuity/api-design.md` (MemoryRepository interface) -- Read: `default/lib/artifact-index/artifact_schema.sql` (reference FTS5 schema) -- Read: `default/lib/artifact-index/artifact_index.py` (reference indexing code) -- Focus: Store and retrieve learnings with FTS5 search -- Output: Database schema, repository, hook - ---- - -### Task 5.1: Create Memory Database Schema (5 subtasks) - -#### Subtask 5.1.1: Write test for database creation (RED) - 3 min -#### Subtask 5.1.2: Create memory-schema.sql with tables - 5 min -#### Subtask 5.1.3: Add FTS5 virtual table and triggers - 4 min -#### Subtask 5.1.4: Implement schema initialization in Python - 4 min -#### Subtask 5.1.5: Test schema creation - 2 min - ---- - -### Task 5.2: Implement Memory Search & Retrieval (5 subtasks) - -#### Subtask 5.2.1: Write tests for memory storage (RED) - 3 min -#### Subtask 5.2.2: Implement store_learning method - 5 min -#### Subtask 5.2.3: Implement search_memories with FTS5 - 5 min -#### Subtask 5.2.4: Add BM25 ranking and filters - 4 min -#### Subtask 5.2.5: Implement access tracking - 2 min - ---- - -### Task 5.3: Create SessionStart Hook (5 subtasks) - -#### Subtask 5.3.1: Write hook test (RED) - 3 min -#### Subtask 5.3.2: Create bash hook wrapper - 3 min -#### Subtask 5.3.3: Create Python memory searcher - 5 min -#### Subtask 5.3.4: Register hook in hooks.json - 2 min -#### Subtask 5.3.5: Test memory injection end-to-end - 4 min - ---- - -## Subtask Statistics - -| Phase | Tasks | Subtasks | Estimated Time | -|-------|-------|----------|----------------| -| **1** | 3 | 16 | 2-3 days | -| **2** | 4 | 18 | 2-3 days | -| **3** | 2 | 12 | 1-2 days | -| **4** | 3 | 17 | 2-3 days | -| **5** | 3 | 15 | 2-3 days | -| **Total** | 15 | 78 | 9-14 days | - ---- - -## Execution Strategy for Write-Plan Agents - -### Agent 1: Phase 1 Foundation - -**Prompt Template:** -``` -Create detailed implementation plan for Ring Continuity Phase 1: Foundation. - -## Context Documents (MUST READ) -1. docs/pre-dev/ring-continuity/research.md - Research findings -2. docs/pre-dev/ring-continuity/prd.md - Product requirements -3. docs/pre-dev/ring-continuity/trd.md - Technical architecture -4. docs/pre-dev/ring-continuity/api-design.md - Component contracts -5. docs/pre-dev/ring-continuity/dependency-map.md - Technology stack - -## Scope -Tasks: 1.1, 1.2, 1.3 (16 subtasks total) -Goal: Establish ~/.ring/ directory with config precedence -Files: ring_home.py, config_loader.py, migration.py, install scripts - -## Requirements -- Follow TDD pattern (test first) -- Use Python 3.9+ stdlib -- Only dependency: PyYAML 6.0+ -- Complete code (no placeholders) -- Integration tests for full workflows - -Output implementation plan to: docs/plans/ring-continuity-phase1-plan.md -``` - -### Agent 2-5: Phases 2-5 - -**Same pattern for each phase:** -- Read all context documents -- Focus on specific task group -- Output to docs/plans/ring-continuity-phase{N}-plan.md - ---- - -## Gate 8 Validation Checklist - -- [x] Every subtask is 2-5 minutes -- [x] TDD cycle enforced (test first for implementation subtasks) -- [x] Complete code provided (no placeholders) -- [x] Zero-context executable (all context in subtask description) -- [x] 78 subtasks across 5 phases -- [x] Clear grouping for ring:write-plan agents -- [x] Context documents specified for each phase - ---- - -## Next Steps - -1. **Review all 9 gate documents** in `docs/pre-dev/ring-continuity/` -2. **Spawn 5 ring:write-plan agents** (one per phase) using subtasks as input -3. **Review plans for integration** - ensure phases work together -4. **Execute implementation** using `/ring:execute-plan` or ring:dev-cycle - ---- - -## Appendix: Complete Subtask List - -[78 subtasks listed above, organized by phase and task] diff --git a/docs/pre-dev/ring-continuity/tasks.md b/docs/pre-dev/ring-continuity/tasks.md deleted file mode 100644 index 9eb83d15..00000000 --- a/docs/pre-dev/ring-continuity/tasks.md +++ /dev/null @@ -1,931 +0,0 @@ -# Ring Continuity - Task Breakdown (Gate 7) - -> **Version:** 1.0 -> **Date:** 2026-01-12 -> **Status:** Draft -> **Based on:** Dependency Map v1.0 (docs/pre-dev/ring-continuity/dependency-map.md) - ---- - -## Executive Summary - -Ring Continuity implementation is broken into **5 phases** with **15 tasks** total. Each task delivers **independently deployable value** and includes **comprehensive testing**. Phases build on each other but each phase can ship to users separately. - -**Total Estimated Effort:** 8-12 days (1.5-2.5 weeks) - -**Phased Rollout:** -- Phase 1-2: Core foundation (3-4 days) -- Phase 3-4: Enhancements (3-4 days) -- Phase 5: Advanced features (2-4 days) - ---- - -## Phase Overview - -| Phase | Tasks | Value Delivered | Effort | Risk | -|-------|-------|-----------------|--------|------| -| **Phase 1: Foundation** | 3 | ~/.ring/ directory, config precedence | 2-3 days | Low | -| **Phase 2: YAML Handoffs** | 4 | 5x token efficiency, machine-parseable | 2-3 days | Low | -| **Phase 3: Confidence Markers** | 2 | Reduced false claims (80% → <20%) | 1-2 days | Low | -| **Phase 4: Skill Activation** | 3 | Auto-suggestion, better discovery | 2-3 days | Medium | -| **Phase 5: Persistent Memory** | 3 | Cross-session learning | 2-4 days | Medium | - ---- - -## Phase 1: Foundation - -**Goal:** Establish ~/.ring/ as Ring's global home with config precedence. - -**User Value:** Single source of truth for Ring configuration and data across all projects. - -**Success Criteria:** Users can customize Ring globally, projects can override. - ---- - -### Task 1.1: Create ~/.ring/ Directory Structure - -**Description:** Initialize ~/.ring/ with standard subdirectories on first Ring use. - -**Value:** Foundation for all persistent Ring data. - -**Acceptance Criteria:** -- [ ] ~/.ring/ created with subdirectories: config/, handoffs/, logs/, cache/, state/ -- [ ] Directory creation is idempotent (safe to run multiple times) -- [ ] Falls back to .ring/ in project if home directory not writable -- [ ] Logs creation to ~/.ring/logs/installation.log - -**Files to Create:** -- `default/lib/ring_home.py` - Directory manager -- `default/lib/ring_home_test.py` - Unit tests -- `install-ring-home.sh` - Standalone install script - -**Testing Strategy:** -```bash -# Unit tests -pytest default/lib/ring_home_test.py - -# Integration test -bash install-ring-home.sh -test -d ~/.ring/handoffs || exit 1 -test -f ~/.ring/config.yaml || exit 1 - -# Fallback test (simulate permission error) -chmod 000 ~/.ring && bash install-ring-home.sh -# Should create .ring/ in current project -``` - -**Dependencies:** None - -**Estimated Effort:** 0.5 days - -**Risk:** Low (straightforward directory creation) - ---- - -### Task 1.2: Implement Config Precedence System - -**Description:** Load and merge configuration from project → global → defaults. - -**Value:** Users can set global preferences and override per project. - -**Acceptance Criteria:** -- [ ] Load config from ~/.ring/config.yaml (global) -- [ ] Load config from .ring/config.yaml (project, if exists) -- [ ] Merge with precedence: project > global > hardcoded defaults -- [ ] Return config value with source attribution (which file it came from) -- [ ] Handle missing config files gracefully (use defaults) - -**Files to Create:** -- `default/lib/config_loader.py` - Config precedence logic -- `default/lib/config_loader_test.py` - Unit tests -- `~/.ring/config.yaml` - Default global config (created by install script) - -**Testing Strategy:** -```python -# Test precedence -def test_config_precedence(): - # Setup: global config has value A, project has value B - global_config = {'memory': {'enabled': True}} - project_config = {'memory': {'enabled': False}} - - merged = merge_configs(global_config, project_config) - - # Project should win - assert merged['memory']['enabled'] == False - assert merged['_source']['memory.enabled'] == 'project' - -# Test graceful degradation -def test_missing_config_files(): - # Neither global nor project exists - merged = load_config() - - # Should use hardcoded defaults - assert merged['memory']['enabled'] == True - assert merged['_source']['memory.enabled'] == 'default' -``` - -**Dependencies:** Task 1.1 (directory structure) - -**Estimated Effort:** 1 day - -**Risk:** Low (well-understood pattern) - ---- - -### Task 1.3: Create Migration Utilities - -**Description:** Migrate existing .ring/ data to ~/.ring/ with safety checks. - -**Value:** Users can adopt new structure without losing existing data. - -**Acceptance Criteria:** -- [ ] Detect existing .ring/ directories in projects -- [ ] Migrate handoffs to ~/.ring/handoffs/{project-name}/ -- [ ] Preserve original files (read-only copy, not move) -- [ ] Log migration results to ~/.ring/logs/migration.log -- [ ] Provide rollback script in case of issues - -**Files to Create:** -- `default/lib/migrate_to_ring_home.py` - Migration script -- `default/lib/migrate_to_ring_home_test.py` - Tests -- `scripts/migrate-ring-data.sh` - User-facing script - -**Testing Strategy:** -```bash -# Setup test project with old .ring/ -mkdir -p test-project/.ring/handoffs/session1/ -echo "---" > test-project/.ring/handoffs/session1/handoff.md - -# Run migration -python3 default/lib/migrate_to_ring_home.py test-project - -# Verify -test -f ~/.ring/handoffs/test-project/session1/handoff.md || exit 1 -test -f test-project/.ring/handoffs/session1/handoff.md || exit 1 # Original preserved -``` - -**Dependencies:** Task 1.1 (directory structure), Task 1.2 (config loading) - -**Estimated Effort:** 0.5-1 day - -**Risk:** Low (copy operations, no deletion) - ---- - -## Phase 2: YAML Handoffs - -**Goal:** Replace Markdown handoffs with token-efficient YAML format. - -**User Value:** 5x token savings (2000 → 400 tokens), machine-parseable for tools. - -**Success Criteria:** New handoffs use YAML, legacy Markdown still readable. - ---- - -### Task 2.1: Define YAML Handoff Schema - -**Description:** Create JSON Schema for handoff validation and documentation. - -**Value:** Prevents invalid handoffs, enables tooling. - -**Acceptance Criteria:** -- [ ] JSON Schema file defines all handoff fields -- [ ] Schema includes required vs optional fields -- [ ] Schema includes validation constraints (max lengths, enums) -- [ ] Schema documented with examples -- [ ] Schema version field for future evolution - -**Files to Create:** -- `default/schemas/handoff-schema-v1.json` - JSON Schema definition -- `default/schemas/handoff-example.yaml` - Example handoff -- `default/schemas/README.md` - Schema documentation - -**Testing Strategy:** -```python -# Validate example against schema -def test_schema_example(): - import json, yaml - from jsonschema import validate - - with open('default/schemas/handoff-schema-v1.json') as f: - schema = json.load(f) - - with open('default/schemas/handoff-example.yaml') as f: - example = yaml.safe_load(f) - - # Should validate without errors - validate(instance=example, schema=schema) -``` - -**Dependencies:** None - -**Estimated Effort:** 0.5 days - -**Risk:** Low (schema definition) - ---- - -### Task 2.2: Implement YAML Serialization - -**Description:** Create Python module to serialize/deserialize handoffs in YAML. - -**Value:** Core capability for YAML handoff creation. - -**Acceptance Criteria:** -- [ ] Serialize HandoffData to YAML string -- [ ] Deserialize YAML string to HandoffData -- [ ] Support frontmatter + body format (--- delimited) -- [ ] Validate against schema before write (if jsonschema available) -- [ ] Token count estimation from serialized output -- [ ] Handles Unicode correctly - -**Files to Create:** -- `default/lib/handoff_serializer.py` - Serialization logic -- `default/lib/handoff_serializer_test.py` - Unit tests - -**Testing Strategy:** -```python -def test_serialize_deserialize(): - original = HandoffData( - session={'id': 'test-session'}, - task={'description': 'Test task', 'status': 'completed'}, - # ... full handoff data - ) - - # Serialize - yaml_str = serialize(original, format='yaml') - - # Deserialize - restored = deserialize(yaml_str, format='yaml') - - # Should be equivalent - assert original == restored - -def test_token_count(): - handoff = create_sample_handoff() - yaml_str = serialize(handoff, format='yaml') - - token_count = estimate_tokens(yaml_str) - - # Should be under 500 tokens - assert token_count < 500 -``` - -**Dependencies:** Task 2.1 (schema), PyYAML - -**Estimated Effort:** 1 day - -**Risk:** Low (straightforward serialization) - ---- - -### Task 2.3: Update ring:handoff-tracking Skill for YAML - -**Description:** Modify ring:handoff-tracking skill to output YAML instead of Markdown. - -**Value:** Users immediately benefit from token savings. - -**Acceptance Criteria:** -- [ ] Skill generates YAML format by default -- [ ] User can choose format via parameter: `--format=yaml|markdown` -- [ ] YAML output validated against schema -- [ ] Token count logged to session -- [ ] Backward compatible with existing skill invocation - -**Files to Modify:** -- `default/skills/handoff-tracking/SKILL.md` - Update template and instructions -- `default/skills/handoff-tracking/handoff-template.yaml` - New YAML template - -**Testing Strategy:** -```bash -# Test YAML handoff creation -echo "Create handoff for test session" | claude-code - -# Verify YAML format -file_path=$(ls -t docs/handoffs/*/2026-*.yaml | head -1) -python3 -c "import yaml; yaml.safe_load(open('$file_path'))" # Should not error - -# Verify token count -tokens=$(wc -w < "$file_path" | awk '{print $1 * 1.3}') # Rough estimate -test "$tokens" -lt 500 || exit 1 -``` - -**Dependencies:** Task 2.2 (serialization) - -**Estimated Effort:** 1 day - -**Risk:** Low (skill update only) - ---- - -### Task 2.4: Implement Dual-Format Indexing - -**Description:** Update artifact indexer to handle both YAML and Markdown handoffs. - -**Value:** All handoffs searchable regardless of format. - -**Acceptance Criteria:** -- [ ] Detect handoff format from file extension (.yaml, .yml, .md) -- [ ] Extract frontmatter from both formats -- [ ] Parse YAML body sections (decisions, learnings, etc.) -- [ ] Index with same FTS5 schema regardless of source format -- [ ] Migration flag to convert Markdown to YAML during indexing - -**Files to Modify:** -- `default/lib/artifact-index/artifact_index.py` - Add YAML parsing -- `default/lib/artifact-index/artifact_index_test.py` - Add YAML tests - -**Testing Strategy:** -```python -def test_index_yaml_handoff(): - # Create YAML handoff - yaml_path = create_test_handoff_yaml() - - # Index it - index_handoff(yaml_path) - - # Search should find it - results = search_handoffs("test task") - assert len(results) > 0 - assert yaml_path in [r['file_path'] for r in results] - -def test_index_markdown_handoff(): - # Existing Markdown handoff - md_path = create_test_handoff_markdown() - - # Should still index - index_handoff(md_path) - - # Search should find it - results = search_handoffs("test task") - assert md_path in [r['file_path'] for r in results] -``` - -**Dependencies:** Task 2.2 (serialization), existing artifact-index - -**Estimated Effort:** 0.5-1 day - -**Risk:** Low (extends existing indexer) - ---- - -## Phase 3: Confidence Markers - -**Goal:** Add verification status to agent outputs to reduce false claims. - -**User Value:** Users know which findings are verified vs inferred (80% → <20% false claim rate). - -**Success Criteria:** Agents output ✓/?/✗ markers with evidence chains. - ---- - -### Task 3.1: Update Agent Output Schemas - -**Description:** Add "Verification" section to all agent output schemas in AGENT_DESIGN.md. - -**Value:** Standardized confidence reporting across all agents. - -**Acceptance Criteria:** -- [ ] Add "Verification" section to 5 schema archetypes -- [ ] Define confidence marker format (✓ VERIFIED, ? INFERRED, ✗ UNCERTAIN) -- [ ] Include evidence chain requirement -- [ ] Update schema validation examples -- [ ] Document when verification is required vs optional - -**Files to Modify:** -- `docs/AGENT_DESIGN.md` - Add Verification section to schemas -- `docs/AGENT_DESIGN.md` - Update examples with confidence markers - -**Testing Strategy:** -```bash -# Verify schema completeness -grep -c "## Verification" docs/AGENT_DESIGN.md -# Should return 5 (one per schema archetype) - -# Verify examples include markers -grep -c "✓ VERIFIED" docs/AGENT_DESIGN.md -grep -c "? INFERRED" docs/AGENT_DESIGN.md -grep -c "✗ UNCERTAIN" docs/AGENT_DESIGN.md -``` - -**Dependencies:** None (documentation only) - -**Estimated Effort:** 0.5 days - -**Risk:** Low (documentation update) - ---- - -### Task 3.2: Integrate Confidence Markers in ring:codebase-explorer - -**Description:** Update ring:codebase-explorer agent to include confidence markers in findings. - -**Value:** Most-used exploration agent immediately benefits from verification. - -**Acceptance Criteria:** -- [ ] Agent output includes ## Verification section -- [ ] Each finding marked with ✓/?/✗ -- [ ] Evidence chain documented (Glob → Read → Trace → ✓) -- [ ] Low-confidence findings flagged for review -- [ ] Agent prompt updated to enforce marker usage - -**Files to Modify:** -- `default/agents/codebase-explorer.md` - Add Verification requirements -- `default/agents/codebase-explorer.md` - Add anti-rationalization table for skipping verification - -**Testing Strategy:** -```bash -# Run explorer on test codebase -Task(subagent_type="ring:codebase-explorer", prompt="Find auth implementation") - -# Verify output contains markers -output=$(cat .claude/cache/agents/codebase-explorer/latest-output.md) -echo "$output" | grep "## Verification" || exit 1 -echo "$output" | grep -E "✓|✗|\?" || exit 1 -``` - -**Dependencies:** Task 3.1 (schema definition) - -**Estimated Effort:** 1 day - -**Risk:** Low (single agent update) - ---- - -## Phase 4: Skill Activation - -**Goal:** Auto-suggest relevant skills based on user prompt patterns. - -**User Value:** Users discover skills without memorizing names. - -**Success Criteria:** 80% of applicable skills suggested automatically. - ---- - -### Task 4.1: Create skill-rules.json with Core Skills - -**Description:** Generate initial skill-rules.json from existing skill frontmatter. - -**Value:** Immediate skill suggestions for most common workflows. - -**Acceptance Criteria:** -- [ ] Parse all SKILL.md files for trigger/skip_when fields -- [ ] Generate skill-rules.json with keyword mappings -- [ ] Include 20+ core skills (TDD, code review, systematic debugging, etc.) -- [ ] Validate generated JSON against schema -- [ ] Install to ~/.ring/skill-rules.json - -**Files to Create:** -- `scripts/generate-skill-rules.py` - Generator script -- `default/schemas/skill-rules-schema.json` - JSON Schema for rules -- `~/.ring/skill-rules.json` - Generated rules file - -**Testing Strategy:** -```python -def test_generate_rules(): - rules = generate_skill_rules_from_skills() - - # Should have entries for key skills - assert 'ring:test-driven-development' in rules['skills'] - assert 'ring:requesting-code-review' in rules['skills'] - - # Should have valid structure - validate(rules, skill_rules_schema) - -def test_keyword_extraction(): - skill_md = """ ---- -trigger: | - - When writing tests - - For test-driven development ---- -""" - keywords = extract_keywords(skill_md) - assert 'test' in keywords or 'tdd' in keywords -``` - -**Dependencies:** Existing skills with trigger frontmatter - -**Estimated Effort:** 1 day - -**Risk:** Medium (mapping logic may need tuning) - ---- - -### Task 4.2: Implement Skill Matching Engine - -**Description:** Create Python module to match prompts against skill rules. - -**Value:** Core logic for skill auto-suggestion. - -**Acceptance Criteria:** -- [ ] Load skill-rules.json with validation -- [ ] Match prompt text against keywords (case-insensitive) -- [ ] Match prompt against regex patterns (intent patterns) -- [ ] Apply negative patterns to filter false positives -- [ ] Rank matches by priority (critical > high > medium > low) -- [ ] Return top 5 matches with confidence scores - -**Files to Create:** -- `default/lib/skill_matcher.py` - Matching engine -- `default/lib/skill_matcher_test.py` - Unit tests - -**Testing Strategy:** -```python -def test_keyword_matching(): - rules = load_test_rules() - prompt = "I want to implement TDD for this feature" - - matches = match_skills(prompt, rules) - - assert 'ring:test-driven-development' in [m.skill for m in matches] - assert matches[0].confidence > 0.8 - -def test_negative_patterns(): - rules = { - 'ring:test-driven-development': { - 'keywords': ['test'], - 'negative_patterns': [r'test.*data', r'test.*environment'] - } - } - - # Should NOT match - prompt1 = "Load test data from database" - matches = match_skills(prompt1, rules) - assert 'ring:test-driven-development' not in [m.skill for m in matches] - - # Should match - prompt2 = "Write test for authentication" - matches = match_skills(prompt2, rules) - assert 'ring:test-driven-development' in [m.skill for m in matches] -``` - -**Dependencies:** Task 4.1 (skill-rules.json) - -**Estimated Effort:** 1-2 days - -**Risk:** Medium (false positive tuning needed) - ---- - -### Task 4.3: Create UserPromptSubmit Hook for Activation - -**Description:** Hook that suggests skills on user prompt submission. - -**Value:** Users see skill suggestions in real-time. - -**Acceptance Criteria:** -- [ ] Hook runs on UserPromptSubmit event -- [ ] Calls skill matcher with user prompt -- [ ] Returns suggestions in additionalContext -- [ ] Respects enforcement levels (block/suggest/warn) -- [ ] Completes within 1 second timeout -- [ ] Gracefully handles matcher failures - -**Files to Create:** -- `default/hooks/skill-activation.sh` - Bash hook wrapper -- `default/hooks/skill-activation.py` - Python matcher caller -- Update `default/hooks/hooks.json` - Register new hook - -**Testing Strategy:** -```bash -# Mock hook input -cat < 0 - assert "async" in results[0].content.lower() -``` - -**Dependencies:** Task 1.1 (directory structure) - -**Estimated Effort:** 1-2 days - -**Risk:** Low (similar to existing artifact-index) - ---- - -### Task 5.2: Implement Memory Search & Retrieval - -**Description:** Create search interface with BM25 ranking and filters. - -**Value:** Users can find relevant learnings quickly. - -**Acceptance Criteria:** -- [ ] Search by text query using FTS5 -- [ ] Filter by learning type, tags, confidence -- [ ] Rank by BM25 relevance score -- [ ] Apply recency weighting (recent learnings rank higher) -- [ ] Update access_count and accessed_at on retrieval -- [ ] Return top 10 results with highlights - -**Files to Modify:** -- `default/lib/memory_repository.py` - Add search methods - -**Testing Strategy:** -```python -def test_search_with_filters(): - repo = MemoryRepository('~/.ring/memory.db') - - results = repo.search_memories( - query="async await", - filters={'types': ['WORKING_SOLUTION'], 'confidence': 'HIGH'}, - limit=5 - ) - - assert len(results) <= 5 - assert all(r.type == 'WORKING_SOLUTION' for r in results) - assert all(r.confidence == 'HIGH' for r in results) - -def test_relevance_ranking(): - repo = MemoryRepository('~/.ring/memory.db') - - results = repo.search_memories("error handling") - - # Results should be sorted by relevance - for i in range(len(results) - 1): - assert results[i].relevance_score >= results[i+1].relevance_score -``` - -**Dependencies:** Task 5.1 (database schema) - -**Estimated Effort:** 1 day - -**Risk:** Low (FTS5 handles ranking) - ---- - -### Task 5.3: Create SessionStart Hook for Memory Injection - -**Description:** Hook that searches memories on session start and injects relevant ones. - -**Value:** Users automatically get context from past sessions. - -**Acceptance Criteria:** -- [ ] Hook runs on SessionStart event -- [ ] Searches memories using project path as context -- [ ] Injects top 5 relevant memories into session -- [ ] Formats memories for readability -- [ ] Completes within 2 seconds timeout -- [ ] Handles missing memory.db gracefully - -**Files to Create:** -- `default/hooks/memory-awareness.sh` - Hook wrapper -- `default/hooks/memory-awareness.py` - Python memory searcher -- Update `default/hooks/hooks.json` - Register hook - -**Testing Strategy:** -```bash -# Store test learning -python3 -c " -from memory_repository import MemoryRepository -repo = MemoryRepository('~/.ring/memory.db') -repo.store_learning('Test learning about async', type='WORKING_SOLUTION') -" - -# Mock SessionStart hook -cat < T1.2 (Config) - │ - └──> T1.3 (Migration) - -Phase 2: YAML Handoffs - T2.1 (Schema) ──> T2.2 (Serializer) ──> T2.3 (Skill Update) - └──> T2.4 (Indexing) - -Phase 3: Confidence Markers - T3.1 (Schema Update) ──> T3.2 (Explorer Integration) - -Phase 4: Skill Activation - T4.1 (Rules Gen) ──> T4.2 (Matcher) ──> T4.3 (Hook) - -Phase 5: Persistent Memory - T5.1 (DB Schema) ──> T5.2 (Search) ──> T5.3 (Hook) - -Cross-Phase Dependencies: - T1.1 ──> T2.2, T4.1, T5.1 (All need ~/.ring/) - T1.2 ──> T4.3, T5.3 (Hooks check config) - T2.2 ──> T2.4 (Indexing needs serializer) -``` - ---- - -## Testing Strategy Summary - -### Test Coverage Targets - -| Component | Unit Test Coverage | Integration Test Coverage | -|-----------|-------------------|---------------------------| -| ring_home.py | 90% | 100% | -| config_loader.py | 95% | 100% | -| handoff_serializer.py | 95% | 100% | -| skill_matcher.py | 90% | 90% | -| memory_repository.py | 90% | 95% | -| Hooks | 70% (bash) | 100% | - -### Test Data - -**Location:** `tests/fixtures/` - -**Test Artifacts:** -- Sample handoffs (YAML and Markdown) -- Sample skill-rules.json -- Sample memory.db with test learnings -- Sample config.yaml files (global and project) - -### Continuous Integration - -**On commit:** -```bash -# Lint -shellcheck default/hooks/*.sh -ruff check default/lib/ -black --check default/lib/ - -# Test -pytest tests/ --cov=default/lib/ --cov-report=term-missing - -# Coverage gate -coverage report --fail-under=85 -``` - ---- - -## Deployment Strategy - -### Rollout Plan - -| Phase | Users | Deployment | -|-------|-------|------------| -| **Phase 1-2** | Early adopters | Alpha release, manual install | -| **Phase 3-4** | Ring users | Beta release, install script | -| **Phase 5** | All users | Stable release, auto-update | - -### Feature Flags - -**Location:** `~/.ring/config.yaml` - -```yaml -features: - yaml_handoffs: true # Enable YAML handoff format - skill_activation: true # Enable auto-suggestion - memory_system: true # Enable persistent memory - confidence_markers: true # Enable markers in agent outputs -``` - -**Allows:** -- Gradual feature rollout -- Users opt-out if issues -- A/B testing different configurations - ---- - -## Risk Mitigation - -### High-Risk Tasks - -| Task | Risk | Mitigation | -|------|------|------------| -| T4.2 (Skill Matcher) | False positives | Extensive test suite with edge cases, negative patterns | -| T4.3 (Activation Hook) | Timeout issues | Async matching, 1s timeout, fallback to no suggestions | -| T5.3 (Memory Hook) | Performance impact | Cache searches, limit to 5 memories, 2s timeout | - -### Rollback Plan - -**Per Phase:** -1. **Phase 1:** Delete ~/.ring/, revert to .ring/ -2. **Phase 2:** Set `handoffs.format: markdown` in config -3. **Phase 3:** Agents output without markers (backward compatible) -4. **Phase 4:** Set `skill_activation.enabled: false` in config -5. **Phase 5:** Set `memory_system.enabled: false` in config - -**All features are opt-out via config.** - ---- - -## Gate 7 Validation Checklist - -- [x] Every task delivers user value independently -- [x] No task larger than 2 weeks (max is 2 days) -- [x] Dependencies are clear (dependency graph) -- [x] Testing approach defined per task -- [x] 5 phases map to 5 feature domains -- [x] 15 total tasks with clear acceptance criteria -- [x] Risk mitigation for high-risk tasks -- [x] Rollout plan with feature flags - ---- - -## Appendix: Task Summary - -| Phase | Task | Value | Effort | Risk | -|-------|------|-------|--------|------| -| **1** | T1.1 Directory Creation | Foundation | 0.5d | Low | -| **1** | T1.2 Config Precedence | Global preferences | 1d | Low | -| **1** | T1.3 Migration Utilities | Preserve existing data | 0.5-1d | Low | -| **2** | T2.1 YAML Schema | Handoff validation | 0.5d | Low | -| **2** | T2.2 YAML Serialization | Core capability | 1d | Low | -| **2** | T2.3 Update Skill | 5x token savings | 1d | Low | -| **2** | T2.4 Dual-Format Indexing | Universal search | 0.5-1d | Low | -| **3** | T3.1 Update Schemas | Standardized confidence | 0.5d | Low | -| **3** | T3.2 Explorer Integration | Reduced false claims | 1d | Low | -| **4** | T4.1 Generate Rules | Initial rules | 1d | Medium | -| **4** | T4.2 Matching Engine | Core activation logic | 1-2d | Medium | -| **4** | T4.3 Activation Hook | Real-time suggestions | 1d | Medium | -| **5** | T5.1 Memory Database | Persistent storage | 1-2d | Low | -| **5** | T5.2 Memory Search | Learning retrieval | 1d | Low | -| **5** | T5.3 Memory Hook | Auto-injection | 1-2d | Medium | - -**Total:** 12-18 days (2.5-3.5 weeks) diff --git a/docs/pre-dev/ring-continuity/trd.md b/docs/pre-dev/ring-continuity/trd.md deleted file mode 100644 index b800d1dc..00000000 --- a/docs/pre-dev/ring-continuity/trd.md +++ /dev/null @@ -1,1023 +0,0 @@ -# Ring Continuity - Technical Requirements Document (Gate 3) - -> **Version:** 1.0 -> **Date:** 2026-01-12 -> **Status:** Draft -> **Based on:** PRD v1.0, Feature Map v1.0 - ---- - -## Executive Summary - -Ring Continuity implements a **layered persistence architecture** using the **plugin pattern** with **hook-based lifecycle interception**, **document-oriented storage**, and **full-text search indexing**. The architecture is technology-agnostic, lightweight (no external services), and maintains backward compatibility through **dual-format adapters**. - -**Core Patterns:** -- **Repository Pattern** for memory and handoff storage abstraction -- **Strategy Pattern** for multi-format handoff support (YAML/Markdown) -- **Observer Pattern** for hook-based event interception -- **Factory Pattern** for configuration precedence (global/project/default) - ---- - -## Architecture Style - -### Primary Patterns - -| Pattern | Application | Rationale | -|---------|-------------|-----------| -| **Layered Architecture** | Foundation → Storage → Enhancement → Integration | Clear separation of concerns, testable layers | -| **Plugin Architecture** | Hook registration, lifecycle events | Extends Claude Code without core modifications | -| **Document-Oriented Storage** | YAML/Markdown files, not object storage | Human-readable, version-controllable | -| **Repository Pattern** | Abstract storage backends | Swap implementations (file vs DB) | -| **Strategy Pattern** | Multiple handoff formats | Backward compatibility without conditionals | - -### Architecture Principles - -1. **Fail-Safe Defaults:** If ~/.ring/ doesn't exist, use project .ring/ -2. **Graceful Degradation:** If memory unavailable, continue without it -3. **Explicit Over Implicit:** Config precedence clearly documented -4. **Progressive Enhancement:** Each feature works independently -5. **Zero External Dependencies:** All persistence local - ---- - -## Component Design (Technology-Agnostic) - -### Component 1: Ring Home Directory Manager - -**Responsibility:** Initialize and manage the ~/.ring/ directory structure. - -**Interface:** -``` -Component: RingHomeManager - -Operations: - - ensure_directory_exists() -> path - - get_config(scope: global|project) -> config - - merge_configs(global, project) -> merged_config - - migrate_legacy_data(source_path) -> migration_result - -Dependencies: - - File system access (via FileSystem adapter) - - Configuration parser - -Outputs: - - ~/.ring/ directory with standard structure - - Merged configuration object -``` - -**Behavior:** -- Check for ~/.ring/ existence on first operation -- Create standard subdirectories (config, memory, handoffs, logs) -- Load configurations with precedence: project > global > defaults -- Provide migration path from legacy locations - -**Error Handling:** -- If creation fails (permissions), fall back to project .ring/ -- If config invalid, use defaults and log warning -- If migration fails, preserve originals and report - ---- - -### Component 2: Memory Storage & Retrieval - -**Responsibility:** Persist and search learnings using full-text indexing. - -**Interface:** -``` -Component: MemoryRepository - -Operations: - - store_learning(content, type, tags, confidence) -> learning_id - - search_memories(query, filters) -> ranked_results - - get_memory(id) -> memory - - update_access_time(id) -> void - - apply_decay(age_threshold) -> affected_count - - prune_expired() -> deleted_count - -Dependencies: - - Full-text search engine - - Timestamp provider - - Secret sanitizer (regex/entropy-based) - -Data Types: - - Learning: {id, content, type, tags, confidence, created_at, accessed_at} - - SearchResult: {learning, relevance_score} -``` - -**Behavior:** -- Store learnings with automatic timestamps (sanitize secrets first) -- Search using full-text index with ranking -- Update access time on retrieval (for decay calculation) -- Decay relevance based on age + access patterns (Async/Background) -- Prune memories with expired_at < now() - -**Error Handling:** -- If DB locked, retry with exponential backoff -- If search fails, fall back to simple text matching -- If storage fails, log and continue (non-critical) - ---- - -### Component 3: Handoff Serialization - -**Responsibility:** Convert session state to/from persistent formats. - -**Interface:** -``` -Component: HandoffSerializer - -Operations: - - serialize(handoff_data, format: yaml|markdown) -> string - - deserialize(content, format: yaml|markdown) -> handoff_data - - validate_schema(handoff_data) -> validation_result - - infer_format(file_path) -> format - - migrate(markdown_path) -> yaml_path - -Dependencies: - - YAML parser - - Markdown parser - - Schema validator - -Data Types: - - HandoffData: {session, task, decisions, artifacts, resume} - - ValidationResult: {valid, errors} -``` - -**Behavior:** -- Strategy Pattern: Choose serializer based on format parameter -- Validation before write (fail fast on invalid schema) -- Dual-format read support (detect from file extension or content) -- Migration preserves original and creates YAML copy - -**Error Handling:** -- If YAML parse fails, try Markdown fallback -- If validation fails, return detailed error messages -- If migration fails, leave original intact - ---- - -### Component 4: Skill Activation Engine - -**Responsibility:** Match user prompts to relevant skills using pattern matching. - -**Interface:** -``` -Component: SkillActivator - -Operations: - - load_rules(path) -> rules - - match_skills(prompt, rules) -> matches - - filter_by_priority(matches) -> filtered_matches - - apply_enforcement(match) -> action: block|suggest|warn - - validate_rules(rules) -> validation_result - -Dependencies: - - Pattern matcher (regex, keywords) - - Rule validator - - Priority resolver - -Data Types: - - SkillRule: {skill, type, enforcement, priority, triggers} - - Match: {skill, score, enforcement} - - Trigger: {keywords, intent_patterns, negative_patterns} -``` - -**Behavior:** -- Load rules from global and project locations -- Match prompt against keywords (exact) and patterns (regex) -- Apply negative patterns to reduce false positives -- Rank matches by priority -- Return action based on enforcement level - -**Error Handling:** -- If rules invalid, skip auto-activation and log -- If pattern compilation fails, skip that pattern -- If multiple high-priority matches, suggest all - ---- - -### Component 5: Confidence Annotation - -**Responsibility:** Add verification status to agent outputs. - -**Interface:** -``` -Component: ConfidenceMarker - -Operations: - - mark_finding(claim, evidence) -> marked_claim - - infer_confidence(evidence_type) -> confidence_level - - format_marker(confidence) -> symbol - - validate_evidence(evidence) -> valid - -Dependencies: - - Evidence type classifier - -Data Types: - - Claim: {content, evidence, confidence} - - Evidence: {type: read_file|grep|assumption, description} - - Confidence: verified|inferred|uncertain -``` - -**Behavior:** -- Classify evidence type from agent actions -- Map evidence to confidence level: - - read_file → verified - - grep/search → inferred - - no_check → uncertain -- Format with symbols: ✓/?/✗ -- Include evidence chain in output - -**Error Handling:** -- If evidence missing, default to uncertain -- If evidence ambiguous, default to inferred -- If marker formatting fails, use text fallback - ---- - -### Component 6: Hook Orchestrator - -**Responsibility:** Coordinate lifecycle events and inject context. - -**Interface:** -``` -Component: HookOrchestrator - -Operations: - - on_session_start(context) -> injected_context - - on_user_prompt(prompt) -> suggestions - - on_post_write(file_path) -> index_result - - on_pre_compact() -> state_snapshot - - on_stop() -> learning_extraction - -Dependencies: - - MemoryRepository - - SkillActivator - - HandoffSerializer - -Outputs: - - JSON response with additionalContext or blocking result -``` - -**Behavior:** -- SessionStart: Load config, inject memories, load active ledger -- UserPromptSubmit: Match skills, suggest relevant -- PostToolUse (Write): Index if handoff/plan/ledger -- PreCompact: Create auto-handoff from state -- Stop: Extract learnings, mark outcomes - -**Error Handling:** -- If hook fails, log and continue (don't block Claude) -- If timeout exceeded, return partial result -- If JSON malformed, wrap in error response - ---- - -## Data Architecture (Conceptual) - -### Storage Model - -``` -User Scope (Global) - └─ ~/.ring/ - ├─ Configuration (hierarchical key-value) - ├─ Memory Store (indexed text corpus) - └─ Handoff Archive (document collection) - -Project Scope (Local) - └─ .ring/ - ├─ Configuration (overrides) - ├─ Session State (ephemeral) - └─ Ledgers (within-session persistence) -``` - -### Data Flow - -``` -Session Start - │ - ▼ -Load Config (project > global > default) - │ - ▼ -Query Memory (FTS search on prompt context) - │ - ▼ -Inject Context (memories + config into session) - │ - ▼ -User Prompt - │ - ▼ -Match Skills (keyword + intent patterns) - │ - ▼ -Suggest/Block/Warn (based on enforcement) - │ - ▼ -Execution (agent with confidence markers) - │ - ▼ -Write Output (YAML handoff with validation) - │ - ▼ -Index Document (FTS5 for future search) - │ - ▼ -Store Learning (successful patterns, failures) - │ - ▼ -Session End -``` - -### State Management - -| State Type | Lifetime | Storage | Example | -|------------|----------|---------|---------| -| **Ephemeral** | Single turn | Memory only | Current prompt | -| **Session** | Until /clear | Project .ring/state/ | Todo list, context usage | -| **Ledger** | Survives /clear | Project .ring/ledgers/ | Phase progress | -| **Handoff** | Cross-session | ~/.ring/handoffs/ or docs/handoffs/ | Session resume | -| **Memory** | Persistent | ~/.ring/memory.db | Learnings, preferences | -| **Config** | Persistent | ~/.ring/config.yaml | User settings | - ---- - -## Integration Patterns - -### Pattern 1: Hook-Based Extension - -**Pattern Name:** Observer Pattern via Lifecycle Hooks - -**Description:** Claude Code provides lifecycle events (SessionStart, UserPromptSubmit, etc.). Ring registers hook scripts that receive event data via stdin and respond via stdout JSON. - -**Components Involved:** -- Hook Orchestrator -- All storage components (via hooks) - -**Data Flow:** -``` -Claude Code → Lifecycle Event → Hook Script (stdin JSON) - ↓ - Process Event - ↓ - Output JSON (stdout) - ↓ -Claude Code ← Response ← Parse JSON Response -``` - -**Why This Pattern:** -- Non-invasive extension of Claude Code -- Hooks are isolated, testable units -- Easy to enable/disable features - ---- - -### Pattern 2: Dual-Format Adapter - -**Pattern Name:** Strategy Pattern for Format Handling - -**Description:** Support both YAML (new) and Markdown (legacy) handoff formats through adapter interface. - -**Components Involved:** -- Handoff Serialization - -**Architecture:** -``` -HandoffSerializer (interface) - ├─ YAMLSerializer (strategy 1) - └─ MarkdownSerializer (strategy 2) - -Usage: - serializer = choose_serializer(format) - output = serializer.serialize(data) -``` - -**Why This Pattern:** -- Backward compatibility without conditionals -- Easy to add new formats -- Each serializer is independently testable - ---- - -### Pattern 3: Precedence-Based Configuration - -**Pattern Name:** Chain of Responsibility for Config Resolution - -**Description:** Configuration values resolved through precedence chain: project → global → default. - -**Components Involved:** -- Ring Home Directory Manager - -**Resolution Flow:** -``` -Request config key "skill_activation" - │ - ▼ -Check .ring/config.yaml (project) - │ - ├─ Found? → Return value - │ - ▼ -Check ~/.ring/config.yaml (global) - │ - ├─ Found? → Return value - │ - ▼ -Return hardcoded default -``` - -**Why This Pattern:** -- Clear override semantics -- Users know where to configure -- Projects can override global without editing - ---- - -### Pattern 4: Full-Text Search Abstraction - -**Pattern Name:** Repository Pattern with FTS Backend - -**Description:** Memory and artifact search abstracted behind repository interface, allowing FTS implementation swap. - -**Components Involved:** -- Memory Storage & Retrieval -- Handoff Serialization (via indexing) - -**Architecture:** -``` -SearchRepository (interface) - ├─ index(document) -> void - ├─ search(query) -> results - └─ rank(results) -> ranked_results - -FTS5Repository (implementation) - ├─ Uses full-text search engine - ├─ BM25 ranking - └─ Phrase and boolean queries -``` - -**Why This Pattern:** -- Could swap FTS5 for embeddings later -- Testing with mock repository -- Performance optimization without interface changes - ---- - -## Security Architecture - -### Threat Model - -| Threat | Risk Level | Mitigation | -|--------|------------|------------| -| **Path Traversal** | High | Validate session IDs, sanitize file paths | -| **Code Injection** | Medium | Use safe YAML loading, validate schemas | -| **Secret Persistence** | Medium | Sanitize inputs, redact secrets before storage | -| **Sensitive Data Leakage** | Medium | Never store credentials in memories/handoffs | -| **Concurrent Access** | Low | File locking for writes, read-only for searches | -| **Disk Space Exhaustion** | Low | Memory expiration, size limits | - -### Security Controls - -**1. Input Validation** -- Session IDs: `^[a-zA-Z0-9_-]+$` (alphanumeric, hyphen, underscore only) -- File paths: Canonical path resolution, no "../" traversal -- YAML loading: Use safe parser (no arbitrary code execution) -- JSON Schema: Validate all external inputs - -**2. Data Privacy** -- All data stored locally (never transmitted) -- No cloud sync, no external services -- User can delete ~/.ring/ anytime -- No telemetry, no tracking -- **Secret Redaction:** Strip API keys/tokens from memory inputs - -**3. Access Control** -- Files use strict permissions (0600 for files, 0700 for dirs) -- No privilege escalation required -- No network access needed - -**4. Audit Trail** -- Debug logs in ~/.ring/logs/ (opt-in) -- Memory creation timestamps -- Handoff outcome tracking - ---- - -## Performance Architecture - -### Performance Requirements - -| Operation | Target | Rationale | -|-----------|--------|-----------| -| **SessionStart hook** | <2 seconds | User experience | -| **Memory search** | <200ms | Interactive response | -| **Handoff write** | <500ms | Non-blocking | -| **Skill matching** | <100ms | User prompt flow | -| **Hook execution** | <10 seconds | Claude Code timeout | - -### Performance Strategies - -**1. Lazy Loading** -- Don't load memories until needed -- Index on write, not on read -- Cache config in memory during session - -**2. Incremental Indexing** -- Index new documents immediately -- Full reindex only on demand -- Use triggers for automatic FTS sync - -**3. Query Optimization** -- Limit search results (top 10) -- Use covering indexes where possible -- Avoid full table scans - -**4. Async Processing** -- Learning extraction runs in background (Stop hook) -- Indexing can be deferred -- Memory decay runs offline - ---- - -## Scalability Architecture - -### Scalability Constraints - -| Dimension | Limit | Reason | -|-----------|-------|--------| -| **Memories** | ~10,000 entries | FTS5 performant up to 100K rows | -| **Handoffs** | ~1,000 files | File system performance | -| **Session duration** | Unlimited | Stateless operations | -| **Concurrent sessions** | ~5 active | File locking contention | - -### Scaling Strategies - -**Horizontal Scaling:** Not applicable (single-user, local tool) - -**Vertical Scaling:** -1. **Memory Pruning:** Auto-delete memories older than 6 months with 0 accesses -2. **Handoff Archival:** Move old handoffs to archive directory -3. **Index Optimization:** Periodic FTS5 OPTIMIZE operation -4. **Disk Space Monitoring:** Warn if ~/.ring/ exceeds 100MB - -**Degradation Path:** -- If memory.db exceeds 50MB → suggest pruning -- If search slow (>1s) → suggest reindex -- If disk full → disable memory storage, keep handoffs - ---- - -## Component Interaction Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ CLAUDE CODE │ -└────────────┬────────────────────────────────────────────────────┘ - │ - │ Lifecycle Events - │ -┌────────────▼────────────────────────────────────────────────────┐ -│ HOOK ORCHESTRATOR │ -│ │ -│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ -│ │SessionStart│ │PromptSubmit│ │ PostWrite │ │ -│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ -│ │ │ │ │ -└────────┼────────────────┼────────────────┼───────────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌────────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Ring Home │ │ Skill │ │ Handoff │ -│ Manager │ │ Activator │ │ Serializer │ -└────┬───────────┘ └──────┬───────┘ └──────┬───────┘ - │ │ │ - │ Config │ Rules │ Documents - │ │ │ -┌────▼─────────────────────▼────────────────▼───────┐ -│ PERSISTENT STORAGE │ -│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ -│ │ Config │ │ Rules │ │ Documents │ │ -│ │ Files │ │ JSON │ │ YAML/MD │ │ -│ └──────────┘ └──────────┘ └──────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ Memory Repository │ │ -│ │ ┌─────────┐ ┌──────────┐ ┌───────────┐ │ │ -│ │ │Database │ │FTS Index │ │ Ranking │ │ │ -│ │ └─────────┘ └──────────┘ └───────────┘ │ │ -│ └─────────────────────────────────────────────┘ │ -└────────────────────────────────────────────────────┘ -``` - ---- - -## Technology-Agnostic Component Specifications - -### Component: Ring Home Manager - -**Architecture Style:** Factory + Template Method - -**Core Responsibilities:** -1. Directory initialization with standard structure -2. Configuration loading with precedence resolution -3. Migration coordination from legacy locations - -**Key Algorithms:** -- **Directory Initialization:** Check existence → Create if missing → Set permissions -- **Config Merge:** Load project → Load global → Apply defaults → Merge with override rules -- **Migration:** Detect legacy → Copy to new → Validate → Update references - -**Contracts:** -``` -ensure_directory_exists(): - Pre-conditions: File system access - Post-conditions: ~/.ring/ exists or fallback path set - Invariants: Standard subdirectories present - -get_config(scope): - Pre-conditions: Directory initialized - Post-conditions: Returns valid config object - Invariants: Never returns null, uses defaults if file missing -``` - ---- - -### Component: Memory Repository - -**Architecture Style:** Repository + Strategy - -**Core Responsibilities:** -1. Learning persistence with type classification -2. Full-text search with relevance ranking -3. Decay mechanism for temporal relevance -4. Expiration handling for temporary learnings - -**Key Algorithms:** -- **Storage:** Validate type → Check duplicates (similarity) → Insert with timestamp -- **Search:** Parse query → FTS search → Rank by BM25 + recency → Apply decay -- **Decay:** score_adjusted = score_raw × decay_factor(age, access_count) -- **Prune:** WHERE (expires_at < now()) OR (age > 180 days AND access_count = 0) - -**Contracts:** -``` -store_learning(content, type, confidence): - Pre-conditions: type in VALID_TYPES, confidence in VALID_LEVELS - Post-conditions: Learning stored with unique ID - Invariants: created_at = now(), accessed_at = created_at - -search_memories(query): - Pre-conditions: query not empty - Post-conditions: Results ranked by relevance - Invariants: Results count <= limit, sorted desc by score -``` - ---- - -### Component: Handoff Serializer - -**Architecture Style:** Strategy + Adapter - -**Core Responsibilities:** -1. YAML serialization with schema validation -2. Markdown deserialization (legacy support) -3. Format detection and routing -4. Migration from Markdown to YAML - -**Key Algorithms:** -- **Serialize:** Validate schema → Extract frontmatter → Extract body → Combine with delimiter -- **Deserialize:** Detect format → Route to parser → Validate structure → Return object -- **Migrate:** Parse Markdown → Map to YAML schema → Validate → Write → Preserve original - -**Contracts:** -``` -serialize(data, format): - Pre-conditions: data passes schema validation - Post-conditions: Returns valid YAML or Markdown string - Invariants: Serialized data deserializes to original data - -deserialize(content, format): - Pre-conditions: content is valid YAML or Markdown - Post-conditions: Returns structured object - Invariants: If format=auto, correctly infers format -``` - ---- - -### Component: Skill Activator - -**Architecture Style:** Rules Engine + Matcher - -**Core Responsibilities:** -1. Load and validate activation rules -2. Match prompts against patterns -3. Apply enforcement levels -4. Reduce false positives via negative patterns - -**Key Algorithms:** -- **Match:** For each rule: (match keywords OR match patterns) AND NOT match negatives -- **Score:** Exact keyword match = 1.0, pattern match = 0.8, priority weight -- **Rank:** Sort by (priority_weight × match_score) -- **Filter:** Apply confidence threshold, remove duplicates - -**Contracts:** -``` -match_skills(prompt, rules): - Pre-conditions: rules validated against schema - Post-conditions: Returns matches sorted by relevance - Invariants: Enforcement level respected, no duplicates - -apply_enforcement(match): - Pre-conditions: match has valid enforcement level - Post-conditions: Returns block|suggest|warn action - Invariants: block > suggest > warn in priority -``` - ---- - -### Component: Confidence Marker - -**Architecture Style:** Decorator + Builder - -**Core Responsibilities:** -1. Annotate findings with verification status -2. Build evidence chains -3. Format output with visual markers -4. Flag low-confidence for review - -**Key Algorithms:** -- **Classify:** Map tool usage to confidence: Read → verified, Grep → inferred, None → uncertain -- **Chain:** Track sequence: Grep → Read → Trace → Verified -- **Format:** Apply symbols: ✓ verified, ? inferred, ✗ uncertain -- **Review Flag:** IF confidence < threshold OR evidence = assumption THEN flag - -**Contracts:** -``` -mark_finding(claim, evidence): - Pre-conditions: claim not empty, evidence list valid - Post-conditions: Returns claim with marker and evidence - Invariants: Marker matches confidence level - -infer_confidence(evidence): - Pre-conditions: evidence type in VALID_TYPES - Post-conditions: Returns valid confidence level - Invariants: Default to 'uncertain' if ambiguous -``` - ---- - -## Deployment Architecture - -### Installation Flow - -``` -User runs: curl ... | bash - │ - ▼ -Installer script - │ - ├─> Check ~/.ring/ existence - │ └─> Create if missing - │ - ├─> Copy skill/agent/hook files - │ - ├─> Initialize memory.db schema - │ - ├─> Create default skill-rules.json - │ - └─> Output success message -``` - -### Runtime Architecture - -**Process Model:** Single-process, synchronous hooks - -``` -Claude Code (main process) - │ - ├─> Spawn hook script (subprocess) - │ └─> Hook executes, returns JSON - │ - ├─> Parse hook response - │ - └─> Continue with injected context -``` - -**No Daemons Required:** All operations synchronous within hook timeouts - -### File System Layout - -``` -~/.ring/ # Global Ring home -├── config.yaml # User configuration -├── memory.db # SQLite database -├── skill-rules.json # Activation rules -├── skill-rules-schema.json # JSON Schema -├── handoffs/ # Cross-project handoffs -│ └── {session-name}/ -│ └── {timestamp}.yaml -├── logs/ # Debug logs (opt-in) -│ └── {date}.log -└── cache/ # Temporary data - └── last-session.json - -$PROJECT/.ring/ # Project-local -├── config.yaml # Project config (optional) -├── state/ # Session state -│ ├── context-usage-{session}.json -│ └── todos-state-{session}.json -├── ledgers/ # Within-session continuity -│ └── CONTINUITY-{name}.md -└── cache/ - └── artifact-index/ - └── context.db # Project artifacts index -``` - ---- - -## Error Handling Architecture - -### Error Categories - -| Category | Severity | Response | -|----------|----------|----------| -| **Critical** | Blocks functionality | Halt and report to user | -| **Degraded** | Partial functionality | Continue with warning | -| **Informational** | No impact | Log only | - -### Error Handling by Component - -**Ring Home Manager:** -- Creation failure (permissions) → Use .ring/ fallback, warn user -- Config parse error → Use defaults, log warning -- Migration failure → Keep original, report error - -**Memory Repository:** -- DB locked → Retry 3x with backoff, then skip memory -- Search error → Fallback to simple text search -- Storage error → Log and continue (memory is enhancement, not critical) - -**Handoff Serializer:** -- YAML parse error → Try Markdown parser -- Validation error → Return detailed errors, block write -- Migration error → Preserve original, report failure - -**Skill Activator:** -- Rules invalid → Skip auto-activation, log error -- Pattern compilation error → Skip that pattern -- Multiple block-level matches → Suggest all, let user choose - -**Hook Orchestrator:** -- Hook timeout → Return partial results -- Hook crash → Log error, continue without hook -- JSON malformed → Wrap in error response, continue - -### Fallback Chain - -``` -Operation Failed - │ - ▼ -Try Primary Method (e.g., ~/.ring/memory.db) - │ - ├─> Success → Return result - │ - ▼ -Try Fallback (e.g., .ring/memory.db project-local) - │ - ├─> Success → Return result - │ - ▼ -Try Last Resort (e.g., in-memory only, no persistence) - │ - ├─> Success → Return result + warning - │ - ▼ -Graceful Failure (e.g., skip memory, continue session) -``` - ---- - -## Testing Architecture - -### Test Pyramid - -``` - ┌────────┐ - ╱ E2E ╲ 10% - Full workflow tests - ╱────────────╲ - ╱ Integration ╲ 30% - Component interaction tests - ╱────────────────╲ - ╱ Unit Tests ╲ 60% - Component isolation tests - ╱──────────────────── ╲ -``` - -**Test Types:** - -| Type | Purpose | Example | -|------|---------|---------| -| **Unit** | Component in isolation | Test YAML serialization | -| **Integration** | Components together | Test memory search with FTS5 | -| **E2E** | Full user workflows | Test session start → memory inject → handoff create | - -### Test Strategy by Component - -**Ring Home Manager:** -- Unit: Config precedence logic -- Integration: Directory creation with mockable FileSystem -- E2E: First-time setup flow - -**Memory Repository:** -- Unit: Learning type classification, decay calculation -- Integration: SQLite FTS5 search and ranking -- E2E: Store learning → Search → Retrieve → Verify - -**Handoff Serializer:** -- Unit: YAML schema validation -- Integration: Read legacy Markdown, write YAML -- E2E: Create handoff → Index → Resume from handoff - -**Skill Activator:** -- Unit: Keyword matching, regex compilation -- Integration: Pattern matching with actual skill-rules.json -- E2E: Prompt → Match → Suggest → User selects - -**Hook Orchestrator:** -- Unit: JSON input/output parsing -- Integration: Hook event dispatch -- E2E: SessionStart → Load config → Inject memories → Continue - ---- - -## Migration Architecture - -### Migration Strategy: Dual-Support Transition - -**Phase 1:** Add YAML support, keep Markdown (1-2 weeks) -- Both formats work -- New handoffs use YAML -- Old handoffs remain Markdown - -**Phase 2:** Migration tools (2-4 weeks after Phase 1) -- Provide conversion utility -- Users can opt-in to convert -- Keep originals - -**Phase 3:** Deprecation (3-6 months after Phase 2) -- Announce Markdown deprecation -- Warn on Markdown handoff creation -- Keep read support indefinitely - -### Backward Compatibility Requirements - -| Feature | Requirement | Implementation | -|---------|-------------|----------------| -| **Handoff reading** | Support both YAML and Markdown | Dual-format adapter | -| **Artifact indexing** | Index both formats | Detect format, extract frontmatter | -| **Skill frontmatter** | Keep existing YAML structure | Extend, don't replace | -| **Hook JSON** | Maintain current schema | Additive changes only | - -### Breaking Change Policy - -**Zero breaking changes** for existing workflows: -- Existing skills continue to work -- Existing handoffs remain readable -- Existing hooks remain functional -- Existing commands unchanged (output format changes only) - -**Opt-in for new features:** -- YAML handoffs: User chooses format -- Memory system: Auto-enabled, but graceful if disabled -- Skill activation: Suggest-only by default -- Confidence markers: Added to new outputs, legacy outputs unchanged - ---- - -## Gate 3 Validation Checklist - -- [x] All Feature Map domains mapped to components (5 components) -- [x] All PRD features mapped to components (27 features → 6 components) -- [x] Component boundaries are clear (responsibility tables) -- [x] Interfaces are technology-agnostic (no SQLite/PyYAML specifics) -- [x] No specific products named (FTS not named as "SQLite FTS5" yet) -- [x] Architecture patterns documented (Repository, Strategy, Observer, Factory) -- [x] Integration patterns defined (Hook-based, Dual-format, Precedence-based) -- [x] Security, performance, scalability addressed -- [x] Migration strategy documented -- [x] Error handling defined per component - ---- - -## Appendix: Pattern Catalog - -| Pattern Name | Category | Where Applied | -|--------------|----------|---------------| -| Repository | Structural | Memory Storage, Search Abstraction | -| Strategy | Behavioral | Handoff format selection | -| Observer | Behavioral | Hook event system | -| Factory | Creational | Config precedence resolution | -| Adapter | Structural | Dual-format handoff support | -| Decorator | Structural | Confidence marker addition | -| Chain of Responsibility | Behavioral | Config precedence chain | -| Template Method | Behavioral | Hook execution flow | diff --git a/shared/lib/context-check.sh b/shared/lib/context-check.sh deleted file mode 100755 index 43179237..00000000 --- a/shared/lib/context-check.sh +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# STATELESS Context Check Utilities -# ============================================================================= -# PURPOSE: Pure functions for context estimation without file I/O -# -# USE THIS WHEN: -# - You need quick context percentage calculations -# - You need warning tier lookups without state -# - You're building UI/display components -# -# DO NOT USE FOR: -# - Tracking turn counts (use default/lib/shell/context-check.sh) -# - Persisting state across invocations -# -# KEY FUNCTIONS: -# - estimate_context_pct() - Calculate % from turn count (pure function) -# - get_warning_tier() - Get tier name from percentage (pure function) -# - format_context_warning() - Format user-facing warning message -# -# FORMULA: -# estimated_tokens = 45000 + (turn_count * 2500) -# percentage = estimated_tokens * 100 / context_size -# -# TIERS (4 levels): -# - none: 0-49% (safe, no warning) -# - info: 50-69% (informational notice) -# - warning: 70-84% (recommend handoff/ledger) -# - critical: 85%+ (mandatory action required) -# ============================================================================= -# shellcheck disable=SC2034 # Unused variables OK for exported config - -# Constants for estimation -# These are conservative estimates based on typical usage patterns -readonly TOKENS_PER_TURN=2500 # Average tokens per conversation turn -readonly BASE_SYSTEM_OVERHEAD=45000 # System prompt, tools, etc. -readonly DEFAULT_CONTEXT_SIZE=200000 # Claude's typical context window - -# Estimate context usage percentage based on turn count -# Args: $1 = turn_count (required) -# Returns: Estimated percentage (0-100) -estimate_context_pct() { - local turn_count="${1:-0}" - local context_size="${2:-$DEFAULT_CONTEXT_SIZE}" - - # Estimate: system overhead + (turns * tokens_per_turn) - local estimated_tokens=$((BASE_SYSTEM_OVERHEAD + (turn_count * TOKENS_PER_TURN))) - - # Calculate percentage (integer math) - local pct=$((estimated_tokens * 100 / context_size)) - - # Cap at 100% - if [[ "$pct" -gt 100 ]]; then - pct=100 - fi - - echo "$pct" -} - -# Get warning tier based on percentage -# Args: $1 = percentage (required) -# Returns: "none", "info", "warning", or "critical" -get_warning_tier() { - local pct="${1:-0}" - - if [[ "$pct" -ge 85 ]]; then - echo "critical" - elif [[ "$pct" -ge 70 ]]; then - echo "warning" - elif [[ "$pct" -ge 50 ]]; then - echo "info" - else - echo "none" - fi -} - -# Get warning message for a tier -# Args: $1 = tier (required), $2 = percentage (required) -# Returns: Warning message string or empty -get_warning_message() { - local tier="$1" - local pct="$2" - - case "$tier" in - critical) - echo "CONTEXT CRITICAL: ${pct}% - MUST create handoff/ledger and /clear soon to avoid losing work!" - ;; - warning) - echo "Context Warning: ${pct}% - Recommend creating a continuity ledger or handoff document soon." - ;; - info) - echo "Context at ${pct}%. Consider summarizing progress when you reach a stopping point." - ;; - *) - echo "" - ;; - esac -} - -# Wrap message in MANDATORY-USER-MESSAGE tags for critical actions -# Args: $1 = message content (required) -# Returns: Message wrapped in mandatory tags -wrap_mandatory_message() { - local message="$1" - cat < -$message -
-EOF -} - -# Format a context warning block for injection -# Args: $1 = tier (required), $2 = percentage (required) -# Returns: Formatted warning block or empty -# BEHAVIOR: -# - critical (85%+): MANDATORY-USER-MESSAGE requiring STOP + ledger + handoff + /clear -# - warning (70-84%): MANDATORY-USER-MESSAGE requiring ledger creation -# - info (50-69%): Simple context-warning XML tag (no mandatory) -format_context_warning() { - local tier="$1" - local pct="$2" - local message - message=$(get_warning_message "$tier" "$pct") - - if [[ -z "$message" ]]; then - echo "" - return - fi - - local icon - case "$tier" in - critical) - icon="[!!!]" - ;; - warning) - icon="[!!]" - ;; - info) - icon="[i]" - ;; - *) - icon="" - ;; - esac - - # Format based on severity - MANDATORY tags at warning+ tiers - if [[ "$tier" == "critical" ]]; then - # Critical: MANDATORY message requiring immediate action - local critical_content - critical_content=$(cat < -$icon Context at ${pct}%. -
-EOF - fi -} - -# Export for subshells -export -f estimate_context_pct 2>/dev/null || true -export -f get_warning_tier 2>/dev/null || true -export -f get_warning_message 2>/dev/null || true -export -f wrap_mandatory_message 2>/dev/null || true -export -f format_context_warning 2>/dev/null || true diff --git a/shared/lib/get-context-usage.sh b/shared/lib/get-context-usage.sh deleted file mode 100755 index 31c6984b..00000000 --- a/shared/lib/get-context-usage.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env bash -# Get actual context usage from Claude Code session file -# Returns: percentage (0-100) or "unknown" if cannot determine -# -# Usage: source get-context-usage.sh && get_context_usage -# Or: bash get-context-usage.sh (prints percentage) - -set -euo pipefail - -# Find the current session file -find_session_file() { - local project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" - local claude_projects="$HOME/.claude/projects" - - # Convert project path to Claude's format (replace / with -) - local project_key - project_key=$(echo "$project_dir" | sed 's|/|-|g') - - local project_sessions="$claude_projects/$project_key" - - if [[ ! -d "$project_sessions" ]]; then - echo "" - return - fi - - # Find most recently modified .jsonl file - local session_file - session_file=$(ls -t "$project_sessions"/*.jsonl 2>/dev/null | head -1) - - echo "$session_file" -} - -# Get context usage percentage from session file -get_context_usage() { - local session_file - session_file=$(find_session_file) - - if [[ -z "$session_file" ]] || [[ ! -f "$session_file" ]]; then - echo "unknown" - return - fi - - # Extract usage from last message with usage data - local usage_json - usage_json=$(grep '"usage"' "$session_file" 2>/dev/null | tail -1) - - if [[ -z "$usage_json" ]]; then - echo "unknown" - return - fi - - # Calculate total tokens and percentage - # Context window is 200k for Claude - local context_size=200000 - - if command -v jq &>/dev/null; then - # Use jq for accurate parsing - local pct - pct=$(echo "$usage_json" | jq -r ' - .message.usage // . | - ((.cache_read_input_tokens // 0) + (.cache_creation_input_tokens // 0) + (.input_tokens // 0)) as $total | - ($total * 100 / 200000) | floor - ' 2>/dev/null) - - if [[ "$pct" =~ ^[0-9]+$ ]]; then - echo "$pct" - return - fi - fi - - # Fallback: grep-based extraction - local cache_read cache_create input_tok - cache_read=$(echo "$usage_json" | grep -o '"cache_read_input_tokens":[0-9]*' | grep -o '[0-9]*' | head -1) - cache_create=$(echo "$usage_json" | grep -o '"cache_creation_input_tokens":[0-9]*' | grep -o '[0-9]*' | head -1) - input_tok=$(echo "$usage_json" | grep -o '"input_tokens":[0-9]*' | grep -o '[0-9]*' | head -1) - - cache_read=${cache_read:-0} - cache_create=${cache_create:-0} - input_tok=${input_tok:-0} - - local total=$((cache_read + cache_create + input_tok)) - local pct=$((total * 100 / context_size)) - - echo "$pct" -} - -# Get warning tier based on actual usage -get_context_tier() { - local pct - pct=$(get_context_usage) - - if [[ "$pct" == "unknown" ]]; then - echo "unknown" - return - fi - - if [[ "$pct" -ge 85 ]]; then - echo "critical" - elif [[ "$pct" -ge 70 ]]; then - echo "warning" - elif [[ "$pct" -ge 50 ]]; then - echo "info" - else - echo "safe" - fi -} - -# If run directly (not sourced), output usage -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - pct=$(get_context_usage) - tier=$(get_context_tier) - echo "Context Usage: ${pct}%" - echo "Tier: ${tier}" -fi diff --git a/shared/lib/ledger-utils.sh b/shared/lib/ledger-utils.sh deleted file mode 100644 index e6e3ff00..00000000 --- a/shared/lib/ledger-utils.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2034 # Unused variables OK for exported config -# Shared ledger utilities for Ring hooks -# Usage: source this file, then call find_active_ledger "$ledger_dir" - -# Find active ledger (most recently modified) - SAFE implementation -# Finds the newest CONTINUITY-*.md file in the specified directory -# -# Arguments: -# $1 - ledger_dir: Path to the directory containing CONTINUITY-*.md files -# -# Returns: -# Prints the path to the most recently modified ledger file, or empty string if none found -# -# Security: -# - Rejects symlinks explicitly (prevents symlink attacks) -# - Uses null-terminated paths (prevents injection via malicious filenames) -# - Portable across macOS/Linux (handles stat differences) -find_active_ledger() { - local ledger_dir="$1" - - # Return empty if directory doesn't exist - [[ ! -d "$ledger_dir" ]] && echo "" && return 0 - - local newest="" - local newest_time=0 - - # Safe iteration with null-terminated paths - while IFS= read -r -d '' file; do - # Security: Reject symlinks explicitly - if [[ -L "$file" ]]; then - echo "Warning: Skipping symlink: $file" >&2 - continue - fi - - # Get modification time (portable across macOS/Linux) - local mtime - if [[ "$(uname)" == "Darwin" ]]; then - mtime=$(stat -f %m "$file" 2>/dev/null || echo 0) - else - mtime=$(stat -c %Y "$file" 2>/dev/null || echo 0) - fi - - if (( mtime > newest_time )); then - newest_time=$mtime - newest="$file" - fi - done < <(find "$ledger_dir" -maxdepth 1 -name "CONTINUITY-*.md" -type f -print0 2>/dev/null) - - echo "$newest" -} - -# Export for subshells -export -f find_active_ledger 2>/dev/null || true