i dont like commits

This commit is contained in:
Tomas Dvorak
2026-02-24 12:10:13 +01:00
parent 898a3c303f
commit 1d72a1cc01
109 changed files with 43586 additions and 8484 deletions
+51 -15
View File
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"path"
@@ -79,6 +80,19 @@ type rankedDoc struct {
searchTerm string
}
type askPersistenceWarning struct {
operation string
cause error
}
func (w *askPersistenceWarning) Error() string {
return fmt.Sprintf("persistence warning: %s: %v", w.operation, w.cause)
}
func (w *askPersistenceWarning) Unwrap() error {
return w.cause
}
func init() {
askCmd.Flags().StringVar(&askLanguage, "lang", "", "language/framework (required)")
askCmd.Flags().StringVarP(&askFormat, "format", "f", "json", "output format (json, text)")
@@ -113,7 +127,7 @@ func runAsk(cmd *cobra.Command, args []string) error {
cfg, err := loadAppConfig()
if err != nil {
return err
return fmt.Errorf("load app config for ask command: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(askTimeoutSec)*time.Second)
@@ -129,7 +143,10 @@ func runAsk(cmd *cobra.Command, args []string) error {
fallbackNeeded := shouldFallbackToLive(localRanked, terms)
fallbackCount := 0
fetchErrors := []string{}
fetchErrors := []error{}
if localErr != nil {
fetchErrors = append(fetchErrors, fmt.Errorf("local retrieval failed: %w", localErr))
}
if fallbackNeeded {
fallbackDocs, fetched, errs := fetchAskDocsFromLive(ctx, cfg, language, question, terms)
fallbackCount = fetched
@@ -143,7 +160,10 @@ func runAsk(cmd *cobra.Command, args []string) error {
}
if len(ranked) == 0 {
return fmt.Errorf("no docs found for %q. errors: %s", language, strings.Join(fetchErrors, "; "))
if len(fetchErrors) == 0 {
return fmt.Errorf("no docs found for %q", language)
}
return fmt.Errorf("no docs found for %q: %w", language, errors.Join(fetchErrors...))
}
sort.Slice(ranked, func(i, j int) bool {
@@ -180,6 +200,12 @@ func runAsk(cmd *cobra.Command, args []string) error {
Confidence: computeConfidence(question, top),
FetchedAt: time.Now(),
}
for _, fetchErr := range fetchErrors {
var persistenceWarning *askPersistenceWarning
if errors.As(fetchErr, &persistenceWarning) {
response.Answer.Notes = append(response.Answer.Notes, persistenceWarning.Error())
}
}
switch strings.ToLower(askFormat) {
case "text":
@@ -290,20 +316,20 @@ func resultMatchesLanguage(result search.Result, language string) bool {
}
}
func fetchAskDocsFromLive(ctx context.Context, cfg *appconfig.Config, language, question string, terms []string) ([]rankedDoc, int, []string) {
func fetchAskDocsFromLive(ctx context.Context, cfg *appconfig.Config, language, question string, terms []string) ([]rankedDoc, int, []error) {
sourceType := scraper.SourceType(mapLanguageToType(language))
if sourceType == "" {
return nil, 0, []string{fmt.Sprintf("unsupported language: %s", language)}
return nil, 0, []error{fmt.Errorf("unsupported language: %s", language)}
}
sc := toScraperConfig(cfg, 2)
sc.MaxDepth = 1
s := scraper.NewScraper(sourceType, sc)
if s == nil {
return nil, 0, []string{fmt.Sprintf("no scraper for %s (%s)", language, sourceType)}
return nil, 0, []error{fmt.Errorf("no scraper for %s (%s)", language, sourceType)}
}
var ranked []rankedDoc
var fetchErrors []string
var fetchErrors []error
seenURL := make(map[string]bool)
totalFetched := 0
fetchedDocs := make([]*scraper.Document, 0)
@@ -311,11 +337,11 @@ func fetchAskDocsFromLive(ctx context.Context, cfg *appconfig.Config, language,
for _, term := range terms {
docURLs, err := candidateDocURLs(language, term)
if err != nil {
fetchErrors = append(fetchErrors, fmt.Sprintf("%s: %v", term, err))
fetchErrors = append(fetchErrors, fmt.Errorf("%s: %w", term, err))
continue
}
termFetched := false
termErrors := make([]string, 0, len(docURLs))
termErrors := make([]error, 0, len(docURLs))
for _, docURL := range docURLs {
if seenURL[docURL] {
continue
@@ -331,11 +357,11 @@ func fetchAskDocsFromLive(ctx context.Context, cfg *appconfig.Config, language,
docs, err := s.Scrape(ctx, source)
if err != nil {
termErrors = append(termErrors, fmt.Sprintf("%s: %v", docURL, err))
termErrors = append(termErrors, fmt.Errorf("%s: %w", docURL, err))
continue
}
if len(docs) == 0 {
termErrors = append(termErrors, fmt.Sprintf("%s: no documents extracted", docURL))
termErrors = append(termErrors, fmt.Errorf("%s: no documents extracted", docURL))
continue
}
@@ -353,20 +379,30 @@ func fetchAskDocsFromLive(ctx context.Context, cfg *appconfig.Config, language,
break
}
if !termFetched && len(termErrors) > 0 {
fetchErrors = append(fetchErrors, strings.Join(termErrors, " | "))
fetchErrors = append(fetchErrors, errors.Join(termErrors...))
}
}
// Persist fallback docs for future local-first queries.
if len(fetchedDocs) > 0 {
_, _ = storage.SaveDocuments(fetchedDocs, storage.SaveOptions{
if _, err := storage.SaveDocuments(fetchedDocs, storage.SaveOptions{
Format: "json",
OutputDir: cfg.Storage.DocsDir,
AllowEmpty: true,
})
}); err != nil {
fetchErrors = append(fetchErrors, &askPersistenceWarning{
operation: "save fallback docs",
cause: err,
})
}
if cfg.Indexing.Enabled {
engine := search.NewEngine(cfg)
_, _ = engine.Rebuild(context.Background())
if _, err := engine.Rebuild(context.Background()); err != nil {
fetchErrors = append(fetchErrors, &askPersistenceWarning{
operation: "rebuild index after fallback",
cause: err,
})
}
}
}
+101
View File
@@ -0,0 +1,101 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"github.com/spf13/cobra"
)
const desloppifyAttribution = "Powered by peteromallet/desloppify"
const missingDesloppifyGuidance = `desloppify was not found in PATH.
Install or update desloppify, then scan this codebase for code quality issues
(dead code, duplication, complexity, smells). It tracks findings persistently
across sessions and reports a health score.
Requires Python 3.11+. Install from PyPI:
pip install --upgrade desloppify
pip install --upgrade "desloppify[full]"
desloppify update-skill codex
Use Devour wrappers (delegates to desloppify):
devour quality scan --path .
devour quality status
devour quality next
Direct equivalent commands:
desloppify scan --path .
desloppify status
desloppify next
--path is the directory to scan ("." for project root).
Language is auto-detected. To override:
desloppify --lang python scan --path .
Note: --lang goes BEFORE the subcommand.
Fix findings, then resolve and rescan:
desloppify resolve fixed <id> --note "what changed" --attest "I have actually [DESCRIBE THE CONCRETE CHANGE YOU MADE] and I am not gaming the score by resolving without fixing."
For false positives or intentional debt:
desloppify resolve wontfix <id> --note "reason" --attest "I have actually verified this is intentional/false-positive and I am not gaming the score by resolving without fixing."
If subjective scores feel stale or inflated:
desloppify scan --path . --reset-subjective
Run regularly (for example before each push). If desloppify itself looks wrong
or confusing, capture a repro and consider filing upstream:
git clone https://github.com/peteromallet/desloppify.git /tmp/desloppify`
func runDesloppifyFromCommand(cmd *cobra.Command, args []string, withAttribution bool) error {
return runDesloppifyWithIO(
cmd.Context(),
cmd.InOrStdin(),
cmd.OutOrStdout(),
cmd.ErrOrStderr(),
args,
withAttribution,
)
}
func runDesloppifyWithIO(
ctx context.Context,
stdin io.Reader,
stdout io.Writer,
stderr io.Writer,
args []string,
withAttribution bool,
) error {
if withAttribution {
fmt.Fprintf(stderr, "devour quality/review is %s\n", desloppifyAttribution)
}
binaryPath, err := exec.LookPath("desloppify")
if err != nil {
printDesloppifyInstallGuidance(stderr)
return fmt.Errorf("desloppify is required: %w", err)
}
run := exec.CommandContext(ctx, binaryPath, args...)
run.Stdin = stdin
run.Stdout = stdout
run.Stderr = stderr
if err := run.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return err
}
return fmt.Errorf("failed to run desloppify: %w", err)
}
return nil
}
func printDesloppifyInstallGuidance(stderr io.Writer) {
fmt.Fprintln(stderr, missingDesloppifyGuidance)
}
+136
View File
@@ -0,0 +1,136 @@
package cmd
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestQualityPassthroughScanArgs(t *testing.T) {
logFile := setupFakeDesloppify(t)
stdout, stderr, err := runRootCommand(t, "quality", "scan", "--path", ".")
if err != nil {
t.Fatalf("quality scan returned error: %v\nstderr:\n%s", err, stderr)
}
logged := readFileTrimmed(t, logFile)
if logged != "scan --path ." {
t.Fatalf("forwarded args = %q, want %q", logged, "scan --path .")
}
if !strings.Contains(stderr, desloppifyAttribution) {
t.Fatalf("stderr missing attribution, got:\n%s", stderr)
}
if !strings.Contains(stdout, "fake stdout") {
t.Fatalf("stdout missing fake tool output, got:\n%s", stdout)
}
}
func TestQualityNoArgsForwardsHelp(t *testing.T) {
logFile := setupFakeDesloppify(t)
_, stderr, err := runRootCommand(t, "quality")
if err != nil {
t.Fatalf("quality returned error: %v\nstderr:\n%s", err, stderr)
}
logged := readFileTrimmed(t, logFile)
if logged != "--help" {
t.Fatalf("forwarded args = %q, want %q", logged, "--help")
}
}
func TestReviewNoArgsRunsDefaultBatchFlow(t *testing.T) {
logFile := setupFakeDesloppify(t)
_, stderr, err := runRootCommand(t, "review")
if err != nil {
t.Fatalf("review returned error: %v\nstderr:\n%s", err, stderr)
}
logged := readFileTrimmed(t, logFile)
want := "review --run-batches --runner codex --parallel --scan-after-import"
if logged != want {
t.Fatalf("forwarded args = %q, want %q", logged, want)
}
}
func TestReviewForwardsArgs(t *testing.T) {
logFile := setupFakeDesloppify(t)
_, stderr, err := runRootCommand(t, "review", "--prepare")
if err != nil {
t.Fatalf("review --prepare returned error: %v\nstderr:\n%s", err, stderr)
}
logged := readFileTrimmed(t, logFile)
if logged != "review --prepare" {
t.Fatalf("forwarded args = %q, want %q", logged, "review --prepare")
}
}
func TestMissingDesloppifyShowsInstallGuidance(t *testing.T) {
t.Setenv("PATH", t.TempDir())
_, stderr, err := runRootCommand(t, "quality", "scan", "--path", ".")
if err == nil {
t.Fatal("expected error when desloppify is missing")
}
if !strings.Contains(stderr, "desloppify was not found in PATH") {
t.Fatalf("stderr missing missing-binary guidance, got:\n%s", stderr)
}
if !strings.Contains(stderr, "pip install --upgrade \"desloppify[full]\"") {
t.Fatalf("stderr missing install command, got:\n%s", stderr)
}
}
func setupFakeDesloppify(t *testing.T) string {
t.Helper()
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "args.log")
scriptPath := filepath.Join(tmpDir, "desloppify")
script := "#!/bin/sh\n" +
"printf '%s\\n' \"$*\" > \"$DEVOUR_TEST_LOG\"\n" +
"echo fake stdout\n" +
"echo fake stderr 1>&2\n" +
"exit 0\n"
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatalf("failed to write fake desloppify script: %v", err)
}
oldPath := os.Getenv("PATH")
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+oldPath)
t.Setenv("DEVOUR_TEST_LOG", logFile)
return logFile
}
func runRootCommand(t *testing.T, args ...string) (string, string, error) {
t.Helper()
var outBuf bytes.Buffer
var errBuf bytes.Buffer
rootCmd.SetOut(&outBuf)
rootCmd.SetErr(&errBuf)
rootCmd.SetArgs(args)
_, err := rootCmd.ExecuteC()
return outBuf.String(), errBuf.String(), err
}
func readFileTrimmed(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read %s: %v", path, err)
}
return strings.TrimSpace(string(data))
}
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env python3
"""
Devour Scorecard CLI - Direct interface to generate scorecards from JSON data.
Usage: devour-scorecard <input.json> <output.png>
"""
import sys
import os
from pathlib import Path
# Add the cmd directory to Python path for imports
sys.path.insert(0, str(Path(__file__).parent))
try:
from devour_scorecard import load_devour_data, generate_scorecard
except ImportError as e:
print(f"Error importing scorecard module: {e}")
sys.exit(1)
def main():
if len(sys.argv) != 3:
print("Usage: devour-scorecard <input.json> <output.png>")
print("")
print("Examples:")
print(" devour-scorecard devour_data/quality/status.json scorecard.png")
print(" devour-scorecard scan_results.json health_badge.png")
sys.exit(1)
input_path = sys.argv[1]
output_path = sys.argv[2]
if not os.path.exists(input_path):
print(f"Error: Input file '{input_path}' not found")
sys.exit(1)
try:
# Load data and generate scorecard
data = load_devour_data(input_path)
result_path = generate_scorecard(data, output_path)
# Calculate file sizes for info
input_size = os.path.getsize(input_path)
output_size = os.path.getsize(result_path)
print(f"✅ Scorecard generated successfully!")
print(f"📁 Output: {result_path}")
print(f"📊 Input: {input_size:,} bytes → Output: {output_size:,} bytes")
print(f"📈 Dimensions: {len(data.dimensions)} categories analyzed")
except Exception as e:
print(f"❌ Error generating scorecard: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-518
View File
@@ -1,518 +0,0 @@
#!/usr/bin/env python3
"""
Devour Scorecard Generator - 1:1 recreation of desloppify scorecard style.
Generates visual health summary PNG with the exact same data structure and visual design.
"""
from __future__ import annotations
import json
import logging
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Tuple, Any, Optional
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("Error: PIL/Pillow required. Install with: pip install Pillow")
sys.exit(1)
logger = logging.getLogger(__name__)
# Visual constants matching desloppify theme
SCALE = 2 # 2x for retina/high-DPI
BG = (248, 248, 246) # Light gray background
FRAME = (222, 222, 220) # Border frame
BORDER = (200, 200, 198) # Inner border
ACCENT = (88, 166, 255) # Blue accent
TEXT = (40, 44, 52) # Dark text
DIM = (140, 140, 140) # Dimmed text
BG_SCORE = (255, 255, 255) # Score background
BG_TABLE = (255, 255, 255) # Table background
BG_ROW_ALT = (250, 250, 248) # Alternating row background
@dataclass
class ScorecardData:
"""Data structure matching desloppify scorecard format."""
project_name: str
version: str
main_score: float
strict_score: float
dimensions: List[Tuple[str, Dict[str, Any]]]
def score_color(score: float, *, muted: bool = False) -> Tuple[int, int, int]:
"""Color-code a score: deep sage >= 90, mustard 70-90, dusty rose < 70.
muted=True returns a desaturated variant for secondary display (strict column).
"""
if score >= 90:
base = (68, 120, 68) # deep sage
elif score >= 70:
base = (120, 140, 72) # olive green
else:
base = (145, 155, 80) # yellow-green
if not muted:
return base
# Pastel orange shades for strict column
if score >= 90:
return (195, 160, 115) # light sandy peach
if score >= 70:
return (200, 148, 100) # warm apricot
return (195, 125, 95) # soft coral
def fmt_score(score: float) -> str:
"""Format score with one decimal place, dropping .0 for integers."""
if score == int(score):
return str(int(score))
return f"{score:.1f}"
def scale(value: int) -> int:
"""Scale value by retina factor."""
return value * SCALE
def load_font(size: int, *, serif: bool = False, bold: bool = False, mono: bool = False) -> ImageFont.ImageFont:
"""Load a font with cross-platform fallback."""
size = size * SCALE
candidates = []
if mono:
candidates = [
"/System/Library/Fonts/SFNSMono.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
"DejaVuSansMono.ttf",
]
elif serif and bold:
candidates = [
"/System/Library/Fonts/Supplemental/Georgia Bold.ttf",
"/System/Library/Fonts/NewYork.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
"DejaVuSerif-Bold.ttf",
]
elif serif:
candidates = [
"/System/Library/Fonts/Supplemental/Georgia.ttf",
"/System/Library/Fonts/NewYork.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
"DejaVuSerif.ttf",
]
elif bold:
candidates = [
"/System/Library/Fonts/SFCompact.ttf",
"/System/Library/Fonts/HelveticaNeue.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"DejaVuSans-Bold.ttf",
]
else:
candidates = [
"/System/Library/Fonts/SFCompact.ttf",
"/System/Library/Fonts/HelveticaNeue.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"DejaVuSans.ttf",
]
for path in candidates:
try:
if os.path.exists(path):
return ImageFont.truetype(path, size)
except OSError:
continue
# Fallback to default font
try:
return ImageFont.load_default()
except:
return ImageFont.truetype("arial.ttf", size)
def draw_left_panel(
draw: ImageDraw.ImageDraw,
main_score: float,
strict_score: float,
project_name: str,
version: str,
*,
lp_left: int,
lp_right: int,
lp_top: int,
lp_bot: int,
) -> None:
"""Draw left panel with title, scores, and project info."""
# Fonts
font_title = load_font(16, bold=True)
font_big = load_font(48, bold=True)
font_version = load_font(11)
font_strict = load_font(12, bold=True)
# Title
title = "CODE HEALTH"
title_bbox = draw.textbbox((0, 0), title, font=font_title)
title_width = title_bbox[2] - title_bbox[0]
title_y = lp_top + scale(8)
center_x = (lp_left + lp_right) // 2
draw.text(
(center_x - title_width / 2, title_y - title_bbox[1]),
title,
fill=TEXT,
font=font_title,
)
# Main score
score_y = title_y + scale(35)
score_text = fmt_score(main_score)
score_bbox = draw.textbbox((0, 0), score_text, font=font_big)
score_width = score_bbox[2] - score_bbox[0]
# Score background
score_bg_y = score_y - scale(5)
score_bg_h = scale(55)
draw.rectangle(
(lp_left + scale(10), score_bg_y, lp_right - scale(10), score_bg_y + score_bg_h),
fill=BG_SCORE,
outline=BORDER,
width=1,
)
draw.text(
(center_x - score_width / 2, score_y - score_bbox[1]),
score_text,
fill=score_color(main_score),
font=font_big,
)
# Strict score
strict_y = score_bg_y + score_bg_h + scale(12)
strict_text = f"Strict: {fmt_score(strict_score)}"
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
strict_width = strict_bbox[2] - strict_bbox[0]
draw.text(
(center_x - strict_width / 2, strict_y - strict_bbox[1]),
strict_text,
fill=score_color(strict_score, muted=True),
font=font_strict,
)
# Project info
info_y = strict_y + scale(25)
# Project name
name_text = project_name.upper()
name_bbox = draw.textbbox((0, 0), name_text, font=font_version)
name_width = name_bbox[2] - name_bbox[0]
draw.text(
(center_x - name_width / 2, info_y - name_bbox[1]),
name_text,
fill=DIM,
font=font_version,
)
# Version
version_y = info_y + scale(18)
version_text = f"v{version}" if version else "dev"
version_bbox = draw.textbbox((0, 0), version_text, font=font_version)
version_width = version_bbox[2] - version_bbox[0]
draw.text(
(center_x - version_width / 2, version_y - version_bbox[1]),
version_text,
fill=DIM,
font=font_version,
)
def draw_vert_rule_with_ornament(
draw: ImageDraw.ImageDraw,
x: int,
y1: int,
y2: int,
mid_y: int,
color: Tuple[int, int, int],
accent: Tuple[int, int, int],
) -> None:
"""Draw vertical divider with ornament at center."""
# Vertical lines
draw.line([(x, y1), (x, mid_y - scale(8))], fill=color, width=1)
draw.line([(x, mid_y + scale(8)), (x, y2)], fill=color, width=1)
# Ornament circle
ornament_size = scale(6)
ellipse1_coords = (
x - ornament_size // 2, mid_y - ornament_size // 2,
x + ornament_size // 2, mid_y + ornament_size // 2
)
draw.ellipse(ellipse1_coords, outline=accent, width=2)
ellipse2_coords = (
x - ornament_size // 2 + 2, mid_y - ornament_size // 2 + 2,
x + ornament_size // 2 - 2, mid_y + ornament_size // 2 - 2
)
draw.ellipse(ellipse2_coords, outline=color, width=1)
def draw_right_panel(
draw: ImageDraw.ImageDraw,
active_dims: List[Tuple[str, Dict[str, Any]]],
row_h: int,
*,
table_x1: int,
table_x2: int,
table_top: int,
table_bot: int,
) -> None:
"""Draw right panel: two separate dimension tables side by side."""
font_row = load_font(11, mono=True)
font_strict = load_font(9, mono=True)
row_count = len(active_dims)
cols = 2
rows_per_col = (row_count + cols - 1) // cols
table_width = table_x2 - table_x1
grid_gap = scale(8)
grid_width = (table_width - grid_gap) // cols
for col_index in range(cols):
grid_x1 = table_x1 + col_index * (grid_width + grid_gap)
grid_x2 = grid_x1 + grid_width
draw.rounded_rectangle(
(grid_x1, table_top, grid_x2, table_bot),
radius=scale(4),
fill=BG_TABLE,
outline=BORDER,
width=1,
)
name_col_width = scale(120)
value_col_gap = scale(4)
value_col_width = scale(34)
total_content_width = (
name_col_width
+ value_col_gap
+ value_col_width
+ value_col_gap
+ value_col_width
)
block_left = grid_x1 + (grid_width - total_content_width) // 2
name_col_x = block_left
health_col_x = name_col_x + name_col_width + value_col_gap
strict_col_x = health_col_x + value_col_width + value_col_gap + scale(4)
rows_this_col = min(rows_per_col, row_count - col_index * rows_per_col)
content_height = rows_this_col * row_h
content_top = (table_top + table_bot) // 2 - content_height // 2
sample_bbox = draw.textbbox((0, 0), "Xg", font=font_row)
row_text_height = sample_bbox[3] - sample_bbox[1]
row_text_offset = sample_bbox[1]
start_idx = col_index * rows_per_col
for row_index in range(rows_this_col):
dim_idx = start_idx + row_index
if dim_idx >= row_count:
break
name, data = active_dims[dim_idx]
band_top = content_top + row_index * row_h
band_bottom = band_top + row_h
if row_index % 2 == 1:
draw.rectangle(
(grid_x1 + 1, band_top, grid_x2 - 1, band_bottom), fill=BG_ROW_ALT
)
text_y = band_top + (row_h - row_text_height) // 2 - row_text_offset + scale(1)
score = data.get("score", 100)
strict = data.get("strict", score)
max_name_width = name_col_width - scale(2)
while (
name
and draw.textlength(name + "\u2026", font=font_row) > max_name_width
):
name = name[:-1]
if draw.textlength(name, font=font_row) > max_name_width:
name = name.rstrip() + "\u2026"
draw.text((name_col_x, text_y), name, fill=TEXT, font=font_row)
draw.text(
(health_col_x, text_y),
f"{fmt_score(score)}%",
fill=score_color(score),
font=font_row,
)
strict_text = f"{fmt_score(strict)}%"
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
strict_text_height = strict_bbox[3] - strict_bbox[1]
strict_y = band_top + (row_h - strict_text_height) // 2 - strict_bbox[1]
draw.text(
(strict_col_x, strict_y),
strict_text,
fill=score_color(strict, muted=True),
font=font_strict,
)
def generate_scorecard(data: ScorecardData, output_path: str | Path) -> Path:
"""Render a landscape scorecard PNG from scorecard data. Returns output path."""
output_path = Path(output_path)
# Layout — landscape (wide), dimensions first
row_count = len(data.dimensions)
row_h = scale(20)
width = scale(780)
divider_x = scale(260)
frame_inset = scale(5)
cols = 2
rows_per_col = (row_count + cols - 1) // cols
table_content_h = scale(14) + scale(4) + scale(6) + rows_per_col * row_h
content_h = max(table_content_h + scale(28), scale(150))
height = scale(12) + content_h
# Create image
img = Image.new("RGB", (width, height), BG)
draw = ImageDraw.Draw(img)
# Double frame
draw.rectangle((0, 0, width - 1, height - 1), outline=FRAME, width=scale(2))
draw.rectangle(
(frame_inset, frame_inset, width - frame_inset - 1, height - frame_inset - 1),
outline=BORDER,
width=1,
)
content_top = frame_inset + scale(1)
content_bot = height - frame_inset - scale(1)
content_mid_y = (content_top + content_bot) // 2
# Left panel: title + score + project name
draw_left_panel(
draw,
data.main_score,
data.strict_score,
data.project_name,
data.version,
lp_left=frame_inset + scale(11),
lp_right=divider_x - scale(11),
lp_top=content_top + scale(4),
lp_bot=content_bot - scale(4),
)
# Vertical divider with ornament
draw_vert_rule_with_ornament(
draw,
divider_x,
content_top + scale(12),
content_bot - scale(12),
content_mid_y,
BORDER,
ACCENT,
)
# Right panel: dimension table
draw_right_panel(
draw,
data.dimensions,
row_h,
table_x1=divider_x + scale(11),
table_x2=width - frame_inset - scale(11),
table_top=content_top + scale(4),
table_bot=content_bot - scale(4),
)
# Save image
output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(str(output_path), "PNG", optimize=True)
return output_path
def load_devour_data(json_path: str) -> ScorecardData:
"""Load Devour scan results and convert to scorecard format."""
with open(json_path, 'r') as f:
data = json.load(f)
# Extract findings
findings = data.get('findings', [])
# Calculate scores
total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings)
strict_score = total_score # Simplified - would use strict scoring logic
# Convert to percentage (inverted)
main_score = max(0, 100 - (total_score / 1000 * 100))
strict_score_pct = max(0, 100 - (strict_score / 1000 * 100))
# Group by type for dimensions
type_counts = {}
type_scores = {}
for finding in findings:
ftype = finding.get('type', 'unknown')
type_counts[ftype] = type_counts.get(ftype, 0) + 1
type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0)
# Create dimensions list
dimensions = []
for ftype, count in type_counts.items():
avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100)
dimensions.append((
ftype.replace('_', ' ').title(),
{
'score': max(0, min(100, avg_score)),
'strict': max(0, min(100, avg_score * 0.8)), # Strict is lower
'count': count
}
))
# Sort by score (lowest first)
dimensions.sort(key=lambda x: x[1]['score'])
return ScorecardData(
project_name="Devour",
version="1.0.0",
main_score=main_score,
strict_score=strict_score_pct,
dimensions=dimensions[:8], # Limit to 8 dimensions
)
def main():
"""Main entry point."""
if len(sys.argv) != 3:
print("Usage: python devour_scorecard.py <devour_results.json> <output.png>")
sys.exit(1)
json_path = sys.argv[1]
output_path = sys.argv[2]
if not os.path.exists(json_path):
print(f"Error: Input file {json_path} not found")
sys.exit(1)
try:
# Load and convert data
data = load_devour_data(json_path)
# Generate scorecard
result_path = generate_scorecard(data, output_path)
print(f"Scorecard generated: {result_path}")
except Exception as e:
print(f"Error generating scorecard: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+5 -2
View File
@@ -44,7 +44,7 @@ func runGet(cmd *cobra.Command, args []string) error {
url, err := constructDocURL(language, keyword)
if err != nil {
return err
return fmt.Errorf("construct documentation url for %s/%s: %w", language, keyword, err)
}
sourceType := mapLanguageToType(language)
@@ -54,7 +54,10 @@ func runGet(cmd *cobra.Command, args []string) error {
fmt.Printf("URL: %s\n", url)
fmt.Printf("Type: %s\n\n", sourceType)
return runScrape(cmd, []string{url})
if err := runScrape(cmd, []string{url}); err != nil {
return fmt.Errorf("run scrape for get command: %w", err)
}
return nil
}
func constructDocURL(language, keyword string) (string, error) {
+1 -1
View File
@@ -49,7 +49,7 @@ func runPush(cmd *cobra.Command, args []string) error {
cfg, err := loadAppConfig()
if err != nil {
return err
return fmt.Errorf("load app config for push command: %w", err)
}
server := strings.TrimSpace(pushServer)
+11 -725
View File
@@ -1,734 +1,20 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
import "github.com/spf13/cobra"
"github.com/spf13/cobra"
"github.com/yourorg/devour/internal/quality"
"github.com/yourorg/devour/internal/quality/detectors"
"github.com/yourorg/devour/internal/quality/plugins"
"github.com/yourorg/devour/internal/quality/plugins/go/fixers"
"github.com/yourorg/devour/internal/quality/review"
_ "github.com/yourorg/devour/internal/quality/plugins/go"
)
// qualityCmd represents the quality command
var qualityCmd = &cobra.Command{
Use: "quality",
Short: "Code quality analysis and technical debt tracking",
Long: `Analyze code quality issues like complexity, duplication, naming inconsistencies,
and more. Supports multiple languages including Go, Python, TypeScript, Java, and Rust.
Examples:
devour quality scan ./src # Scan current directory
devour quality scan --lang go ./src # Scan with explicit language
devour quality status # Show current status
devour quality next # Show next priority issue
devour quality resolve fixed 123 --note "Refactored" # Mark issue as fixed`,
}
// scanCmd represents the scan subcommand
var scanCmd = &cobra.Command{
Use: "scan [path]",
Short: "Run code quality analysis",
Long: `Scan the given path for code quality issues. Automatically detects language
unless specified with --lang flag.
The scan will detect:
- Complexity issues (nested loops, excessive function calls)
- Code duplication and near-duplicates
- Naming inconsistencies across directories
- Large files and god classes
- Orphaned code and unused exports`,
RunE: runQualityScan,
}
// qualityStatusCmd represents the quality status subcommand
var qualityStatusCmd = &cobra.Command{
Use: "status",
Short: "Show code quality status and scorecard",
Long: `Display the current code quality status including:
- Overall health score and grade
- Findings broken down by type and severity
- Progress metrics and next steps`,
RunE: runQualityStatus,
}
// nextCmd represents the next subcommand
var nextCmd = &cobra.Command{
Use: "next",
Short: "Show the next highest priority issue to fix",
Long: `Display the next highest priority finding based on severity and score.
This helps you focus on the most impactful improvements first.`,
RunE: runQualityNext,
}
// resolveCmd represents the resolve subcommand
var resolveCmd = &cobra.Command{
Use: "resolve <status> <id>",
Short: "Mark a finding as resolved",
Long: `Mark a finding with a specific status:
- fixed: Issue has been resolved
- wontfix: Issue won't be fixed (valid reason required)
- false_positive: Finding is incorrect
- ignore: Temporarily ignore the finding
Examples:
devour quality resolve fixed abc123 --note "Refactored complex function"
devour quality resolve wontfix def456 --note "Legacy code, planned replacement"`,
RunE: runQualityResolve,
}
// Quality flags
var (
qualityPath string
qualityLanguage string
qualityExclude []string
qualityThreshold int
qualityMinLOC int
qualityTargetScore int
qualityFormat string
qualityResetSubjective bool
qualityNoBadge bool
qualityBadgePath string
)
var explain bool
var tier int
var resolveNote string
var attest string
var statusNarrative bool
var fixDryRun bool
var fixAll bool
var reviewPrepare bool
var reviewImport string
var fixCmd = &cobra.Command{
Use: "fix [id]",
Short: "Auto-fix code quality issues",
Long: `Automatically fix T1 (auto-fixable) issues.
Examples:
devour quality fix unused_import::file.go::fmt # Fix specific issue
devour quality fix --all --dry-run # Preview all fixes
devour quality fix --all # Fix all auto-fixable issues`,
RunE: runQualityFix,
}
var reviewCmd = &cobra.Command{
Use: "review",
Short: "Generate or import AI review packets",
Long: `Generate a review packet for AI analysis, or import responses.
Examples:
devour quality review --prepare # Generate review packet
devour quality review --import responses.json # Import AI responses`,
RunE: runQualityReview,
Use: "quality [desloppify-args...]",
Short: "Code quality workflows powered by desloppify",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
forward := args
if len(forward) == 0 {
forward = []string{"--help"}
}
return runDesloppifyFromCommand(cmd, forward, true)
},
}
func init() {
rootCmd.AddCommand(qualityCmd)
qualityCmd.AddCommand(scanCmd, qualityStatusCmd, nextCmd, resolveCmd, fixCmd, reviewCmd)
// Scan flags
scanCmd.Flags().StringVar(&qualityPath, "path", ".", "Path to scan")
scanCmd.Flags().StringVar(&qualityLanguage, "lang", "", "Language (auto-detected if not specified)")
scanCmd.Flags().StringSliceVar(&qualityExclude, "exclude", []string{}, "Exclude patterns")
scanCmd.Flags().IntVar(&qualityThreshold, "threshold", 15, "Minimum score to flag an issue")
scanCmd.Flags().IntVar(&qualityMinLOC, "min-loc", 50, "Minimum lines of code to analyze")
scanCmd.Flags().IntVar(&qualityTargetScore, "target-score", 95, "Target health score")
scanCmd.Flags().StringVar(&qualityFormat, "format", "text", "Output format (text, json, strict)")
scanCmd.Flags().BoolVar(&qualityResetSubjective, "reset-subjective", false, "Reset subjective baseline")
scanCmd.Flags().BoolVar(&qualityNoBadge, "no-badge", false, "Skip badge generation")
scanCmd.Flags().StringVar(&qualityBadgePath, "badge-path", "scorecard.png", "Badge output path")
// Status flags
qualityStatusCmd.Flags().StringVar(&qualityFormat, "format", "text", "Output format (text, json, strict)")
qualityStatusCmd.Flags().BoolVar(&statusNarrative, "narrative", false, "Include narrative analysis")
// Next flags
nextCmd.Flags().BoolVar(&explain, "explain", false, "Show detailed explanation")
nextCmd.Flags().IntVar(&tier, "tier", 0, "Filter by severity tier (1-4)")
// Resolve flags
resolveCmd.Flags().StringVar(&resolveNote, "note", "", "Note explaining the resolution (required)")
resolveCmd.Flags().StringVar(&attest, "attest", "", "Attestation of improvement")
// Fix flags
fixCmd.Flags().BoolVar(&fixDryRun, "dry-run", false, "Show what would be fixed without making changes")
fixCmd.Flags().BoolVar(&fixAll, "all", false, "Fix all auto-fixable issues")
// Review flags
reviewCmd.Flags().BoolVar(&reviewPrepare, "prepare", false, "Generate review packet for AI analysis")
reviewCmd.Flags().StringVar(&reviewImport, "import", "", "Import review responses from file")
}
func runQualityScan(cmd *cobra.Command, args []string) error {
path := qualityPath
if len(args) > 0 {
path = args[0]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Errorf("path does not exist: %s", path)
}
config := &quality.Config{
Path: path,
Language: qualityLanguage,
Exclude: qualityExclude,
Threshold: qualityThreshold,
MinLOC: qualityMinLOC,
TargetScore: qualityTargetScore,
ResetSubjective: qualityResetSubjective,
NoBadge: qualityNoBadge,
BadgePath: qualityBadgePath,
}
scanner := quality.NewScanner(config)
finder := quality.NewDefaultFileFinder()
scanner.SetFileFinder(finder)
lang := qualityLanguage
if lang == "" {
lang = quality.DetectLanguage(path)
fmt.Printf("Auto-detected language: %s\n", lang)
}
plugin, ok := plugins.Get(lang)
if ok {
fmt.Printf("Using %s plugin with AST analysis\n", lang)
for _, detector := range plugin.CreateDetectors(finder) {
scanner.RegisterDetector(detector)
}
} else {
scanner.RegisterDetector(detectors.NewComplexityDetector(finder))
scanner.RegisterDetector(detectors.NewDuplicationDetector(finder))
scanner.RegisterDetector(detectors.NewNamingDetector(finder))
}
ctx := context.Background()
result, err := scanner.Scan(ctx)
if err != nil {
return fmt.Errorf("scan failed: %w", err)
}
result.Findings = quality.AttachDocsEvidence(lang, result.Findings)
return outputScanResult(result, qualityFormat)
}
func runQualityStatus(cmd *cobra.Command, args []string) error {
// Load previous scan results
dataDir := filepath.Join(".", "devour_data", "quality")
statusFile := filepath.Join(dataDir, "status.json")
var findings []quality.Finding
var lastScan time.Time
if data, err := os.ReadFile(statusFile); err == nil {
var status struct {
Findings []quality.Finding `json:"findings"`
Timestamp time.Time `json:"timestamp"`
}
if err := json.Unmarshal(data, &status); err == nil {
findings = status.Findings
lastScan = status.Timestamp
}
}
if len(findings) == 0 {
fmt.Println("No previous scan results found. Run 'devour quality scan' first.")
return nil
}
// Generate scorecard
scorer := quality.NewScorer(qualityTargetScore)
scorecard := scorer.GenerateScorecard(findings, lastScan)
// Output based on format
switch qualityFormat {
case "json":
return json.NewEncoder(os.Stdout).Encode(scorecard)
case "strict":
fmt.Println(scorer.FormatStrictScorecard(findings, lastScan))
printQualityEvidenceSummary(findings)
return nil
default:
fmt.Println(scorer.FormatScorecard(scorecard))
printQualityEvidenceSummary(findings)
return nil
}
}
func runQualityNext(cmd *cobra.Command, args []string) error {
// Load previous scan results
dataDir := filepath.Join(".", "devour_data", "quality")
statusFile := filepath.Join(dataDir, "status.json")
var findings []quality.Finding
if data, err := os.ReadFile(statusFile); err == nil {
var status struct {
Findings []quality.Finding `json:"findings"`
}
if err := json.Unmarshal(data, &status); err == nil {
findings = status.Findings
}
}
if len(findings) == 0 {
fmt.Println("No findings found. Run 'devour quality scan' first.")
return nil
}
// Get next priority finding
scorer := quality.NewScorer(qualityTargetScore)
next := scorer.GetNextPriority(findings)
if next == nil {
fmt.Println("🎉 No open issues to fix!")
return nil
}
// Filter by tier if specified
if tier > 0 {
if int(next.Severity) != tier {
// Find next in specified tier
for _, finding := range findings {
if finding.Status == quality.StatusOpen && int(finding.Severity) == tier {
next = &finding
break
}
}
if next == nil || int(next.Severity) != tier {
fmt.Printf("No open issues found in tier %d.\n", tier)
return nil
}
}
}
// Display finding
fmt.Printf("Next Priority Issue (T%d)\n", int(next.Severity))
fmt.Println("=======================================")
fmt.Printf("File: %s:%d\n", next.File, next.Line)
fmt.Printf("Title: %s\n", next.Title)
fmt.Printf("Score: %d\n", next.Score)
fmt.Printf("ID: %s\n", next.ID)
fmt.Printf("\nDescription:\n%s\n", next.Description)
if next.Metadata != nil {
if urls := strings.TrimSpace(next.Metadata["docs_evidence_urls"]); urls != "" {
fmt.Printf("\nEvidence Docs:\n%s\n", urls)
}
if rationale := strings.TrimSpace(next.Metadata["docs_evidence_rationale"]); rationale != "" {
fmt.Printf("\nRationale:\n%s\n", rationale)
}
if confidence := strings.TrimSpace(next.Metadata["docs_evidence_confidence"]); confidence != "" {
fmt.Printf("Evidence confidence: %s\n", confidence)
}
}
if explain {
fmt.Printf("\nExplanation:\n")
fmt.Printf("This is a T%d severity issue. ", int(next.Severity))
switch next.Severity {
case quality.SeverityT1:
fmt.Println("T1 issues are typically auto-fixable like unused imports or debug logs.")
case quality.SeverityT2:
fmt.Println("T2 issues require quick manual fixes like unused variables or dead exports.")
case quality.SeverityT3:
fmt.Println("T3 issues need judgment calls like near-duplicates or single-use abstractions.")
case quality.SeverityT4:
fmt.Println("T4 issues require major refactoring like god components or mixed concerns.")
}
fmt.Printf("\nTo fix: devour quality resolve fixed %s --note \"Describe what you fixed\"\n", next.ID)
}
return nil
}
func runQualityResolve(cmd *cobra.Command, args []string) error {
if len(args) < 2 {
return fmt.Errorf("usage: devour quality resolve <status> <id>")
}
status := quality.Status(args[0])
id := args[1]
// Validate status
validStatuses := map[quality.Status]bool{
quality.StatusFixed: true,
quality.StatusWontfix: true,
quality.StatusFalsePositive: true,
quality.StatusIgnored: true,
}
if !validStatuses[status] {
return fmt.Errorf("invalid status: %s", status)
}
if resolveNote == "" {
return fmt.Errorf("--note is required when resolving findings")
}
// Load current findings
dataDir := filepath.Join(".", "devour_data", "quality")
statusFile := filepath.Join(dataDir, "status.json")
var findings []quality.Finding
if data, err := os.ReadFile(statusFile); err == nil {
var statusData struct {
Findings []quality.Finding `json:"findings"`
}
if err := json.Unmarshal(data, &statusData); err == nil {
findings = statusData.Findings
}
}
// Find and update the finding
found := false
for i, finding := range findings {
if finding.ID == id {
findings[i].Status = status
findings[i].UpdatedAt = time.Now()
if finding.Metadata == nil {
findings[i].Metadata = make(map[string]string)
}
findings[i].Metadata["resolution_note"] = resolveNote
if attest != "" {
findings[i].Metadata["attestation"] = attest
}
found = true
break
}
}
if !found {
return fmt.Errorf("finding not found: %s", id)
}
// Save updated findings
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
statusData := struct {
Findings []quality.Finding `json:"findings"`
Timestamp time.Time `json:"timestamp"`
}{
Findings: findings,
Timestamp: time.Now(),
}
data, err := json.MarshalIndent(statusData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal status: %w", err)
}
if err := os.WriteFile(statusFile, data, 0644); err != nil {
return fmt.Errorf("failed to save status: %w", err)
}
fmt.Printf("Resolved: %s as %s\n", id, status)
if resolveNote != "" {
fmt.Printf("Note: %s\n", resolveNote)
}
return nil
}
func outputScanResult(result *quality.ScanResult, format string) error {
// Save results to data directory
dataDir := filepath.Join(".", "devour_data", "quality")
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
statusFile := filepath.Join(dataDir, "status.json")
statusData := struct {
Findings []quality.Finding `json:"findings"`
Timestamp time.Time `json:"timestamp"`
}{
Findings: result.Findings,
Timestamp: result.Timestamp,
}
data, err := json.MarshalIndent(statusData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal results: %w", err)
}
if err := os.WriteFile(statusFile, data, 0644); err != nil {
return fmt.Errorf("failed to save results: %w", err)
}
// Note: Scorecard generation is now handled by the dedicated 'devour scorecard' command
if !qualityNoBadge && qualityBadgePath != "" {
fmt.Printf("💡 Use 'devour scorecard' to generate beautiful scorecard banners\n")
}
// Output based on format
switch format {
case "json":
return json.NewEncoder(os.Stdout).Encode(result)
default:
return formatScanResultText(result)
}
}
func formatScanResultText(result *quality.ScanResult) error {
fmt.Println("Code Quality Scan Results")
fmt.Println("=======================================")
fmt.Printf("Files checked: %d\n", result.FilesChecked)
fmt.Printf("Duration: %s\n", result.Duration)
fmt.Printf("Score: %d (strict: %d)\n", result.Score, result.StrictScore)
fmt.Printf("Findings: %d\n\n", len(result.Findings))
if len(result.Findings) == 0 {
fmt.Println("No code quality issues found.")
return nil
}
bySeverity := make(map[quality.Severity][]quality.Finding)
for _, finding := range result.Findings {
bySeverity[finding.Severity] = append(bySeverity[finding.Severity], finding)
}
tiers := []quality.Severity{quality.SeverityT4, quality.SeverityT3, quality.SeverityT2, quality.SeverityT1}
tierNames := map[quality.Severity]string{
quality.SeverityT1: "T1 (Auto-fixable)",
quality.SeverityT2: "T2 (Quick manual)",
quality.SeverityT3: "T3 (Needs judgment)",
quality.SeverityT4: "T4 (Major refactor)",
}
for _, severity := range tiers {
findings := bySeverity[severity]
if len(findings) == 0 {
continue
}
fmt.Printf("[%s] %d issues\n", tierNames[severity], len(findings))
for _, finding := range findings {
fmt.Printf(" - %s:%d - %s (score: %d)\n",
filepath.Base(finding.File), finding.Line, finding.Title, finding.Score)
}
fmt.Println()
}
fmt.Println("Run 'devour quality next' to see the highest priority issue to fix.")
fmt.Println("Run 'devour quality status' for detailed scorecard.")
return nil
}
func runQualityFix(cmd *cobra.Command, args []string) error {
dataDir := filepath.Join(".", "devour_data", "quality")
statusFile := filepath.Join(dataDir, "status.json")
var findings []quality.Finding
if data, err := os.ReadFile(statusFile); err == nil {
var status struct {
Findings []quality.Finding `json:"findings"`
}
if err := json.Unmarshal(data, &status); err == nil {
findings = status.Findings
}
}
if len(findings) == 0 {
fmt.Println("No findings found. Run 'devour quality scan' first.")
return nil
}
availableFixers := []plugins.Fixer{
fixers.NewUnusedImportFixer(),
fixers.NewFormattingFixer(),
}
ctx := context.Background()
fixed := 0
var errors []string
if fixAll {
for _, finding := range findings {
if finding.Status != quality.StatusOpen || finding.Severity != quality.SeverityT1 {
continue
}
for _, fixer := range availableFixers {
if fixer.CanFix(finding) {
result, err := fixer.Fix(ctx, finding, fixDryRun)
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", finding.ID, err))
continue
}
if result.Success {
fixed++
fmt.Printf("[OK] %s\n", result.Message)
}
break
}
}
}
} else {
if len(args) < 1 {
return fmt.Errorf("specify a finding ID or use --all")
}
targetID := args[0]
var target *quality.Finding
for i := range findings {
if findings[i].ID == targetID {
target = &findings[i]
break
}
}
if target == nil {
return fmt.Errorf("finding not found: %s", targetID)
}
for _, fixer := range availableFixers {
if fixer.CanFix(*target) {
result, err := fixer.Fix(ctx, *target, fixDryRun)
if err != nil {
return fmt.Errorf("fix failed: %w", err)
}
fmt.Printf("[OK] %s\n", result.Message)
fixed = 1
break
}
}
}
if fixDryRun {
fmt.Printf("\nDry run complete. %d issues would be fixed.\n", fixed)
} else {
fmt.Printf("\nFixed %d issues.\n", fixed)
}
if len(errors) > 0 {
fmt.Printf("\nErrors:\n")
for _, e := range errors {
fmt.Printf(" • %s\n", e)
}
}
return nil
}
func runQualityReview(cmd *cobra.Command, args []string) error {
dataDir := filepath.Join(".", "devour_data")
if reviewPrepare {
return prepareReviewPacket(dataDir)
}
if reviewImport != "" {
return importReviewResponses(dataDir, reviewImport)
}
return fmt.Errorf("use --prepare to generate a review packet or --import <file> to import responses")
}
func prepareReviewPacket(dataDir string) error {
statusFile := filepath.Join(dataDir, "quality", "status.json")
var findings []quality.Finding
var lastScan time.Time
if data, err := os.ReadFile(statusFile); err == nil {
var status struct {
Findings []quality.Finding `json:"findings"`
Timestamp time.Time `json:"timestamp"`
}
if err := json.Unmarshal(data, &status); err == nil {
findings = status.Findings
lastScan = status.Timestamp
}
}
scorer := quality.NewScorer(qualityTargetScore)
scorecard := scorer.GenerateScorecard(findings, lastScan)
gen := review.NewPacketGenerator(dataDir)
packet, err := gen.Generate(findings, scorecard, "go")
if err != nil {
return fmt.Errorf("failed to generate review packet: %w", err)
}
filename := fmt.Sprintf("review-%s.json", time.Now().Format("20060102-150405"))
if err := gen.Save(packet, filename); err != nil {
return fmt.Errorf("failed to save review packet: %w", err)
}
fmt.Printf("Review packet generated: %s/review/%s\n", dataDir, filename)
fmt.Printf("Findings to review: %d\n", len(packet.Findings))
fmt.Printf("Questions: %d\n", len(packet.Questions))
return nil
}
func importReviewResponses(dataDir string, filename string) error {
gen := review.NewPacketGenerator(dataDir)
data, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("failed to read responses file: %w", err)
}
var responses map[string]string
var respData struct {
Responses map[string]string `json:"responses"`
}
if err := json.Unmarshal(data, &respData); err == nil {
responses = respData.Responses
} else {
var simpleResponses map[string]string
if err := json.Unmarshal(data, &simpleResponses); err != nil {
return fmt.Errorf("failed to parse responses: %w", err)
}
responses = simpleResponses
}
if err := gen.ImportReview(filepath.Base(filename), responses); err != nil {
return fmt.Errorf("failed to import responses: %w", err)
}
fmt.Printf("Imported %d review responses\n", len(responses))
return nil
}
func printQualityEvidenceSummary(findings []quality.Finding) {
totalWithEvidence := 0
for _, f := range findings {
if f.Metadata != nil && strings.TrimSpace(f.Metadata["docs_evidence_urls"]) != "" {
totalWithEvidence++
}
}
if totalWithEvidence == 0 {
return
}
fmt.Printf("\nEvidence-linked findings: %d/%d\n", totalWithEvidence, len(findings))
for _, f := range findings {
if f.Metadata == nil {
continue
}
urls := strings.TrimSpace(f.Metadata["docs_evidence_urls"])
if urls == "" {
continue
}
fmt.Printf(" • %s:%d - %s\n %s\n", filepath.Base(f.File), f.Line, f.Title, urls)
break
}
}
+27
View File
@@ -0,0 +1,27 @@
package cmd
import "github.com/spf13/cobra"
var reviewCmd = &cobra.Command{
Use: "review [desloppify-review-args...]",
Short: "Run holistic review via desloppify",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
forward := []string{"review"}
if len(args) == 0 {
forward = append(forward,
"--run-batches",
"--runner", "codex",
"--parallel",
"--scan-after-import",
)
} else {
forward = append(forward, args...)
}
return runDesloppifyFromCommand(cmd, forward, true)
},
}
func init() {
rootCmd.AddCommand(reviewCmd)
}
+23 -72
View File
@@ -1,90 +1,41 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
)
var (
scorecardCompact bool
scorecardDetailed bool
scorecardOutput string
scorecardPath string
scorecardBadgePath string
scorecardResetSubjective bool
scorecardSkipSlow bool
)
var scorecardCmd = &cobra.Command{
Use: "scorecard",
Short: "Generate Devour quality scorecards",
Long: `Generate beautiful dark-themed scorecards showing code quality metrics.
Creates both compact and detailed PNG banners with:
- Modern dark theme design
- Glass morphism effects
- Devour brand colors
- Professional typography
Short: "Generate a scorecard badge via desloppify",
Long: `Generate the quality scorecard badge by delegating to desloppify.
This runs a scan and writes badge output to --badge-path.
Examples:
devour scorecard # Generate both compact and detailed
devour scorecard --compact # Generate only compact banner
devour scorecard --detailed # Generate only detailed banner
devour scorecard --output custom # Custom output filename`,
Run: func(cmd *cobra.Command, args []string) {
generateScorecards()
devour scorecard
devour scorecard --path . --badge-path scorecard.png
devour scorecard --reset-subjective`,
RunE: func(cmd *cobra.Command, args []string) error {
forward := []string{"scan", "--path", scorecardPath, "--badge-path", scorecardBadgePath}
if scorecardResetSubjective {
forward = append(forward, "--reset-subjective")
}
if scorecardSkipSlow {
forward = append(forward, "--skip-slow")
}
return runDesloppifyFromCommand(cmd, forward, true)
},
}
func init() {
scorecardCmd.Flags().BoolVar(&scorecardCompact, "compact", false, "Generate compact banner only")
scorecardCmd.Flags().BoolVar(&scorecardDetailed, "detailed", false, "Generate detailed banner only")
scorecardCmd.Flags().StringVarP(&scorecardOutput, "output", "o", "lighthouse_scorecard", "Output filename prefix")
}
func generateScorecards() {
// Get the current working directory (project root)
workingDir, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting working directory: %v\n", err)
os.Exit(1)
}
// Path to the Python script relative to working directory
pythonScriptPath := filepath.Join(workingDir, "cmd", "banner_generator", "main.py")
// Check if Python script exists
if _, err := os.Stat(pythonScriptPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: Python scorecard generator not found at %s\n", pythonScriptPath)
os.Exit(1)
}
// Build Python command arguments
args := []string{pythonScriptPath}
if scorecardCompact {
args = append(args, "--compact")
} else if scorecardDetailed {
args = append(args, "--detailed")
}
if scorecardOutput != "lighthouse_scorecard" {
args = append(args, "--output", scorecardOutput)
}
// Execute Python script
fmt.Println("🎨 Generating Devour Scorecards...")
fmt.Printf("📂 Using generator: %s\n", pythonScriptPath)
cmd := exec.Command("python3", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = workingDir
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error generating scorecards: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ Scorecard generation complete!")
scorecardCmd.Flags().StringVar(&scorecardPath, "path", ".", "Path to scan")
scorecardCmd.Flags().StringVar(&scorecardBadgePath, "badge-path", "scorecard.png", "Badge output path")
scorecardCmd.Flags().BoolVar(&scorecardResetSubjective, "reset-subjective", false, "Reset subjective scores before scan")
scorecardCmd.Flags().BoolVar(&scorecardSkipSlow, "skip-slow", false, "Skip slow detectors")
}
-519
View File
@@ -1,519 +0,0 @@
#!/usr/bin/env python3
"""
Devour Scorecard Generator - 1:1 recreation of desloppify scorecard style.
Generates visual health summary PNG with the exact same data structure and visual design.
"""
from __future__ import annotations
import json
import logging
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Tuple, Any, Optional
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("Error: PIL/Pillow required. Install with: pip install Pillow")
sys.exit(1)
logger = logging.getLogger(__name__)
# Visual constants matching desloppify theme
SCALE = 2 # 2x for retina/high-DPI
BG = (248, 248, 246) # Light gray background
FRAME = (222, 222, 220) # Border frame
BORDER = (200, 200, 198) # Inner border
ACCENT = (88, 166, 255) # Blue accent
TEXT = (40, 44, 52) # Dark text
DIM = (140, 140, 140) # Dimmed text
BG_SCORE = (255, 255, 255) # Score background
BG_TABLE = (255, 255, 255) # Table background
BG_ROW_ALT = (250, 250, 248) # Alternating row background
@dataclass
class ScorecardData:
"""Data structure matching desloppify scorecard format."""
project_name: str
version: str
main_score: float
strict_score: float
dimensions: List[Tuple[str, Dict[str, Any]]]
def score_color(score: float, *, muted: bool = False) -> Tuple[int, int, int]:
"""Color-code a score: deep sage >= 90, mustard 70-90, dusty rose < 70.
muted=True returns a desaturated variant for secondary display (strict column).
"""
if score >= 90:
base = (68, 120, 68) # deep sage
elif score >= 70:
base = (120, 140, 72) # olive green
else:
base = (145, 155, 80) # yellow-green
if not muted:
return base
# Pastel orange shades for strict column
if score >= 90:
return (195, 160, 115) # light sandy peach
if score >= 70:
return (200, 148, 100) # warm apricot
return (195, 125, 95) # soft coral
def fmt_score(score: float) -> str:
"""Format score with one decimal place, dropping .0 for integers."""
if score == int(score):
return str(int(score))
return f"{score:.1f}"
def scale(value: int) -> int:
"""Scale value by retina factor."""
return value * SCALE
def load_font(size: int, *, serif: bool = False, bold: bool = False, mono: bool = False) -> ImageFont.ImageFont:
"""Load a font with cross-platform fallback."""
size = size * SCALE
candidates = []
if mono:
candidates = [
"/System/Library/Fonts/SFNSMono.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
"DejaVuSansMono.ttf",
]
elif serif and bold:
candidates = [
"/System/Library/Fonts/Supplemental/Georgia Bold.ttf",
"/System/Library/Fonts/NewYork.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
"DejaVuSerif-Bold.ttf",
]
elif serif:
candidates = [
"/System/Library/Fonts/Supplemental/Georgia.ttf",
"/System/Library/Fonts/NewYork.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
"DejaVuSerif.ttf",
]
elif bold:
candidates = [
"/System/Library/Fonts/SFCompact.ttf",
"/System/Library/Fonts/HelveticaNeue.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"DejaVuSans-Bold.ttf",
]
else:
candidates = [
"/System/Library/Fonts/SFCompact.ttf",
"/System/Library/Fonts/HelveticaNeue.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"DejaVuSans.ttf",
]
for path in candidates:
try:
if os.path.exists(path):
return ImageFont.truetype(path, size)
except OSError:
continue
# Fallback to default font
try:
return ImageFont.load_default()
except:
return ImageFont.truetype("arial.ttf", size)
def draw_left_panel(
draw: ImageDraw.ImageDraw,
main_score: float,
strict_score: float,
project_name: str,
version: str,
*,
lp_left: int,
lp_right: int,
lp_top: int,
lp_bot: int,
) -> None:
"""Draw left panel with title, scores, and project info."""
# Fonts
font_title = load_font(16, bold=True)
font_big = load_font(48, bold=True)
font_version = load_font(11)
font_strict = load_font(12, bold=True)
# Title
title = "CODE HEALTH"
title_bbox = draw.textbbox((0, 0), title, font=font_title)
title_width = title_bbox[2] - title_bbox[0]
title_y = lp_top + scale(8)
center_x = (lp_left + lp_right) // 2
draw.text(
(center_x - title_width / 2, title_y - title_bbox[1]),
title,
fill=TEXT,
font=font_title,
)
# Main score
score_y = title_y + scale(35)
score_text = fmt_score(main_score)
score_bbox = draw.textbbox((0, 0), score_text, font=font_big)
score_width = score_bbox[2] - score_bbox[0]
# Score background
score_bg_y = score_y - scale(5)
score_bg_h = scale(55)
draw.rectangle(
(lp_left + scale(10), score_bg_y, lp_right - scale(10), score_bg_y + score_bg_h),
fill=BG_SCORE,
outline=BORDER,
width=1,
)
draw.text(
(center_x - score_width / 2, score_y - score_bbox[1]),
score_text,
fill=score_color(main_score),
font=font_big,
)
# Strict score
strict_y = score_bg_y + score_bg_h + scale(12)
strict_text = f"Strict: {fmt_score(strict_score)}"
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
strict_width = strict_bbox[2] - strict_bbox[0]
draw.text(
(center_x - strict_width / 2, strict_y - strict_bbox[1]),
strict_text,
fill=score_color(strict_score, muted=True),
font=font_strict,
)
# Project info
info_y = strict_y + scale(25)
# Project name
name_text = project_name.upper()
name_bbox = draw.textbbox((0, 0), name_text, font=font_version)
name_width = name_bbox[2] - name_bbox[0]
draw.text(
(center_x - name_width / 2, info_y - name_bbox[1]),
name_text,
fill=DIM,
font=font_version,
)
# Version
version_y = info_y + scale(18)
version_text = f"v{version}" if version else "dev"
version_bbox = draw.textbbox((0, 0), version_text, font=font_version)
version_width = version_bbox[2] - version_bbox[0]
draw.text(
(center_x - version_width / 2, version_y - version_bbox[1]),
version_text,
fill=DIM,
font=font_version,
)
def draw_vert_rule_with_ornament(
draw: ImageDraw.ImageDraw,
x: int,
y1: int,
y2: int,
mid_y: int,
color: Tuple[int, int, int],
accent: Tuple[int, int, int],
) -> None:
"""Draw vertical divider with ornament at center."""
# Vertical lines
draw.line([(x, y1), (x, mid_y - scale(8))], fill=color, width=1)
draw.line([(x, mid_y + scale(8)), (x, y2)], fill=color, width=1)
# Ornament circle
ornament_size = scale(6)
draw.ellipse(
(x - ornament_size // 2, mid_y - ornament_size // 2,
x + ornament_size // 2, mid_y + ornament_size // 2),
outline=accent,
width=2,
)
draw.ellipse(
(x - ornament_size // 2 + 2, mid_y - ornament_size // 2 + 2,
x + ornament_size // 2 - 2, mid_y + ornament_size // 2 - 2),
outline=color,
width=1,
)
def draw_right_panel(
draw: ImageDraw.ImageDraw,
active_dims: List[Tuple[str, Dict[str, Any]]],
row_h: int,
*,
table_x1: int,
table_x2: int,
table_top: int,
table_bot: int,
) -> None:
"""Draw right panel: two separate dimension tables side by side."""
font_row = load_font(11, mono=True)
font_strict = load_font(9, mono=True)
row_count = len(active_dims)
cols = 2
rows_per_col = (row_count + cols - 1) // cols
table_width = table_x2 - table_x1
grid_gap = scale(8)
grid_width = (table_width - grid_gap) // cols
for col_index in range(cols):
grid_x1 = table_x1 + col_index * (grid_width + grid_gap)
grid_x2 = grid_x1 + grid_width
draw.rounded_rectangle(
(grid_x1, table_top, grid_x2, table_bot),
radius=scale(4),
fill=BG_TABLE,
outline=BORDER,
width=1,
)
name_col_width = scale(120)
value_col_gap = scale(4)
value_col_width = scale(34)
total_content_width = (
name_col_width
+ value_col_gap
+ value_col_width
+ value_col_gap
+ value_col_width
)
block_left = grid_x1 + (grid_width - total_content_width) // 2
name_col_x = block_left
health_col_x = name_col_x + name_col_width + value_col_gap
strict_col_x = health_col_x + value_col_width + value_col_gap + scale(4)
rows_this_col = min(rows_per_col, row_count - col_index * rows_per_col)
content_height = rows_this_col * row_h
content_top = (table_top + table_bot) // 2 - content_height // 2
sample_bbox = draw.textbbox((0, 0), "Xg", font=font_row)
row_text_height = sample_bbox[3] - sample_bbox[1]
row_text_offset = sample_bbox[1]
start_idx = col_index * rows_per_col
for row_index in range(rows_this_col):
dim_idx = start_idx + row_index
if dim_idx >= row_count:
break
name, data = active_dims[dim_idx]
band_top = content_top + row_index * row_h
band_bottom = band_top + row_h
if row_index % 2 == 1:
draw.rectangle(
(grid_x1 + 1, band_top, grid_x2 - 1, band_bottom), fill=BG_ROW_ALT
)
text_y = band_top + (row_h - row_text_height) // 2 - row_text_offset + scale(1)
score = data.get("score", 100)
strict = data.get("strict", score)
max_name_width = name_col_width - scale(2)
while (
name
and draw.textlength(name + "\u2026", font=font_row) > max_name_width
):
name = name[:-1]
if draw.textlength(name, font=font_row) > max_name_width:
name = name.rstrip() + "\u2026"
draw.text((name_col_x, text_y), name, fill=TEXT, font=font_row)
draw.text(
(health_col_x, text_y),
f"{fmt_score(score)}%",
fill=score_color(score),
font=font_row,
)
strict_text = f"{fmt_score(strict)}%"
strict_bbox = draw.textbbox((0, 0), strict_text, font=font_strict)
strict_text_height = strict_bbox[3] - strict_bbox[1]
strict_y = band_top + (row_h - strict_text_height) // 2 - strict_bbox[1]
draw.text(
(strict_col_x, strict_y),
strict_text,
fill=score_color(strict, muted=True),
font=font_strict,
)
def generate_scorecard(data: ScorecardData, output_path: str | Path) -> Path:
"""Render a landscape scorecard PNG from scorecard data. Returns output path."""
output_path = Path(output_path)
# Layout — landscape (wide), dimensions first
row_count = len(data.dimensions)
row_h = scale(20)
width = scale(780)
divider_x = scale(260)
frame_inset = scale(5)
cols = 2
rows_per_col = (row_count + cols - 1) // cols
table_content_h = scale(14) + scale(4) + scale(6) + rows_per_col * row_h
content_h = max(table_content_h + scale(28), scale(150))
height = scale(12) + content_h
# Create image
img = Image.new("RGB", (width, height), BG)
draw = ImageDraw.Draw(img)
# Double frame
draw.rectangle((0, 0, width - 1, height - 1), outline=FRAME, width=scale(2))
draw.rectangle(
(frame_inset, frame_inset, width - frame_inset - 1, height - frame_inset - 1),
outline=BORDER,
width=1,
)
content_top = frame_inset + scale(1)
content_bot = height - frame_inset - scale(1)
content_mid_y = (content_top + content_bot) // 2
# Left panel: title + score + project name
draw_left_panel(
draw,
data.main_score,
data.strict_score,
data.project_name,
data.version,
lp_left=frame_inset + scale(11),
lp_right=divider_x - scale(11),
lp_top=content_top + scale(4),
lp_bot=content_bot - scale(4),
)
# Vertical divider with ornament
draw_vert_rule_with_ornament(
draw,
divider_x,
content_top + scale(12),
content_bot - scale(12),
content_mid_y,
BORDER,
ACCENT,
)
# Right panel: dimension table
draw_right_panel(
draw,
data.dimensions,
row_h,
table_x1=divider_x + scale(11),
table_x2=width - frame_inset - scale(11),
table_top=content_top + scale(4),
table_bot=content_bot - scale(4),
)
# Save image
output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(str(output_path), "PNG", optimize=True)
return output_path
def load_devour_data(json_path: str) -> ScorecardData:
"""Load Devour scan results and convert to scorecard format."""
with open(json_path, 'r') as f:
data = json.load(f)
# Extract findings
findings = data.get('findings', [])
# Calculate scores
total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings)
strict_score = total_score # Simplified - would use strict scoring logic
# Convert to percentage (inverted)
main_score = max(0, 100 - (total_score / 1000 * 100))
strict_score_pct = max(0, 100 - (strict_score / 1000 * 100))
# Group by type for dimensions
type_counts = {}
type_scores = {}
for finding in findings:
ftype = finding.get('type', 'unknown')
type_counts[ftype] = type_counts.get(ftype, 0) + 1
type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0)
# Create dimensions list
dimensions = []
for ftype, count in type_counts.items():
avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100)
dimensions.append((
ftype.replace('_', ' ').title(),
{
'score': max(0, min(100, avg_score)),
'strict': max(0, min(100, avg_score * 0.8)), # Strict is lower
'count': count
}
))
# Sort by score (lowest first)
dimensions.sort(key=lambda x: x[1]['score'])
return ScorecardData(
project_name="Devour",
version="1.0.0",
main_score=main_score,
strict_score=strict_score_pct,
dimensions=dimensions[:8], # Limit to 8 dimensions
)
def main():
"""Main entry point."""
if len(sys.argv) != 3:
print("Usage: python devour_scorecard.py <devour_results.json> <output.png>")
sys.exit(1)
json_path = sys.argv[1]
output_path = sys.argv[2]
if not os.path.exists(json_path):
print(f"Error: Input file {json_path} not found")
sys.exit(1)
try:
# Load and convert data
data = load_devour_data(json_path)
# Generate scorecard
result_path = generate_scorecard(data, output_path)
print(f"Scorecard generated: {result_path}")
except Exception as e:
print(f"Error generating scorecard: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+7 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"os"
@@ -87,7 +88,7 @@ func init() {
func runScrape(cmd *cobra.Command, args []string) error {
cfg, err := loadAppConfig()
if err != nil {
return err
return fmt.Errorf("load app config for scrape command: %w", err)
}
if scrapeSources != "" {
@@ -122,7 +123,7 @@ func runScrape(cmd *cobra.Command, args []string) error {
outputDir := resolveOutputDir(cfg, scrapeOutput)
count, err := scrapeOne(cmd, cfg, source, outputDir)
if err != nil {
return err
return fmt.Errorf("scrape source %q: %w", sourceURL, err)
}
if cfg.Indexing.Enabled {
@@ -151,7 +152,7 @@ func scrapeFromConfig(cmd *cobra.Command, cfg *appconfig.Config, configPath stri
Sources []appconfig.SourceConfig `yaml:"sources"`
}
if wrapErr := yaml.Unmarshal(raw, &wrapped); wrapErr != nil {
return fmt.Errorf("parse sources file: %w", err)
return fmt.Errorf("parse sources file: %w", wrapErr)
}
list = wrapped.Sources
}
@@ -167,6 +168,7 @@ func scrapeFromConfig(cmd *cobra.Command, cfg *appconfig.Config, configPath stri
success := 0
failures := 0
totalDocs := 0
sourceErrors := make([]error, 0)
for _, srcCfg := range list {
source := sourceFromConfig(srcCfg)
if source.Type == "" {
@@ -189,6 +191,7 @@ func scrapeFromConfig(cmd *cobra.Command, cfg *appconfig.Config, configPath stri
if srcErr != nil {
failures++
fmt.Printf("✗ %s failed: %v\n", source.Name, srcErr)
sourceErrors = append(sourceErrors, fmt.Errorf("%s: %w", source.Name, srcErr))
continue
}
totalDocs += count
@@ -204,7 +207,7 @@ func scrapeFromConfig(cmd *cobra.Command, cfg *appconfig.Config, configPath stri
fmt.Printf("\nSummary: %d succeeded, %d failed, %d docs written\n", success, failures, totalDocs)
if failures > 0 {
return fmt.Errorf("one or more sources failed")
return fmt.Errorf("one or more sources failed: %w", errors.Join(sourceErrors...))
}
return nil
}
+35 -18
View File
@@ -42,7 +42,7 @@ func init() {
func runServe(cmd *cobra.Command, args []string) error {
if _, err := loadAppConfig(); err != nil {
return err
return fmt.Errorf("load app config for server startup: %w", err)
}
srvCfg := &server.Config{
@@ -65,14 +65,17 @@ func runServe(cmd *cobra.Command, args []string) error {
}
srv := server.NewServer(srvCfg)
return srv.Start(context.Background())
if err := srv.Start(context.Background()); err != nil {
return fmt.Errorf("start rpc server: %w", err)
}
return nil
}
func handleServeMethod(ctx context.Context, method string, params json.RawMessage) (any, error) {
// The method implementation needs full typed config. Load per-call to avoid stale state.
loadedCfg, err := loadAppConfig()
if err != nil {
return nil, err
return nil, fmt.Errorf("load app config for rpc method %q: %w", method, err)
}
switch strings.TrimSpace(method) {
@@ -83,23 +86,31 @@ func handleServeMethod(ctx context.Context, method string, params json.RawMessag
Threshold float64 `json:"threshold"`
}
if len(params) > 0 {
_ = json.Unmarshal(params, &req)
if err := json.Unmarshal(params, &req); err != nil {
return nil, fmt.Errorf("decode devour_query params: %w", err)
}
}
engine := search.NewEngine(loadedCfg)
results, stats, err := engine.Search(ctx, req.Query, search.SearchOptions{Limit: req.Limit, Threshold: req.Threshold})
if err != nil {
return nil, err
return nil, fmt.Errorf("run devour_query search: %w", err)
}
return map[string]any{"query": req.Query, "count": len(results), "results": results, "indexed": stats.Documents}, nil
case "devour_status":
docsStats, err := projectstate.CollectDocsStats(loadedCfg.Storage.DocsDir)
if err != nil {
return nil, err
return nil, fmt.Errorf("collect docs stats: %w", err)
}
state, err := projectstate.LoadSourceState(loadedCfg.Storage.MetadataDir)
if err != nil {
return nil, fmt.Errorf("load source state: %w", err)
}
state, _ := projectstate.LoadSourceState(loadedCfg.Storage.MetadataDir)
engine := search.NewEngine(loadedCfg)
idxStats, _ := engine.EnsureIndexed(ctx)
idxStats, err := engine.EnsureIndexed(ctx)
if err != nil {
return nil, fmt.Errorf("ensure search index: %w", err)
}
return map[string]any{
"documents": docsStats.DocumentCount,
"storage_bytes": docsStats.StorageBytes,
@@ -122,7 +133,7 @@ func handleServeMethod(ctx context.Context, method string, params json.RawMessag
Exclude []string `json:"exclude"`
}
if err := json.Unmarshal(params, &req); err != nil {
return nil, err
return nil, fmt.Errorf("decode devour_scrape params: %w", err)
}
if strings.TrimSpace(req.Source) == "" {
return nil, fmt.Errorf("source is required")
@@ -148,15 +159,17 @@ func handleServeMethod(ctx context.Context, method string, params json.RawMessag
prevFormat := scrapeFormat
prevOutput := scrapeOutput
prevAllowEmpty := scrapeAllowEmpty
defer func() {
scrapeFormat = prevFormat
scrapeOutput = prevOutput
scrapeAllowEmpty = prevAllowEmpty
}()
scrapeFormat = coalesceString(req.Format, "json")
scrapeOutput = req.Output
scrapeAllowEmpty = false
count, err := scrapeOne(nil, loadedCfg, source, resolveOutputDir(loadedCfg, req.Output))
scrapeFormat = prevFormat
scrapeOutput = prevOutput
scrapeAllowEmpty = prevAllowEmpty
if err != nil {
return nil, err
return nil, fmt.Errorf("run scrape for %q: %w", req.Source, err)
}
return map[string]any{"source": req.Source, "type": st, "documents": count}, nil
@@ -166,7 +179,7 @@ func handleServeMethod(ctx context.Context, method string, params json.RawMessag
Limit int `json:"limit"`
}
if err := json.Unmarshal(params, &req); err != nil {
return nil, err
return nil, fmt.Errorf("decode devour_ask params: %w", err)
}
if strings.TrimSpace(req.Question) == "" {
return nil, fmt.Errorf("question is required")
@@ -178,7 +191,7 @@ func handleServeMethod(ctx context.Context, method string, params json.RawMessag
engine := search.NewEngine(loadedCfg)
results, _, err := engine.Search(ctx, req.Question, search.SearchOptions{Limit: limit})
if err != nil {
return nil, err
return nil, fmt.Errorf("run devour_ask search: %w", err)
}
summary := "No relevant docs found."
if len(results) > 0 {
@@ -194,15 +207,19 @@ func handleServeMethod(ctx context.Context, method string, params json.RawMessag
Rebuild bool `json:"rebuild"`
}
if len(params) > 0 {
_ = json.Unmarshal(params, &req)
if err := json.Unmarshal(params, &req); err != nil {
return nil, fmt.Errorf("decode devour_sync params: %w", err)
}
}
syncForce = req.Force
syncRebuild = req.Rebuild
syncSource = req.Source
defer func() {
syncForce, syncRebuild, syncSource = prevForce, prevRebuild, prevSource
}()
err := runSync(nil, nil)
syncForce, syncRebuild, syncSource = prevForce, prevRebuild, prevSource
if err != nil {
return nil, err
return nil, fmt.Errorf("run devour_sync: %w", err)
}
return map[string]any{"ok": true}, nil