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:
co-authored by
Claude Opus 4.7
commit
f3ee4722a8
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user