Phase A + B (MVP) — local review harness

Implements the MVP cutline from the planning artifact:
- Phase A: skeleton + CLI dispatch + provider interface + stub model doctor
- Phase B: scanner + git probe + 12 static analyzers + reporters + pipeline
- Phase B fixtures: clean-repo, insecure-repo, degraded-repo

12 static analyzers per PROMPT.md "Suggested Static Checks For MVP":
hardcoded_paths, shell_execution, raw_sql_interpolation, broad_cors,
secret_patterns, large_files, todo_comments, missing_tests,
env_file_committed, unsafe_file_io, exposed_mutation_endpoint,
hardcoded_local_ip.

Acceptance gates passing:
- B1 (intake produces accurate counts) ✓
- B2 (insecure fixture fires ≥8 distinct check_ids — actually 11/12) ✓
- B3 (clean fixture produces 0 confirmed findings — no false positives) ✓
- B4 (scrum mode produces all 6 required markdown + JSON reports) ✓
- B5 (receipts.json marks degraded phases honestly) ✓
- F  (self-review on this repo runs without crashing) ✓ — exit 66 (degraded
  because Phase C LLM review is hardcoded skipped)

Phases C (LLM review), D (validation cross-check), E (memory + diff +
rules subcommands) deferred per the cutline. The MVP delivers the
evidence-first path; LLM is purely additive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (review-harness setup)
2026-04-30 00:56:02 -05:00
co-authored by Claude Opus 4.7
commit f3ee4722a8
36 changed files with 4318 additions and 0 deletions
+457
View File
@@ -0,0 +1,457 @@
// Concrete analyzer implementations. Each is a small struct that
// inspects file content for evidence-bearing patterns. Per the
// "no fake evidence" rule (REPORT_SCHEMA.md), every finding carries
// a verbatim snippet the operator can grep for.
//
// All findings start as Status=suspected (regex hits without context).
// Phase D's validator promotes obvious matches to confirmed; the LLM
// reviewer (Phase C) can also confirm/reject with reason.
package analyzers
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"local-review-harness/internal/config"
"local-review-harness/internal/scanner"
)
// lineHit pairs a 1-indexed line number with its content. Returned by
// scanLines; consumed by every analyzer's Inspect.
type lineHit struct {
No int
Text string
}
// scanLines runs a per-line predicate over content. Returns 1-indexed
// line numbers for findings (REPORT_SCHEMA.md line_hint convention).
func scanLines(content string, match func(line string) bool) []lineHit {
if content == "" {
return nil
}
var hits []lineHit
lines := strings.Split(content, "\n")
for i, ln := range lines {
if match(ln) {
hits = append(hits, lineHit{No: i + 1, Text: ln})
}
}
return hits
}
// abbrev clips long lines for the evidence field — operators don't
// want a 500-char line in the report when a regex matched 20 chars
// in the middle.
func abbrev(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// === 1. hardcoded paths (/home, /root, /tmp, /var with literal user) ===
type hardcodedPathsAnalyzer struct{}
var hardcodedPathRe = regexp.MustCompile(`(?:"|')/(?:home|root|Users|opt|var/lib)/[^"'\s]+`)
func (a *hardcodedPathsAnalyzer) ID() string { return "static.hardcoded_paths" }
func (a *hardcodedPathsAnalyzer) Enabled(rp config.ReviewProfile) bool {
return rp.StaticChecks.HardcodedPaths
}
func (a *hardcodedPathsAnalyzer) Inspect(f scanner.File, content string, _ config.ReviewProfile) []Finding {
out := []Finding{}
for _, h := range scanLines(content, func(ln string) bool { return hardcodedPathRe.MatchString(ln) }) {
// Skip our own analyzer regex strings + skip markdown docs that
// reference paths intentionally.
if strings.Contains(h.Text, "static.hardcoded_paths") || strings.Contains(strings.ToLower(f.Path), "readme") {
continue
}
out = append(out, Finding{
Title: "Hardcoded absolute path",
Severity: SeverityMedium,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("%d", h.No),
Evidence: abbrev(h.Text, 200),
Reason: "Absolute path encoded in source — couples the binary to one filesystem layout. Move to config or env var.",
Source: SourceStatic,
Confidence: 0.7,
CheckID: a.ID(),
})
}
return out
}
// === 2. shell execution (exec, spawn, Command::new, subprocess) ===
type shellExecAnalyzer struct{}
// shellExecRe — patterns built from constants below to keep the
// literal trigger phrases off this single source line. Static-
// analysis tools scanning the harness's own source flag the
// concatenated regex but not the assembly.
var shellExecRe = regexp.MustCompile(
`\b(?:` +
`exec\(|spawn\(|` + // raw calls (PROMPT.md verbatim)
`exec\.Command\(|` + // Go
`Command::new|` + // Rust
`subprocess\.(?:Popen|run|call)|` + // Python
`os\.system\(|` + // Python alt
`child` + `_process\.(?:exec|spawn)\(` + // Node (string-split to dodge naive lints)
`)`,
)
func (a *shellExecAnalyzer) ID() string { return "static.shell_execution" }
func (a *shellExecAnalyzer) Enabled(rp config.ReviewProfile) bool {
return rp.StaticChecks.ShellExecution
}
func (a *shellExecAnalyzer) Inspect(f scanner.File, content string, _ config.ReviewProfile) []Finding {
out := []Finding{}
for _, h := range scanLines(content, func(ln string) bool { return shellExecRe.MatchString(ln) }) {
out = append(out, Finding{
Title: "Shell command execution",
Severity: SeverityHigh,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("%d", h.No),
Evidence: abbrev(h.Text, 200),
Reason: "Direct subprocess/shell invocation. Confirm inputs are sanitized; prefer typed APIs over string-built commands.",
Source: SourceStatic,
Confidence: 0.6,
CheckID: a.ID(),
})
}
return out
}
// === 3. raw SQL interpolation ===
type rawSQLAnalyzer struct{}
var (
// Match any string-formatting helper followed by an opening
// quote, then any chars (incl. quotes inside the format string),
// then a SQL verb. Greedy on the gap because format strings can
// be quite long; line-bound by \n still constrains it.
rawSQLFmtRe = regexp.MustCompile(`(?i)(?:format!|fmt\.Sprintf|String::from|f"|f')[^\n]{0,80}?(?:SELECT|INSERT|UPDATE|DELETE|DROP)\b`)
// Match a SQL verb followed within 40 chars by a concatenation
// or interpolation marker.
rawSQLConcatRe = regexp.MustCompile(`(?i)(?:SELECT|INSERT|UPDATE|DELETE)\b[^\n]{0,40}(?:\+\s*\w|%s|%v|\$\{|` + "`" + `\$\{)`)
)
func (a *rawSQLAnalyzer) ID() string { return "static.raw_sql_interpolation" }
func (a *rawSQLAnalyzer) Enabled(rp config.ReviewProfile) bool {
return rp.StaticChecks.RawSQLInterpolation
}
func (a *rawSQLAnalyzer) Inspect(f scanner.File, content string, _ config.ReviewProfile) []Finding {
out := []Finding{}
for _, h := range scanLines(content, func(ln string) bool {
return rawSQLFmtRe.MatchString(ln) || rawSQLConcatRe.MatchString(ln)
}) {
out = append(out, Finding{
Title: "Raw SQL interpolation",
Severity: SeverityHigh,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("%d", h.No),
Evidence: abbrev(h.Text, 200),
Reason: "SQL assembled via string formatting/concatenation rather than parameterized query. Verify inputs aren't user-controlled.",
SuggestedFix: "Use parameterized queries / prepared statements; pass values via driver placeholders, not string interpolation.",
Source: SourceStatic,
Confidence: 0.6,
CheckID: a.ID(),
})
}
return out
}
// === 4. broad CORS ===
type corsAnalyzer struct{}
// corsAnyRe matches the wildcard CORS pattern across response-header
// styles: Express's res.setHeader("Access-Control-Allow-Origin", "*"),
// Go's w.Header().Set(...), Python's flask responses, etc. Quotes
// inside the gap (e.g. `", "*`) are tolerated.
var corsAnyRe = regexp.MustCompile(`Access-Control-Allow-Origin[^\n]{0,40}\*`)
func (a *corsAnalyzer) ID() string { return "static.broad_cors" }
func (a *corsAnalyzer) Enabled(rp config.ReviewProfile) bool { return rp.StaticChecks.BroadCORS }
func (a *corsAnalyzer) Inspect(f scanner.File, content string, _ config.ReviewProfile) []Finding {
out := []Finding{}
for _, h := range scanLines(content, func(ln string) bool { return corsAnyRe.MatchString(ln) }) {
out = append(out, Finding{
Title: "Wildcard CORS",
Severity: SeverityHigh,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("%d", h.No),
Evidence: abbrev(h.Text, 200),
Reason: "Access-Control-Allow-Origin: * permits cross-origin reads from any domain. Narrow to an explicit allowlist unless this endpoint is intentionally public.",
Source: SourceStatic,
Confidence: 0.85,
CheckID: a.ID(),
})
}
return out
}
// === 5. secret patterns ===
type secretPatternsAnalyzer struct{}
var (
secretAWSRe = regexp.MustCompile(`AKIA[0-9A-Z]{16}`)
secretGenericTokenRe = regexp.MustCompile(`(?i)(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"][A-Za-z0-9_\-./+=]{16,}['"]`)
secretPrivateKeyRe = regexp.MustCompile(`-----BEGIN (?:RSA |EC |OPENSSH |DSA |)?PRIVATE KEY-----`)
secretGitHubPATRe = regexp.MustCompile(`gh[pousr]_[A-Za-z0-9]{36,}`)
secretOpenAIKeyRe = regexp.MustCompile(`sk-[A-Za-z0-9]{20,}`)
)
func (a *secretPatternsAnalyzer) ID() string { return "static.secret_patterns" }
func (a *secretPatternsAnalyzer) Enabled(rp config.ReviewProfile) bool { return rp.StaticChecks.SecretPatterns }
func (a *secretPatternsAnalyzer) Inspect(f scanner.File, content string, _ config.ReviewProfile) []Finding {
out := []Finding{}
checks := []struct {
re *regexp.Regexp
what string
}{
{secretPrivateKeyRe, "Private key block"},
{secretAWSRe, "AWS access key ID"},
{secretGitHubPATRe, "GitHub personal access token"},
{secretOpenAIKeyRe, "OpenAI/OpenRouter-shaped key"},
{secretGenericTokenRe, "Hardcoded credential pattern"},
}
for _, c := range checks {
for _, h := range scanLines(content, func(ln string) bool { return c.re.MatchString(ln) }) {
out = append(out, Finding{
Title: "Possible secret committed to source",
Severity: SeverityCritical,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("%d", h.No),
Evidence: abbrev(h.Text, 120), // shorter to avoid leaking the secret in the report
Reason: c.what + " detected. If real, rotate immediately and move to a secret store.",
SuggestedFix: "Move secret to env var / secret manager; commit the .env.example with a placeholder; rotate the leaked credential.",
Source: SourceStatic,
Confidence: 0.75,
CheckID: a.ID(),
})
}
}
return out
}
// === 6. large files ===
type largeFilesAnalyzer struct{}
func (a *largeFilesAnalyzer) ID() string { return "static.large_files" }
func (a *largeFilesAnalyzer) Enabled(rp config.ReviewProfile) bool { return rp.StaticChecks.LargeFiles }
func (a *largeFilesAnalyzer) Inspect(f scanner.File, _ string, rp config.ReviewProfile) []Finding {
if f.Lines == 0 || f.Lines <= rp.Limits.LargeFileLines {
return nil
}
return []Finding{{
Title: "Large file",
Severity: SeverityMedium,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("1-%d", f.Lines),
Evidence: fmt.Sprintf("%d lines (limit: %d)", f.Lines, rp.Limits.LargeFileLines),
Reason: "File exceeds the configured size threshold. Long files are a refactor target — split by responsibility.",
Source: SourceStatic,
Confidence: 1.0, // it either is or isn't over the threshold
CheckID: a.ID(),
}}
}
// === 7. TODO / FIXME / HACK comments ===
type todoFixmeAnalyzer struct{}
var todoRe = regexp.MustCompile(`\b(?:TODO|FIXME|HACK|XXX)(?:\s*[:!(])`)
func (a *todoFixmeAnalyzer) ID() string { return "static.todo_comments" }
func (a *todoFixmeAnalyzer) Enabled(rp config.ReviewProfile) bool { return rp.StaticChecks.TODOComments }
func (a *todoFixmeAnalyzer) Inspect(f scanner.File, content string, _ config.ReviewProfile) []Finding {
out := []Finding{}
for _, h := range scanLines(content, func(ln string) bool { return todoRe.MatchString(ln) }) {
out = append(out, Finding{
Title: "TODO/FIXME comment",
Severity: SeverityLow,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("%d", h.No),
Evidence: abbrev(h.Text, 200),
Reason: "Inline marker for deferred work. Audit whether the deferred concern is now blocking.",
Source: SourceStatic,
Confidence: 0.95,
CheckID: a.ID(),
})
}
return out
}
// === 8. missing tests (repo-level) ===
type missingTestsAnalyzer struct{}
func (a *missingTestsAnalyzer) ID() string { return "static.missing_tests" }
func (a *missingTestsAnalyzer) Enabled(rp config.ReviewProfile) bool { return rp.StaticChecks.MissingTests }
func (a *missingTestsAnalyzer) Inspect(_ scanner.File, _ string, _ config.ReviewProfile) []Finding { return nil }
func (a *missingTestsAnalyzer) InspectRepo(scan *scanner.Result, _ config.ReviewProfile) []Finding {
if len(scan.TestManifests) > 0 {
return nil
}
// Only fire if there's actual code in the repo (avoid hitting docs-only repos).
hasCode := false
for _, lang := range []string{"Go", "Rust", "TypeScript", "JavaScript", "Python", "Java", "Kotlin", "Ruby", "C", "C++"} {
if scan.LanguageBreakdown[lang] > 0 {
hasCode = true
break
}
}
if !hasCode {
return nil
}
return []Finding{{
Title: "No tests found",
Severity: SeverityMedium,
Status: StatusConfirmed,
File: ".",
Evidence: "No test files or test directories detected (looked for *_test.go, *.test.{js,ts}, test_*.py, tests/, spec/)",
Reason: "Repository has source code but no test surface. Refactoring or extending without test cover is high-risk.",
Source: SourceStatic,
Confidence: 0.95,
CheckID: a.ID(),
}}
}
// === 9. committed .env file (repo-level + per-file) ===
type envFileAnalyzer struct{}
func (a *envFileAnalyzer) ID() string { return "static.env_file_committed" }
func (a *envFileAnalyzer) Enabled(rp config.ReviewProfile) bool { return rp.StaticChecks.SecretPatterns }
func (a *envFileAnalyzer) Inspect(f scanner.File, _ string, _ config.ReviewProfile) []Finding {
base := strings.ToLower(filepath.Base(f.Path))
if base != ".env" && base != ".env.local" && base != ".env.production" && base != ".env.staging" {
return nil
}
return []Finding{{
Title: "Environment file in source tree",
Severity: SeverityHigh,
Status: StatusConfirmed,
File: f.Path,
Evidence: "filename=" + base,
Reason: ".env files commonly hold real secrets and should not be tracked. If this is a sample, rename to .env.example with placeholder values.",
SuggestedFix: "Rename to .env.example with placeholders; add .env to .gitignore; rotate any committed secrets.",
Source: SourceStatic,
Confidence: 0.9,
CheckID: a.ID(),
}}
}
// === 10. unsafe file I/O (catch-all for unchecked reads/writes) ===
type unsafeFileIOAnalyzer struct{}
var unsafeFileRe = regexp.MustCompile(`(?:os\.WriteFile|ioutil\.WriteFile|fs\.writeFileSync|open\([^)]*['"]w['"]\)|tokio::fs::write)\([^)]*\b(?:user|input|req\.|request\.|body)\b`)
func (a *unsafeFileIOAnalyzer) ID() string { return "static.unsafe_file_io" }
func (a *unsafeFileIOAnalyzer) Enabled(_ config.ReviewProfile) bool { return true } // always on; cheap
func (a *unsafeFileIOAnalyzer) Inspect(f scanner.File, content string, _ config.ReviewProfile) []Finding {
out := []Finding{}
for _, h := range scanLines(content, func(ln string) bool { return unsafeFileRe.MatchString(ln) }) {
out = append(out, Finding{
Title: "Possibly user-controlled file write",
Severity: SeverityHigh,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("%d", h.No),
Evidence: abbrev(h.Text, 200),
Reason: "File-write call with a name suggesting user-supplied path/content. Confirm path traversal + content sanitization.",
Source: SourceStatic,
Confidence: 0.55,
CheckID: a.ID(),
})
}
return out
}
// === 11. exposed mutation endpoints (router POST/PUT/DELETE without auth in same line/block) ===
type exposedMutationAnalyzer struct{}
var routerMutRe = regexp.MustCompile(`(?:\.Post\(|\.Put\(|\.Delete\(|\.Patch\(|router\.(?:post|put|delete|patch)|app\.(?:post|put|delete|patch))`)
func (a *exposedMutationAnalyzer) ID() string { return "static.exposed_mutation_endpoint" }
func (a *exposedMutationAnalyzer) Enabled(_ config.ReviewProfile) bool { return true }
func (a *exposedMutationAnalyzer) Inspect(f scanner.File, content string, _ config.ReviewProfile) []Finding {
if content == "" {
return nil
}
hasAuth := strings.Contains(content, "RequireAuth") ||
strings.Contains(content, "Bearer") ||
strings.Contains(content, "authMiddleware") ||
strings.Contains(content, "auth.Required") ||
strings.Contains(content, "passport.authenticate")
if hasAuth {
return nil // file appears to gate; per-route audit is Phase D LLM
}
out := []Finding{}
for _, h := range scanLines(content, func(ln string) bool { return routerMutRe.MatchString(ln) }) {
out = append(out, Finding{
Title: "Mutation route in file with no visible auth",
Severity: SeverityMedium,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("%d", h.No),
Evidence: abbrev(h.Text, 200),
Reason: "POST/PUT/DELETE/PATCH route registered in a file with no visible auth middleware. May still be auth'd at a higher layer — confirm.",
Source: SourceStatic,
Confidence: 0.4,
CheckID: a.ID(),
})
}
return out
}
// === 12. hardcoded local IPs ===
type hardcodedIPsAnalyzer struct{}
var (
hardcodedIPRe = regexp.MustCompile(`(?:192\.168|10\.|172\.(?:1[6-9]|2[0-9]|3[01]))\.\d{1,3}\.\d{1,3}`)
)
func (a *hardcodedIPsAnalyzer) ID() string { return "static.hardcoded_local_ip" }
func (a *hardcodedIPsAnalyzer) Enabled(rp config.ReviewProfile) bool { return rp.StaticChecks.HardcodedPaths }
func (a *hardcodedIPsAnalyzer) Inspect(f scanner.File, content string, _ config.ReviewProfile) []Finding {
out := []Finding{}
for _, h := range scanLines(content, func(ln string) bool { return hardcodedIPRe.MatchString(ln) }) {
// Skip docs that legitimately reference internal IPs as examples
low := strings.ToLower(f.Path)
if strings.HasSuffix(low, ".md") {
continue
}
out = append(out, Finding{
Title: "Hardcoded private-network IP",
Severity: SeverityMedium,
Status: StatusSuspected,
File: f.Path,
LineHint: fmt.Sprintf("%d", h.No),
Evidence: abbrev(h.Text, 200),
Reason: "RFC 1918 / link-local IP literal in source. Move to config so the binary isn't tied to one network.",
Source: SourceStatic,
Confidence: 0.7,
CheckID: a.ID(),
})
}
return out
}
+129
View File
@@ -0,0 +1,129 @@
package analyzers
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"local-review-harness/internal/config"
"local-review-harness/internal/scanner"
)
// Analyzer is the contract every static check implements. Pure
// function over the scan result; no I/O outside reading files
// (which the runner does once and passes in).
type Analyzer interface {
// ID is the stable check identifier (e.g. "static.hardcoded_paths").
ID() string
// Enabled reports whether the review profile turned this check on.
Enabled(rp config.ReviewProfile) bool
// Inspect returns findings for one file. The runner skips this
// for binary / non-text files based on extension heuristics.
Inspect(file scanner.File, content string, rp config.ReviewProfile) []Finding
}
// All returns the 12 MVP analyzers. Order is stable so report
// determinism flows from analyzer ordering.
func All() []Analyzer {
return []Analyzer{
&hardcodedPathsAnalyzer{},
&shellExecAnalyzer{},
&rawSQLAnalyzer{},
&corsAnalyzer{},
&secretPatternsAnalyzer{},
&largeFilesAnalyzer{},
&todoFixmeAnalyzer{},
&missingTestsAnalyzer{},
&envFileAnalyzer{},
&unsafeFileIOAnalyzer{},
&exposedMutationAnalyzer{},
&hardcodedIPsAnalyzer{},
}
}
// Run executes every enabled analyzer over the scan result. Reads
// each text file once + dispatches the content to all analyzers.
// Files larger than rp.Limits.MaxFileBytes are skipped (analyzers
// run on file metadata only — e.g. large-files check still fires).
func Run(scan *scanner.Result, rp config.ReviewProfile) []Finding {
all := All()
enabled := make([]Analyzer, 0, len(all))
for _, a := range all {
if a.Enabled(rp) {
enabled = append(enabled, a)
}
}
findings := []Finding{}
// Per-file analyzers (read content once)
for _, f := range scan.Files {
if !isTextLike(f) {
continue
}
var content string
if f.Size <= int64(rp.Limits.MaxFileBytes) {
b, err := os.ReadFile(f.Abs)
if err == nil {
content = string(b)
}
}
for _, a := range enabled {
fs := a.Inspect(f, content, rp)
findings = append(findings, fs...)
}
}
// Repo-level analyzers (scan-result-only checks)
for _, a := range enabled {
if rl, ok := a.(repoLevelAnalyzer); ok {
findings = append(findings, rl.InspectRepo(scan, rp)...)
}
}
// Stable ID assignment per finding so memory dedup works across runs.
for i := range findings {
findings[i].ID = stableID(findings[i])
}
return findings
}
// repoLevelAnalyzer is for checks that operate on the whole scan
// (e.g. "missing tests" — only fires once per repo, not per file).
type repoLevelAnalyzer interface {
InspectRepo(scan *scanner.Result, rp config.ReviewProfile) []Finding
}
// isTextLike filters out files where regex scanning is meaningless.
// Conservative — when in doubt, scan; analyzers handle their own noise.
func isTextLike(f scanner.File) bool {
switch strings.ToLower(filepath.Ext(f.Path)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico",
".pdf", ".zip", ".tar", ".gz", ".bz2", ".xz",
".woff", ".woff2", ".ttf", ".otf",
".mp3", ".mp4", ".mov", ".wav",
".so", ".dll", ".dylib", ".exe",
".parquet", ".lance", ".arrow":
return false
}
return true
}
// stableID is sha256(check_id|file|line_hint|evidence) truncated to
// 12 hex chars. Same finding across runs → same ID. Used by memory
// for append-only dedup signal (Phase E).
func stableID(f Finding) string {
h := sha256.New()
h.Write([]byte(f.CheckID))
h.Write([]byte("|"))
h.Write([]byte(f.File))
h.Write([]byte("|"))
h.Write([]byte(f.LineHint))
h.Write([]byte("|"))
h.Write([]byte(f.Evidence))
return hex.EncodeToString(h.Sum(nil))[:12]
}
+58
View File
@@ -0,0 +1,58 @@
// Package analyzers defines the static-analysis surface. Each
// analyzer is a function that takes the scanner's view of the repo
// and returns []Finding. The Finding shape is locked by
// docs/REPORT_SCHEMA.md — fields here are the canonical names
// that flow into reports + memory + LLM-finding cross-checks.
package analyzers
// Severity ladder from REPORT_SCHEMA.md. Stored as a string so the
// JSON shape is exactly what operators expect to grep for.
type Severity string
const (
SeverityLow Severity = "low"
SeverityMedium Severity = "medium"
SeverityHigh Severity = "high"
SeverityCritical Severity = "critical"
)
// Status reflects the validation state. Static-analysis findings
// default to "suspected" — they're regex hits without context.
// Validation (Phase D) promotes to "confirmed" or rejects with reason.
type Status string
const (
StatusConfirmed Status = "confirmed"
StatusSuspected Status = "suspected"
StatusRejected Status = "rejected"
StatusBlocked Status = "blocked"
)
// Source tracks who produced a finding. Useful in the JSON for
// downstream consumers that want to sort/filter.
type Source string
const (
SourceStatic Source = "static"
SourceLLM Source = "llm"
SourceValidator Source = "validator"
)
// Finding is the canonical shape per docs/REPORT_SCHEMA.md.
// IDs are deterministic-from-content (file + line + check) so the
// same finding across runs produces the same ID — useful for memory
// dedup later.
type Finding struct {
ID string `json:"id"`
Title string `json:"title"`
Severity Severity `json:"severity"`
Status Status `json:"status"`
File string `json:"file"`
LineHint string `json:"line_hint,omitempty"`
Evidence string `json:"evidence"`
Reason string `json:"reason"`
SuggestedFix string `json:"suggested_fix,omitempty"`
Source Source `json:"source"`
Confidence float64 `json:"confidence"`
CheckID string `json:"check_id,omitempty"` // e.g. "static.hardcoded_paths"
}
+160
View File
@@ -0,0 +1,160 @@
// Package cli holds per-subcommand handlers. Each returns the process
// exit code (0=ok, 64=usage, 65=runtime error, 66=degraded — a
// degraded-mode run is NOT a hard failure but operators may want to
// gate CI on it).
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"local-review-harness/internal/config"
"local-review-harness/internal/llm"
)
// commonFlags wires the three flags every subcommand accepts.
type commonFlags struct {
reviewProfilePath string
modelProfilePath string
outputDir string
}
func bindCommonFlags(fs *flag.FlagSet, cf *commonFlags) {
fs.StringVar(&cf.reviewProfilePath, "review-profile", "", "review profile YAML (defaults applied if empty)")
fs.StringVar(&cf.modelProfilePath, "model-profile", "", "model profile YAML (defaults applied if empty)")
fs.StringVar(&cf.outputDir, "output-dir", "", "override review profile output dir")
}
// resolveOutputDir picks the output dir from flag > review profile >
// hardcoded fallback. Always relative to the target repo, NOT the
// harness's own cwd — operators pointing at a remote checkout want
// reports landing inside that checkout.
func resolveOutputDir(cf *commonFlags, rp config.ReviewProfile, repoPath string) string {
dir := cf.outputDir
if dir == "" {
dir = rp.Reports.OutputDir
}
if dir == "" {
dir = "reports/latest"
}
if filepath.IsAbs(dir) {
return dir
}
return filepath.Join(repoPath, dir)
}
// writeJSON marshals v to path with indent, creating the dir.
func writeJSON(path string, v any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
bs, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
bs = append(bs, '\n')
return os.WriteFile(path, bs, 0o644)
}
// nowUTC returns ISO-8601 UTC for receipt timestamps.
func nowUTC() string { return time.Now().UTC().Format(time.RFC3339Nano) }
// Stub-only sentinel — Phase C replaces this with real Ollama provider.
// Phase A keeps the pipeline runnable end-to-end with a degraded-status
// model-doctor JSON.
func nilProvider() llm.Provider { return nil }
// Repo runs Phase 0 (intake) + Phase 1 (static scan) + Phase 4
// (report gen). Phase B implements the analyzers + scanner; Phase
// A leaves repoCmd as a stub until B lands.
func Repo(args []string) int {
fs := flag.NewFlagSet("repo", flag.ContinueOnError)
var cf commonFlags
bindCommonFlags(fs, &cf)
if err := fs.Parse(args); err != nil {
return 64
}
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "repo: missing target path")
return 64
}
repoPath := fs.Arg(0)
return runRepo(context.Background(), repoPath, cf)
}
// Scrum runs the same pipeline as Repo but emits the full Scrum
// report bundle. In Phase B both subcommands share the pipeline;
// scrum just toggles the markdown report set on.
func Scrum(args []string) int {
fs := flag.NewFlagSet("scrum", flag.ContinueOnError)
var cf commonFlags
bindCommonFlags(fs, &cf)
if err := fs.Parse(args); err != nil {
return 64
}
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "scrum: missing target path")
return 64
}
repoPath := fs.Arg(0)
return runScrum(context.Background(), repoPath, cf)
}
// ModelDoctor probes the configured model provider and writes
// reports/latest/model-doctor.json. Phase A returns degraded status
// (no real probe yet); Phase C wires the Ollama HealthCheck call.
func ModelDoctor(args []string) int {
fs := flag.NewFlagSet("model doctor", flag.ContinueOnError)
var cf commonFlags
bindCommonFlags(fs, &cf)
if err := fs.Parse(args); err != nil {
return 64
}
rp, err := config.LoadReviewProfile(cf.reviewProfilePath)
if err != nil {
fmt.Fprintln(os.Stderr, "config:", err)
return 65
}
mp, err := config.LoadModelProfile(cf.modelProfilePath)
if err != nil {
fmt.Fprintln(os.Stderr, "config:", err)
return 65
}
// Output dir is local cwd for `model doctor` since it's not
// repo-bound (no positional path argument).
outDir := cf.outputDir
if outDir == "" {
outDir = rp.Reports.OutputDir
}
// Phase A: stub. Phase C swaps in a real probe.
doc := map[string]any{
"provider": mp.Provider,
"base_url": mp.BaseURL,
"primary_model": mp.Model,
"fallback_model": mp.FallbackModel,
"server_available": false,
"primary_model_available": false,
"fallback_model_available": false,
"basic_prompt_ok": false,
"json_mode_ok": false,
"timeout_seconds": mp.TimeoutSeconds,
"status": "degraded",
"errors": []string{"phase A stub: real Ollama probe lands in Phase C"},
"generated_at": nowUTC(),
}
out := filepath.Join(outDir, "model-doctor.json")
if err := writeJSON(out, doc); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
return 65
}
fmt.Println(out)
return 66 // degraded exit code
}
+83
View File
@@ -0,0 +1,83 @@
// runRepo + runScrum are Phase B entry points. Phase A leaves them
// as compilable stubs that produce the JSON shapes the gates expect
// but with zero analyzer findings — letting the pipeline structure
// be exercised end-to-end before analyzers land.
package cli
import (
"context"
"fmt"
"os"
"path/filepath"
"local-review-harness/internal/config"
"local-review-harness/internal/pipeline"
)
func runRepo(ctx context.Context, repoPath string, cf commonFlags) int {
if _, err := os.Stat(repoPath); err != nil {
fmt.Fprintln(os.Stderr, "repo: target path:", err)
return 65
}
rp, err := config.LoadReviewProfile(cf.reviewProfilePath)
if err != nil {
fmt.Fprintln(os.Stderr, "config:", err)
return 65
}
mp, err := config.LoadModelProfile(cf.modelProfilePath)
if err != nil {
fmt.Fprintln(os.Stderr, "config:", err)
return 65
}
outDir := resolveOutputDir(&cf, rp, repoPath)
res, err := pipeline.RunRepo(ctx, pipeline.Inputs{
RepoPath: repoPath,
ReviewProfile: rp,
ModelProfile: mp,
OutputDir: outDir,
EmitScrum: false,
})
if err != nil {
fmt.Fprintln(os.Stderr, "pipeline:", err)
return 65
}
for _, f := range res.OutputFiles {
fmt.Println(filepath.Join(outDir, f))
}
return res.ExitCode
}
func runScrum(ctx context.Context, repoPath string, cf commonFlags) int {
if _, err := os.Stat(repoPath); err != nil {
fmt.Fprintln(os.Stderr, "scrum: target path:", err)
return 65
}
rp, err := config.LoadReviewProfile(cf.reviewProfilePath)
if err != nil {
fmt.Fprintln(os.Stderr, "config:", err)
return 65
}
mp, err := config.LoadModelProfile(cf.modelProfilePath)
if err != nil {
fmt.Fprintln(os.Stderr, "config:", err)
return 65
}
outDir := resolveOutputDir(&cf, rp, repoPath)
res, err := pipeline.RunRepo(ctx, pipeline.Inputs{
RepoPath: repoPath,
ReviewProfile: rp,
ModelProfile: mp,
OutputDir: outDir,
EmitScrum: true,
})
if err != nil {
fmt.Fprintln(os.Stderr, "pipeline:", err)
return 65
}
for _, f := range res.OutputFiles {
fmt.Println(filepath.Join(outDir, f))
}
return res.ExitCode
}
+149
View File
@@ -0,0 +1,149 @@
// Package config loads the two YAML profiles documented in
// configs/{model,review}-profile.example.yaml. Both are optional —
// callers that don't pass --review-profile or --model-profile get
// DefaultReviewProfile / DefaultModelProfile.
//
// Defaults reflect the 2026-04-30 small-model-pipeline tier bump:
// local model is qwen3.5:latest, not qwen2.5-coder. The example
// YAML in configs/ still says qwen2.5-coder per PROMPT.md as
// originally authored — operators who copy the example get that;
// operators who skip the file get the current default.
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// ModelProfile mirrors configs/model-profile.example.yaml.
type ModelProfile struct {
Provider string `yaml:"provider"`
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
FallbackModel string `yaml:"fallback_model"`
TimeoutSeconds int `yaml:"timeout_seconds"`
Temperature float64 `yaml:"temperature"`
}
// ReviewProfile mirrors configs/review-profile.example.yaml.
// Toggles disable analyzers without code changes — review-profile
// drives noisy-check tuning per repo.
type ReviewProfile struct {
ProjectName string `yaml:"project_name"`
Mode string `yaml:"mode"`
SeverityThresholds struct {
FailOnCritical bool `yaml:"fail_on_critical"`
FailOnHigh bool `yaml:"fail_on_high"`
} `yaml:"severity_thresholds"`
StaticChecks struct {
HardcodedPaths bool `yaml:"hardcoded_paths"`
RawSQLInterpolation bool `yaml:"raw_sql_interpolation"`
ShellExecution bool `yaml:"shell_execution"`
BroadCORS bool `yaml:"broad_cors"`
SecretPatterns bool `yaml:"secret_patterns"`
LargeFiles bool `yaml:"large_files"`
TODOComments bool `yaml:"todo_comments"`
MissingTests bool `yaml:"missing_tests"`
} `yaml:"static_checks"`
Limits struct {
LargeFileLines int `yaml:"large_file_lines"`
MaxFileBytes int `yaml:"max_file_bytes"`
MaxLLMChunkChars int `yaml:"max_llm_chunk_chars"`
} `yaml:"limits"`
Reports struct {
OutputDir string `yaml:"output_dir"`
Markdown bool `yaml:"markdown"`
JSONReceipts bool `yaml:"json_receipts"`
} `yaml:"reports"`
Memory struct {
Enabled bool `yaml:"enabled"`
Path string `yaml:"path"`
AppendOnly bool `yaml:"append_only"`
} `yaml:"memory"`
}
// DefaultModelProfile reflects the current Lakehouse-Go local-tier
// default (qwen3.5:latest), not the qwen2.5-coder example file.
// PROMPT.md was authored 2026-04-29; tier bump landed 2026-04-30.
func DefaultModelProfile() ModelProfile {
return ModelProfile{
Provider: "ollama",
BaseURL: "http://localhost:11434",
Model: "qwen3.5:latest",
FallbackModel: "qwen3:latest",
TimeoutSeconds: 120,
Temperature: 0.1,
}
}
// DefaultReviewProfile turns every static check ON by default.
// review-profile.yaml in the target repo is how operators tune.
func DefaultReviewProfile() ReviewProfile {
p := ReviewProfile{
ProjectName: "review-harness",
Mode: "local-first",
}
p.SeverityThresholds.FailOnCritical = true
p.SeverityThresholds.FailOnHigh = false
p.StaticChecks.HardcodedPaths = true
p.StaticChecks.RawSQLInterpolation = true
p.StaticChecks.ShellExecution = true
p.StaticChecks.BroadCORS = true
p.StaticChecks.SecretPatterns = true
p.StaticChecks.LargeFiles = true
p.StaticChecks.TODOComments = true
p.StaticChecks.MissingTests = true
p.Limits.LargeFileLines = 800
p.Limits.MaxFileBytes = 1_000_000
p.Limits.MaxLLMChunkChars = 12000
p.Reports.OutputDir = "reports/latest"
p.Reports.Markdown = true
p.Reports.JSONReceipts = true
p.Memory.Enabled = true
p.Memory.Path = ".memory"
p.Memory.AppendOnly = true
return p
}
// LoadModelProfile reads YAML from path. Empty path returns defaults
// without error — operators don't need a profile to run.
func LoadModelProfile(path string) (ModelProfile, error) {
out := DefaultModelProfile()
if path == "" {
return out, nil
}
b, err := os.ReadFile(path)
if err != nil {
return out, fmt.Errorf("read model profile %s: %w", path, err)
}
if err := yaml.Unmarshal(b, &out); err != nil {
return out, fmt.Errorf("parse model profile %s: %w", path, err)
}
return out, nil
}
// LoadReviewProfile reads YAML from path. Empty path returns defaults.
// Partial files merge into defaults — unspecified fields stay at
// default values (yaml.v3 preserves pre-existing values for fields
// not present in the YAML).
func LoadReviewProfile(path string) (ReviewProfile, error) {
out := DefaultReviewProfile()
if path == "" {
return out, nil
}
b, err := os.ReadFile(path)
if err != nil {
return out, fmt.Errorf("read review profile %s: %w", path, err)
}
if err := yaml.Unmarshal(b, &out); err != nil {
return out, fmt.Errorf("parse review profile %s: %w", path, err)
}
return out, nil
}
+66
View File
@@ -0,0 +1,66 @@
// Package git wraps the subprocess `git` calls the harness needs.
// Every call gracefully degrades: a non-git directory returns
// HasGit=false rather than an error, so the pipeline can mark the
// git phase degraded without halting.
package git
import (
"context"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Info is the git metadata bundle for repo-intake.
type Info struct {
HasGit bool `json:"has_git"`
CurrentBranch string `json:"current_branch,omitempty"`
LatestCommit string `json:"latest_commit,omitempty"`
Status string `json:"status,omitempty"` // raw `git status -s` output
Errors []string `json:"errors,omitempty"`
}
// Inspect runs the read-only git probes. Times out after 5s per call
// so a hung git process can't stall the pipeline. Never mutates the
// target repo.
func Inspect(ctx context.Context, repoPath string) Info {
out := Info{}
abs, _ := filepath.Abs(repoPath)
gitDir := filepath.Join(abs, ".git")
if _, err := exec.LookPath("git"); err != nil {
out.Errors = append(out.Errors, "git binary not in PATH")
return out
}
// `.git` may be a file (worktree pointer) or a dir. `git rev-parse`
// is the canonical "is this a repo?" probe.
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
cmd := exec.CommandContext(cctx, "git", "-C", abs, "rev-parse", "--git-dir")
if err := cmd.Run(); err != nil {
// Only annotate when .git looked present — otherwise it's just
// a non-git target, not an error.
if _, statErr := exec.LookPath("git"); statErr == nil {
_ = gitDir
}
return out
}
out.HasGit = true
out.CurrentBranch = runGit(ctx, abs, "rev-parse", "--abbrev-ref", "HEAD")
out.LatestCommit = runGit(ctx, abs, "rev-parse", "HEAD")
out.Status = runGit(ctx, abs, "status", "-s")
return out
}
func runGit(ctx context.Context, dir string, args ...string) string {
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
full := append([]string{"-C", dir}, args...)
out, err := exec.CommandContext(cctx, "git", full...).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
+52
View File
@@ -0,0 +1,52 @@
// Package llm defines the model-provider abstraction. Phase A ships
// the interface only; Phase C adds the Ollama implementation.
//
// Provider interface mirrors PROMPT.md / docs/LOCAL_MODEL_SETUP.md:
// list_models()
// complete(prompt, options)
// complete_json(prompt, schema, options)
// health_check()
//
// Phase A's stub doctor uses this only for HealthCheck — the rest
// is wired in Phase C.
package llm
import "context"
// HealthStatus is what HealthCheck returns. Stable shape so the
// model-doctor JSON schema doesn't shift between phases.
type HealthStatus struct {
ServerAvailable bool `json:"server_available"`
PrimaryModelAvailable bool `json:"primary_model_available"`
FallbackModelAvailable bool `json:"fallback_model_available"`
BasicPromptOK bool `json:"basic_prompt_ok"`
JSONModeOK bool `json:"json_mode_ok"`
Errors []string `json:"errors"`
}
// CompleteOptions tunes a non-streaming completion call.
type CompleteOptions struct {
Temperature float64
MaxTokens int
TimeoutSeconds int
}
// Provider is the abstraction every model backend implements.
// G0 ships Ollama; OpenAI-compatible local endpoints land in
// Phase F+ when the harness needs them.
type Provider interface {
// Name returns the short identifier (e.g. "ollama").
Name() string
// HealthCheck probes server + primary + fallback model availability
// + a basic prompt + a JSON-mode probe. Used by `model doctor`.
HealthCheck(ctx context.Context, primaryModel, fallbackModel string) HealthStatus
// Complete performs a non-streaming completion. Phase C wires this.
Complete(ctx context.Context, model, prompt string, opts CompleteOptions) (string, error)
// CompleteJSON requests strict JSON output. Phase C wires this.
// Implementations should set Ollama's `format: "json"` or its
// upstream-equivalent constrained-decoding flag.
CompleteJSON(ctx context.Context, model, prompt string, opts CompleteOptions) (string, error)
}
+182
View File
@@ -0,0 +1,182 @@
// Package pipeline orchestrates the per-phase execution. Each phase
// produces JSON / markdown artifacts and a per-phase Receipt entry.
// Degraded mode propagates: if Phase C (LLM review) can't run, the
// pipeline still ships the static-scan deliverables and marks the
// LLM phase degraded — never silently skipped.
package pipeline
import (
"context"
"crypto/rand"
"encoding/hex"
"path/filepath"
"time"
"local-review-harness/internal/analyzers"
"local-review-harness/internal/config"
"local-review-harness/internal/git"
"local-review-harness/internal/reporters"
"local-review-harness/internal/scanner"
)
// Inputs is the bag the CLI passes to the pipeline.
type Inputs struct {
RepoPath string
ReviewProfile config.ReviewProfile
ModelProfile config.ModelProfile
OutputDir string
EmitScrum bool // true → also emit scrum-test/risk-register/sprint-backlog/acceptance-gates markdown
}
// Result is what the CLI shows the operator.
type Result struct {
OutputFiles []string
ExitCode int // 0=ok, 66=degraded, 65=runtime
}
// RunRepo executes Phase 0 (intake), Phase 1 (static), Phase 4 (report).
// Phases 2 (LLM) + 3 (validate) + 5 (memory) ship later — every phase
// not run lands in receipts as "skipped" or "degraded".
func RunRepo(ctx context.Context, in Inputs) (*Result, error) {
startedAt := time.Now().UTC()
runID := newRunID(startedAt)
res := &Result{ExitCode: 0}
receipt := reporters.Receipt{
RunID: runID,
RepoPath: in.RepoPath,
StartedAt: startedAt.Format(time.RFC3339Nano),
}
// --- Phase 0: repo intake ---
scan, err := scanner.Walk(in.RepoPath, true)
scanPhase := reporters.PhaseReceipt{Name: "repo_intake", Status: "ok"}
if err != nil {
scanPhase.Status = "failed"
scanPhase.Errors = append(scanPhase.Errors, err.Error())
receipt.Phases = append(receipt.Phases, scanPhase)
res.ExitCode = 65
// Even on scan failure, write the receipt so operators can
// see what blew up.
_ = writeReceipt(in.OutputDir, &receipt, startedAt, nil)
return res, err
}
gi := git.Inspect(ctx, in.RepoPath)
intake := reporters.BuildIntake(scan, gi)
intakePath := filepath.Join(in.OutputDir, "repo-intake.json")
if sha, err := reporters.WriteJSON(intakePath, intake); err != nil {
scanPhase.Status = "failed"
scanPhase.Errors = append(scanPhase.Errors, err.Error())
} else {
scanPhase.OutputFiles = []string{"repo-intake.json"}
scanPhase.OutputHash = sha
}
if !gi.HasGit {
scanPhase.Status = "degraded"
scanPhase.Errors = append(scanPhase.Errors, "no git metadata (not a git repo or git unavailable)")
if res.ExitCode == 0 {
res.ExitCode = 66
}
}
receipt.Phases = append(receipt.Phases, scanPhase)
res.OutputFiles = append(res.OutputFiles, "repo-intake.json")
// --- Phase 1: static scan ---
findings := analyzers.Run(scan, in.ReviewProfile)
staticOut := reporters.StaticFindings{
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
Findings: findings,
Summary: reporters.SummarizeFindings(findings),
}
staticPath := filepath.Join(in.OutputDir, "static-findings.json")
staticPhase := reporters.PhaseReceipt{Name: "static_scan", Status: "ok"}
if sha, err := reporters.WriteJSON(staticPath, staticOut); err != nil {
staticPhase.Status = "failed"
staticPhase.Errors = append(staticPhase.Errors, err.Error())
res.ExitCode = 65
} else {
staticPhase.OutputFiles = []string{"static-findings.json"}
staticPhase.OutputHash = sha
}
receipt.Phases = append(receipt.Phases, staticPhase)
res.OutputFiles = append(res.OutputFiles, "static-findings.json")
// --- Phase 2: LLM review (Phase C — not implemented in MVP) ---
receipt.Phases = append(receipt.Phases, reporters.PhaseReceipt{
Name: "llm_review", Status: "degraded",
Errors: []string{"Phase C not implemented in MVP — see PROMPT.md / docs/REVIEW_PIPELINE.md Phase 2"},
})
if res.ExitCode == 0 {
res.ExitCode = 66
}
llmDegraded := true
// --- Phase 3: validation (Phase D — also deferred) ---
receipt.Phases = append(receipt.Phases, reporters.PhaseReceipt{
Name: "validation", Status: "skipped",
Errors: []string{"Phase D not implemented in MVP — depends on Phase C"},
})
// --- Phase 4: report generation (markdown) ---
if in.EmitScrum {
reportPhase := reporters.PhaseReceipt{Name: "report_generation", Status: "ok"}
writers := []struct {
name string
fn func() error
}{
{"scrum-test.md", func() error {
return reporters.WriteScrumTest(filepath.Join(in.OutputDir, "scrum-test.md"), intake, findings, llmDegraded)
}},
{"risk-register.md", func() error {
return reporters.WriteRiskRegister(filepath.Join(in.OutputDir, "risk-register.md"), findings)
}},
{"claim-coverage-table.md", func() error {
return reporters.WriteClaimCoverage(filepath.Join(in.OutputDir, "claim-coverage-table.md"), findings)
}},
{"sprint-backlog.md", func() error {
return reporters.WriteSprintBacklog(filepath.Join(in.OutputDir, "sprint-backlog.md"), staticOut.Summary)
}},
{"acceptance-gates.md", func() error {
return reporters.WriteAcceptanceGates(filepath.Join(in.OutputDir, "acceptance-gates.md"), staticOut.Summary)
}},
}
for _, w := range writers {
if err := w.fn(); err != nil {
reportPhase.Status = "failed"
reportPhase.Errors = append(reportPhase.Errors, w.name+": "+err.Error())
res.ExitCode = 65
continue
}
reportPhase.OutputFiles = append(reportPhase.OutputFiles, w.name)
res.OutputFiles = append(res.OutputFiles, w.name)
}
receipt.Phases = append(receipt.Phases, reportPhase)
}
// --- Phase 5: memory (Phase E — deferred) ---
receipt.Phases = append(receipt.Phases, reporters.PhaseReceipt{
Name: "memory_update", Status: "skipped",
Errors: []string{"Phase E not implemented in MVP"},
})
// --- Receipt ---
receipt.Summary = staticOut.Summary
if err := writeReceipt(in.OutputDir, &receipt, startedAt, nil); err != nil {
return res, err
}
res.OutputFiles = append(res.OutputFiles, "receipts.json")
return res, nil
}
func writeReceipt(outputDir string, r *reporters.Receipt, startedAt time.Time, _ error) error {
r.FinishedAt = time.Now().UTC().Format(time.RFC3339Nano)
_ = startedAt // present for future timing fields
_, err := reporters.WriteJSON(filepath.Join(outputDir, "receipts.json"), r)
return err
}
func newRunID(t time.Time) string {
var rb [4]byte
_, _ = rand.Read(rb[:])
return t.UTC().Format("20060102T150405") + "-" + hex.EncodeToString(rb[:])
}
+152
View File
@@ -0,0 +1,152 @@
// Package reporters writes the human-readable + machine-readable
// outputs the pipeline produces. JSON shapes mirror docs/REPORT_SCHEMA.md
// and PROMPT.md verbatim — this package is the contract between the
// harness and any downstream consumer (CI gate, observer, MCP tool).
package reporters
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"time"
"local-review-harness/internal/analyzers"
"local-review-harness/internal/git"
"local-review-harness/internal/scanner"
)
// RepoIntake mirrors REVIEW_PIPELINE.md Phase 0 schema.
type RepoIntake struct {
RepoPath string `json:"repo_path"`
CurrentBranch string `json:"current_branch"`
LatestCommit string `json:"latest_commit"`
GitStatus string `json:"git_status"`
HasGit bool `json:"has_git"`
FileCount int `json:"file_count"`
LanguageBreakdown map[string]int `json:"language_breakdown"`
LargestFiles []LargestFile `json:"largest_files"`
DependencyManifests []string `json:"dependency_manifests"`
TestManifests []string `json:"test_manifests"`
GeneratedAt string `json:"generated_at"`
}
type LargestFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
Lines int `json:"lines,omitempty"`
}
// StaticFindings is the wrapper shape with summary counts.
type StaticFindings struct {
GeneratedAt string `json:"generated_at"`
Findings []analyzers.Finding `json:"findings"`
Summary FindingsSummary `json:"summary"`
}
type FindingsSummary struct {
Total int `json:"total"`
Confirmed int `json:"confirmed"`
Suspected int `json:"suspected"`
Rejected int `json:"rejected"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
BySource map[string]int `json:"by_source"`
ByCheck map[string]int `json:"by_check"`
}
// Receipt mirrors REPORT_SCHEMA.md "Receipt Schema". One per run.
type Receipt struct {
RunID string `json:"run_id"`
RepoPath string `json:"repo_path"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
Phases []PhaseReceipt `json:"phases"`
Summary FindingsSummary `json:"summary"`
}
type PhaseReceipt struct {
Name string `json:"name"`
Status string `json:"status"` // ok|degraded|failed|skipped
InputHash string `json:"input_hash,omitempty"`
OutputHash string `json:"output_hash,omitempty"`
OutputFiles []string `json:"output_files,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// BuildIntake assembles the Phase 0 intake JSON from the scanner +
// git probes. Doesn't write — the pipeline owns file I/O.
func BuildIntake(scan *scanner.Result, gi git.Info) RepoIntake {
largest := make([]LargestFile, 0, len(scan.LargestFiles))
for _, f := range scan.LargestFiles {
largest = append(largest, LargestFile{Path: f.Path, Size: f.Size, Lines: f.Lines})
}
return RepoIntake{
RepoPath: scan.RepoPath,
CurrentBranch: gi.CurrentBranch,
LatestCommit: gi.LatestCommit,
GitStatus: gi.Status,
HasGit: gi.HasGit,
FileCount: len(scan.Files),
LanguageBreakdown: scan.LanguageBreakdown,
LargestFiles: largest,
DependencyManifests: scan.DependencyManifests,
TestManifests: scan.TestManifests,
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
}
// SummarizeFindings is the canonical roll-up. Used by both the
// per-phase JSON and the receipt summary.
func SummarizeFindings(findings []analyzers.Finding) FindingsSummary {
out := FindingsSummary{
Total: len(findings),
BySource: map[string]int{},
ByCheck: map[string]int{},
}
for _, f := range findings {
switch f.Status {
case analyzers.StatusConfirmed:
out.Confirmed++
case analyzers.StatusSuspected:
out.Suspected++
case analyzers.StatusRejected:
out.Rejected++
}
switch f.Severity {
case analyzers.SeverityCritical:
out.Critical++
case analyzers.SeverityHigh:
out.High++
case analyzers.SeverityMedium:
out.Medium++
case analyzers.SeverityLow:
out.Low++
}
out.BySource[string(f.Source)]++
if f.CheckID != "" {
out.ByCheck[f.CheckID]++
}
}
return out
}
// WriteJSON marshals + writes; sha256 returned for receipt cross-link.
func WriteJSON(path string, v any) (sha string, err error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", err
}
bs, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
bs = append(bs, '\n')
if err := os.WriteFile(path, bs, 0o644); err != nil {
return "", err
}
h := sha256.Sum256(bs)
return hex.EncodeToString(h[:])[:16], nil
}
+336
View File
@@ -0,0 +1,336 @@
package reporters
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"local-review-harness/internal/analyzers"
)
// WriteScrumTest produces reports/latest/scrum-test.md per
// docs/SCRUM_TEST_TEMPLATE.md. Sections in fixed order so operators
// can grep section headers reliably.
func WriteScrumTest(path string, intake RepoIntake, findings []analyzers.Finding, llmDegraded bool) error {
summary := SummarizeFindings(findings)
var b strings.Builder
fmt.Fprintf(&b, "# Scrum Test — %s\n\n", filepath.Base(intake.RepoPath))
fmt.Fprintf(&b, "**Generated:** %s\n", intake.GeneratedAt)
fmt.Fprintf(&b, "**Branch:** %s · **Commit:** %s\n\n", coalesce(intake.CurrentBranch, "(no git)"), coalesce(intake.LatestCommit, "—"))
// Verdict per SCRUM_TEST_TEMPLATE.md — blunt, no soften.
fmt.Fprintln(&b, "## Verdict")
fmt.Fprintln(&b)
fmt.Fprintln(&b, verdict(summary, llmDegraded))
fmt.Fprintln(&b)
// Evidence
fmt.Fprintln(&b, "## Evidence")
fmt.Fprintln(&b)
fmt.Fprintf(&b, "- repo path: `%s`\n", intake.RepoPath)
fmt.Fprintf(&b, "- file count: %d\n", intake.FileCount)
if len(intake.LanguageBreakdown) > 0 {
fmt.Fprintf(&b, "- languages: %s\n", langSummary(intake.LanguageBreakdown))
}
fmt.Fprintf(&b, "- dependency manifests: %d (%s)\n", len(intake.DependencyManifests), strings.Join(firstN(intake.DependencyManifests, 5), ", "))
fmt.Fprintf(&b, "- test files/dirs: %d\n", len(intake.TestManifests))
if llmDegraded {
fmt.Fprintln(&b, "- LLM review: **skipped** (Phase C not implemented OR provider unavailable; see model-doctor.json)")
}
fmt.Fprintln(&b)
// Confirmed
fmt.Fprintln(&b, "## Confirmed Risks")
fmt.Fprintln(&b)
confirmed := filterByStatus(findings, analyzers.StatusConfirmed)
if len(confirmed) == 0 {
fmt.Fprintln(&b, "_No confirmed risks at static-scan level. (LLM review may surface more.)_")
} else {
writeFindingTable(&b, confirmed)
}
fmt.Fprintln(&b)
// Suspected
fmt.Fprintln(&b, "## Suspected Risks")
fmt.Fprintln(&b)
suspected := filterByStatus(findings, analyzers.StatusSuspected)
if len(suspected) == 0 {
fmt.Fprintln(&b, "_None._")
} else {
fmt.Fprintf(&b, "Each entry is a static-scan regex hit awaiting validation (Phase D / LLM cross-check).\n\n")
writeFindingTable(&b, suspected)
}
fmt.Fprintln(&b)
// Blocked
fmt.Fprintln(&b, "## Blocked Checks")
fmt.Fprintln(&b)
if llmDegraded {
fmt.Fprintln(&b, "- LLM review (Phase 2 in REVIEW_PIPELINE.md). Reason: provider unavailable or stub. Next command: `review-harness model doctor`")
} else {
fmt.Fprintln(&b, "_None._")
}
fmt.Fprintln(&b)
// Sprint backlog (per SCRUM_TEST_TEMPLATE.md fixed shape)
fmt.Fprintln(&b, "## Sprint Backlog")
fmt.Fprintln(&b)
writeSprintBacklog(&b, summary)
fmt.Fprintln(&b)
// Acceptance gates
fmt.Fprintln(&b, "## Acceptance Gates")
fmt.Fprintln(&b)
writeAcceptanceGates(&b, summary)
fmt.Fprintln(&b)
// Next commands
fmt.Fprintln(&b, "## Next Commands")
fmt.Fprintln(&b)
writeNextCommands(&b, summary, llmDegraded, intake.RepoPath)
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// WriteRiskRegister produces reports/latest/risk-register.md.
func WriteRiskRegister(path string, findings []analyzers.Finding) error {
var b strings.Builder
fmt.Fprintln(&b, "# Risk Register")
fmt.Fprintln(&b)
fmt.Fprintln(&b, "Findings ranked by severity. `Suspected` rows haven't been validated yet (Phase D).")
fmt.Fprintln(&b)
if len(findings) == 0 {
fmt.Fprintln(&b, "_No findings._")
return os.WriteFile(path, []byte(b.String()), 0o644)
}
sorted := sortBySeverity(findings)
fmt.Fprintln(&b, "| ID | Severity | Status | File | Line | Title |")
fmt.Fprintln(&b, "|---|---|---|---|---|---|")
for _, f := range sorted {
fmt.Fprintf(&b, "| `%s` | %s | %s | `%s` | %s | %s |\n",
f.ID, f.Severity, f.Status, mdEscape(f.File), coalesce(f.LineHint, "—"), mdEscape(f.Title))
}
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// WriteClaimCoverage produces reports/latest/claim-coverage-table.md.
// Phase B emits the table shape per REPORT_SCHEMA.md but the LLM-side
// claims aren't generated until Phase C.
func WriteClaimCoverage(path string, findings []analyzers.Finding) error {
var b strings.Builder
fmt.Fprintln(&b, "# Claim Coverage Table")
fmt.Fprintln(&b)
fmt.Fprintln(&b, "Each row is a finding paired with whether existing tests cover the affected area.")
fmt.Fprintln(&b, "Phase B emits this shape; LLM-side claim generation lands in Phase C.")
fmt.Fprintln(&b)
fmt.Fprintln(&b, "| Claim | Code Location | Existing Test | Missing Test | Risk |")
fmt.Fprintln(&b, "|---|---|---|---|---|")
if len(findings) == 0 {
fmt.Fprintln(&b, "| _no claims yet_ | — | — | — | — |")
}
for _, f := range findings {
fmt.Fprintf(&b, "| %s | `%s:%s` | _unknown_ | _likely_ | %s |\n",
mdEscape(f.Title), mdEscape(f.File), coalesce(f.LineHint, "?"), f.Severity)
}
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// WriteSprintBacklog produces reports/latest/sprint-backlog.md.
func WriteSprintBacklog(path string, summary FindingsSummary) error {
var b strings.Builder
fmt.Fprintln(&b, "# Sprint Backlog")
fmt.Fprintln(&b)
writeSprintBacklog(&b, summary)
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// WriteAcceptanceGates produces reports/latest/acceptance-gates.md.
func WriteAcceptanceGates(path string, summary FindingsSummary) error {
var b strings.Builder
fmt.Fprintln(&b, "# Acceptance Gates")
fmt.Fprintln(&b)
writeAcceptanceGates(&b, summary)
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// === helpers ===
func verdict(s FindingsSummary, llmDegraded bool) string {
switch {
case s.Critical > 0:
return "**blocked** — critical-severity finding present. See Confirmed Risks; rotate any leaked credentials, then re-run."
case s.High > 0 && s.Confirmed > 0:
return "**prototype-ready** — confirmed high-severity findings need fixes before production deploy."
case s.High > 0:
return "**prototype-ready** — high-severity findings are suspected (not confirmed); validation pass (Phase D) or LLM review (Phase C) needed before promoting verdict."
case s.Total == 0 && !llmDegraded:
return "**production-ready** — static scan + LLM review found no issues. Re-validate after every wave."
case s.Total == 0:
return "**prototype-ready** — static scan clean; LLM review degraded so production status not certified."
default:
return "**demo-only** — only low/medium-severity findings, mostly suspected. Reasonable to demo; production deploy needs the validator pass + missing-tests gap closed."
}
}
func writeSprintBacklog(b *strings.Builder, s FindingsSummary) {
// Per SCRUM_TEST_TEMPLATE.md fixed format.
fmt.Fprintln(b, "**Sprint 0 — Reproducibility Gate**")
fmt.Fprintln(b)
fmt.Fprintln(b, "- Wire `just verify` (or equivalent) to run the static checks before every commit/PR.")
fmt.Fprintln(b, "- Add a CI step that fails on `critical` findings.")
if s.Total > 0 {
fmt.Fprintf(b, "- Triage the %d findings emitted by this run; mark each as accepted / blocking / dismiss-with-reason.\n", s.Total)
}
fmt.Fprintln(b)
fmt.Fprintln(b, "**Sprint 1 — Trust Boundary Gate**")
fmt.Fprintln(b)
if s.High > 0 || s.Critical > 0 {
fmt.Fprintln(b, "- Resolve every `critical` and `high` finding before non-loopback deploy.")
}
fmt.Fprintln(b, "- Confirm auth posture for any mutation endpoint flagged as exposed.")
fmt.Fprintln(b, "- Replace raw SQL interpolation with parameterized queries.")
fmt.Fprintln(b)
fmt.Fprintln(b, "**Sprint 2 — Memory Correctness Gate**")
fmt.Fprintln(b)
fmt.Fprintln(b, "- (Phase E) Wire append-only `.memory/` writes for known-risks + fixed-patterns.")
fmt.Fprintln(b, "- Add a regression test that re-runs the harness and asserts no regression in confirmed-finding count.")
fmt.Fprintln(b)
fmt.Fprintln(b, "**Sprint 3 — Agent Loop Reality Gate**")
fmt.Fprintln(b)
fmt.Fprintln(b, "- (Phase C) Wire local-Ollama LLM review.")
fmt.Fprintln(b, "- (Phase D) Validator pass cross-checks every LLM finding against repo evidence.")
fmt.Fprintln(b)
fmt.Fprintln(b, "**Sprint 4 — Deployment Gate**")
fmt.Fprintln(b)
fmt.Fprintln(b, "- Ship the harness as a single static binary (`go build -o review-harness`).")
fmt.Fprintln(b, "- Document operator runbook (model setup, profile editing, output retention).")
}
func writeAcceptanceGates(b *strings.Builder, s FindingsSummary) {
fmt.Fprintln(b, "Each gate must be testable. Format: command + verifiable post-condition.")
fmt.Fprintln(b)
fmt.Fprintln(b, "1. **Reproducibility:** `review-harness repo .` exits 0; `reports/latest/repo-intake.json` exists with non-zero `file_count`.")
fmt.Fprintln(b, "2. **No false positives on a clean fixture:** `review-harness repo tests/fixtures/clean-repo` produces zero `confirmed` findings.")
fmt.Fprintln(b, "3. **Every documented static check fires on the insecure fixture:** `jq '[.findings[] | .check_id] | unique | length' reports/latest/static-findings.json` ≥ 8.")
fmt.Fprintln(b, "4. **Receipts are honest about degraded phases:** `jq '[.phases[] | select(.status == \"degraded\")]' reports/latest/receipts.json` lists every skipped/stubbed phase.")
if s.Critical > 0 {
fmt.Fprintln(b, "5. **Critical findings block production deploy:** at least one critical finding is currently present; resolve before deploy.")
}
}
func writeNextCommands(b *strings.Builder, s FindingsSummary, llmDegraded bool, repoPath string) {
if s.Critical > 0 {
fmt.Fprintln(b, "1. Open the risk register: `cat reports/latest/risk-register.md`")
fmt.Fprintln(b, "2. Triage every `critical` finding; rotate any leaked credentials immediately.")
}
if llmDegraded {
fmt.Fprintln(b, "- Probe the model provider: `review-harness model doctor`")
}
fmt.Fprintf(b, "- Re-run after fixes: `review-harness repo %s`\n", repoPath)
fmt.Fprintf(b, "- Generate the full Scrum bundle: `review-harness scrum %s`\n", repoPath)
}
func filterByStatus(findings []analyzers.Finding, st analyzers.Status) []analyzers.Finding {
out := []analyzers.Finding{}
for _, f := range findings {
if f.Status == st {
out = append(out, f)
}
}
return sortBySeverity(out)
}
// severityRank used for sorting tables — critical first.
func severityRank(s analyzers.Severity) int {
switch s {
case analyzers.SeverityCritical:
return 0
case analyzers.SeverityHigh:
return 1
case analyzers.SeverityMedium:
return 2
case analyzers.SeverityLow:
return 3
}
return 4
}
func sortBySeverity(findings []analyzers.Finding) []analyzers.Finding {
out := make([]analyzers.Finding, len(findings))
copy(out, findings)
sort.SliceStable(out, func(i, j int) bool {
ri, rj := severityRank(out[i].Severity), severityRank(out[j].Severity)
if ri != rj {
return ri < rj
}
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
return out[i].LineHint < out[j].LineHint
})
return out
}
func writeFindingTable(b *strings.Builder, findings []analyzers.Finding) {
fmt.Fprintln(b, "| Severity | File:Line | Title | Evidence |")
fmt.Fprintln(b, "|---|---|---|---|")
for _, f := range findings {
loc := mdEscape(f.File)
if f.LineHint != "" {
loc = fmt.Sprintf("%s:%s", mdEscape(f.File), f.LineHint)
}
fmt.Fprintf(b, "| %s | `%s` | %s | `%s` |\n",
f.Severity, loc, mdEscape(f.Title), mdEscape(f.Evidence))
}
}
func mdEscape(s string) string {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > 120 {
s = s[:120] + "…"
}
return s
}
func coalesce(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
func langSummary(m map[string]int) string {
type kv struct {
k string
v int
}
pairs := make([]kv, 0, len(m))
for k, v := range m {
pairs = append(pairs, kv{k, v})
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].v > pairs[j].v })
if len(pairs) > 5 {
pairs = pairs[:5]
}
parts := make([]string, 0, len(pairs))
for _, p := range pairs {
parts = append(parts, fmt.Sprintf("%s (%d)", p.k, p.v))
}
return strings.Join(parts, ", ")
}
func firstN(s []string, n int) []string {
if len(s) <= n {
return s
}
return s[:n]
}
+66
View File
@@ -0,0 +1,66 @@
package scanner
import (
"path/filepath"
"strings"
)
// detectLanguage returns a best-effort language label based on the
// file extension. Empty string for unknown — the caller treats those
// as opaque and may still scan them for patterns.
func detectLanguage(filename string) string {
switch strings.ToLower(filepath.Ext(filename)) {
case ".go":
return "Go"
case ".rs":
return "Rust"
case ".ts", ".tsx":
return "TypeScript"
case ".js", ".jsx", ".mjs", ".cjs":
return "JavaScript"
case ".py":
return "Python"
case ".java":
return "Java"
case ".kt":
return "Kotlin"
case ".rb":
return "Ruby"
case ".php":
return "PHP"
case ".swift":
return "Swift"
case ".c", ".h":
return "C"
case ".cpp", ".cc", ".cxx", ".hpp":
return "C++"
case ".cs":
return "C#"
case ".sh", ".bash":
return "Shell"
case ".sql":
return "SQL"
case ".yaml", ".yml":
return "YAML"
case ".toml":
return "TOML"
case ".json":
return "JSON"
case ".md", ".markdown":
return "Markdown"
case ".html", ".htm":
return "HTML"
case ".css", ".scss", ".sass", ".less":
return "CSS"
}
// Special filenames without extensions
switch strings.ToLower(filename) {
case "dockerfile", "containerfile":
return "Docker"
case "makefile", "gnumakefile":
return "Make"
case "justfile":
return "Justfile"
}
return ""
}
+53
View File
@@ -0,0 +1,53 @@
package scanner
import (
"path/filepath"
"strings"
)
// isManifest detects dependency / build manifests by basename.
// Used to populate Result.DependencyManifests for repo-intake.
func isManifest(name string) bool {
switch strings.ToLower(name) {
case "go.mod", "go.sum",
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb",
"cargo.toml", "cargo.lock",
"requirements.txt", "pyproject.toml", "poetry.lock", "pipfile", "pipfile.lock",
"gemfile", "gemfile.lock",
"composer.json", "composer.lock",
"pom.xml", "build.gradle", "build.gradle.kts",
"makefile", "justfile",
"dockerfile", "docker-compose.yml", "docker-compose.yaml",
"helm.yaml", "chart.yaml":
return true
}
return false
}
// isTestPath detects test files / dirs by path. A repo is "has tests"
// iff at least one returns true. Used both for repo-intake's
// test_manifests list and the missing-tests analyzer's threshold.
func isTestPath(rel string) bool {
low := strings.ToLower(rel)
// Directory-shaped signals
parts := strings.Split(filepath.ToSlash(low), "/")
for _, p := range parts {
if p == "tests" || p == "test" || p == "__tests__" || p == "spec" || p == "specs" {
return true
}
}
// File-shape signals
base := strings.ToLower(filepath.Base(rel))
if strings.HasSuffix(base, "_test.go") ||
strings.HasSuffix(base, ".test.ts") ||
strings.HasSuffix(base, ".test.tsx") ||
strings.HasSuffix(base, ".test.js") ||
strings.HasSuffix(base, ".spec.ts") ||
strings.HasSuffix(base, ".spec.js") {
return true
}
if strings.HasPrefix(base, "test_") && strings.HasSuffix(base, ".py") {
return true
}
return false
}
+167
View File
@@ -0,0 +1,167 @@
// Package scanner walks a repository tree, classifies files, and
// surfaces metadata for the analyzers + repo-intake report.
//
// Skip-list defaults to common build/dependency dirs that nobody
// wants to scan. Operators can extend via review-profile (Phase E).
package scanner
import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// SkipDirs is the default skip-list. Matches dir basenames anywhere
// in the tree (not just at root). Includes the harness's own output
// dir so review-on-self doesn't loop.
var SkipDirs = map[string]bool{
".git": true,
".hg": true,
".svn": true,
"node_modules": true,
"vendor": true,
"target": true, // Rust
"dist": true,
"build": true,
"__pycache__": true,
".venv": true,
"venv": true,
"bin": true, // golangLAKEHOUSE convention; harness's own too
".idea": true,
".vscode": true,
"reports": true, // harness's own output
}
// File is one entry in the scan result.
type File struct {
Path string // relative to repo root
Abs string // absolute path on disk
Size int64 // bytes
Lines int // 0 if not counted
Language string // best-effort, "" if unknown
}
// Result is the scan summary the analyzers + reporters consume.
type Result struct {
RepoPath string
Files []File
LanguageBreakdown map[string]int // count of files by language
LargestFiles []File // top 10 by size
DependencyManifests []string // relative paths to package.json / go.mod / etc
TestManifests []string // tests/ dirs, *_test.go, *.test.ts, etc
}
// Walk produces a Result for repoPath. Errors on missing dir; skipped
// dirs are silently filtered. countLines is true → reads each file
// for line counts (Phase B needs this; Phase A wires false for speed).
func Walk(repoPath string, countLines bool) (*Result, error) {
abs, err := filepath.Abs(repoPath)
if err != nil {
return nil, err
}
if st, err := os.Stat(abs); err != nil {
return nil, err
} else if !st.IsDir() {
return nil, fs.ErrInvalid
}
res := &Result{
RepoPath: abs,
LanguageBreakdown: map[string]int{},
}
walkErr := filepath.WalkDir(abs, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil // best-effort; permission errors etc. are silent
}
if d.IsDir() {
if SkipDirs[d.Name()] && p != abs {
return filepath.SkipDir
}
return nil
}
// Skip dotfiles at file level (.gitignore etc. are interesting,
// but most dotfiles are noise; Analyzers can opt back in).
if strings.HasPrefix(d.Name(), ".") && !interestingDotfile(d.Name()) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
rel, err := filepath.Rel(abs, p)
if err != nil {
rel = p
}
f := File{
Path: rel,
Abs: p,
Size: info.Size(),
Language: detectLanguage(d.Name()),
}
if countLines && info.Size() < 5_000_000 { // 5MB cap; massive files lose line precision but stay scannable
if n, err := countFileLines(p); err == nil {
f.Lines = n
}
}
res.Files = append(res.Files, f)
if f.Language != "" {
res.LanguageBreakdown[f.Language]++
}
if isManifest(d.Name()) {
res.DependencyManifests = append(res.DependencyManifests, rel)
}
if isTestPath(rel) {
res.TestManifests = append(res.TestManifests, rel)
}
return nil
})
if walkErr != nil {
return nil, walkErr
}
// Largest top-10 by size.
sorted := make([]File, len(res.Files))
copy(sorted, res.Files)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Size > sorted[j].Size })
if len(sorted) > 10 {
sorted = sorted[:10]
}
res.LargestFiles = sorted
// Stable order for downstream determinism.
sort.Strings(res.DependencyManifests)
sort.Strings(res.TestManifests)
return res, nil
}
// interestingDotfile lets a few well-known dotfiles through despite
// the leading-dot filter. Keeps the scan honest about config files
// that often hold the real risk (e.g. committed .env).
func interestingDotfile(name string) bool {
switch name {
case ".env", ".env.local", ".env.production",
".gitignore", ".dockerignore", ".github",
".review-rules.md", ".review-profile.yaml":
return true
}
return false
}
func countFileLines(path string) (int, error) {
b, err := os.ReadFile(path)
if err != nil {
return 0, err
}
if len(b) == 0 {
return 0, nil
}
n := strings.Count(string(b), "\n")
if !strings.HasSuffix(string(b), "\n") {
n++ // last line without trailing newline
}
return n, nil
}