mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-07-29 15:43:48 +00:00
i dont like commits
This commit is contained in:
+51
-15
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user