diff --git a/README.md b/README.md index cd8aabb..f5866e1 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ for _, f := range result.Findings { See the [examples/](examples/) directory for runnable code samples. +## Portable quality graph + +`qualitygraph.Build` projects a completed `sight.Result` into bounded shared +quality nodes, report-to-finding `contains` edges, and observation events. +Source diffs, paths, report text, messages, fixes, and reasoning are retained +only as SHA-256 digests. Sight remains the review source of truth; consumers +such as Hawk may journal and compose the projection. + ## Provider Interface Implement the `Provider` interface to use any LLM: diff --git a/docs/architecture.md b/docs/architecture.md index 12fedda..2958c35 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,6 +29,7 @@ sight/ β”œβ”€β”€ reviewer.go πŸ”„ Reviewer: parallel concern orchestration β”œβ”€β”€ options.go βš™οΈ config, With* functions, presets β”œβ”€β”€ provider.go πŸ”Œ Provider interface (consumers implement) +β”œβ”€β”€ qualitygraph/ πŸ•ΈοΈ Privacy-safe shared quality graph projection β”œβ”€β”€ severity.go πŸ“Š Re-exports from hawk-core-contracts/types β”œβ”€β”€ static_rules.go πŸ›‘οΈ 30+ static analysis rules β”œβ”€β”€ taint_analysis.go πŸ”— SSA-based taint tracking @@ -44,6 +45,10 @@ sight/ └── output/ πŸ“Š SARIF and terminal formatters ``` +`qualitygraph.Build` is an explicit read-only boundary after review. It never +runs a review or persists graph state, and it excludes source and human-readable +review content from graph attributes. + --- ## πŸ“€ Public API diff --git a/go.mod b/go.mod index 9e09999..f36ba26 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/sight go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.4 + github.com/GrayCodeAI/hawk-core-contracts v0.1.9 github.com/GrayCodeAI/hawk-mcpkit v0.1.4 github.com/mark3labs/mcp-go v0.49.0 golang.org/x/tools v0.45.0 diff --git a/go.sum b/go.sum index 675e45c..12fb0dd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.4 h1:HRcuxvl5RMgqmF0Lt1YwHY84nGcUwXculXtnrE/8nLI= -github.com/GrayCodeAI/hawk-core-contracts v0.1.4/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.9 h1:uXX/gtNM+3kxSEzu+rZkHykzcEaAbASn1lmPyOGMXvc= +github.com/GrayCodeAI/hawk-core-contracts v0.1.9/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/hawk-mcpkit v0.1.4 h1:tlhZXKDbI679I7c1feeY/pzErFwndD+R2CQf9sqHAVE= github.com/GrayCodeAI/hawk-mcpkit v0.1.4/go.mod h1:C32HPDRqiDETbVbMIbOTvguek6KImpLCffJjet7sqck= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/incremental.go b/incremental.go index e4c4ace..fa2283c 100644 --- a/incremental.go +++ b/incremental.go @@ -12,6 +12,8 @@ import ( "strings" "sync" "time" + + sightcontext "github.com/GrayCodeAI/sight/internal/context" ) // IncrementalState tracks the last-reviewed commit SHA for incremental reviews. @@ -230,20 +232,6 @@ func gitRoot(ctx context.Context) (string, error) { return strings.TrimSpace(string(out)), nil } -// validateGitRef ensures a git ref contains no dangerous characters. -func validateGitRef(ref string) error { - if ref == "" { - return fmt.Errorf("empty git ref") - } - if ref[0] == '-' { - return fmt.Errorf("git ref %q starts with dash", ref) - } - if strings.ContainsAny(ref, ";&|$`(){}[]<>!#*?\n\r\x00\\ ") { - return fmt.Errorf("git ref %q contains forbidden characters", ref) - } - return nil -} - // gitDiffRange runs `git diff base...head` with a context timeout. func gitDiffRange(ctx context.Context, base, head string) (string, error) { // Default 30s timeout if context has no deadline @@ -253,15 +241,15 @@ func gitDiffRange(ctx context.Context, base, head string) (string, error) { defer cancel() } - if err := validateGitRef(base); err != nil { + if err := sightcontext.ValidateGitRef(base); err != nil { return "", fmt.Errorf("invalid base ref: %w", err) } - if err := validateGitRef(head); err != nil { + if err := sightcontext.ValidateGitRef(head); err != nil { return "", fmt.Errorf("invalid head ref: %w", err) } // Try three-dot syntax first (merge-base diff) - // #nosec G204 β€” base and head validated by validateGitRef above + // #nosec G204 β€” base and head validated by sightcontext.ValidateGitRef above out, err := exec.CommandContext(ctx, "git", "diff", base+"..."+head).Output() if err == nil { return string(out), nil diff --git a/internal/context/git.go b/internal/context/git.go index a8b7ef0..9b5f800 100644 --- a/internal/context/git.go +++ b/internal/context/git.go @@ -75,13 +75,13 @@ func FormatContext(contexts []FileContext) string { // DiffBase returns the diff of the current branch against a base branch. func DiffBase(base string) (string, error) { - if err := validateGitRef(base); err != nil { + if err := ValidateGitRef(base); err != nil { return "", err } - // #nosec G204 β€” base validated by validateGitRef + // #nosec G204 β€” base validated by ValidateGitRef out, err := exec.Command("git", "diff", base+"...HEAD").Output() if err != nil { - // #nosec G204 β€” base validated by validateGitRef + // #nosec G204 β€” base validated by ValidateGitRef out2, err2 := exec.Command("git", "diff", base).Output() if err2 != nil { return "", fmt.Errorf("git diff failed: %w", err) @@ -93,10 +93,10 @@ func DiffBase(base string) (string, error) { // ChangedFiles returns the list of files changed relative to a base. func ChangedFiles(base string) ([]string, error) { - if err := validateGitRef(base); err != nil { + if err := ValidateGitRef(base); err != nil { return nil, err } - // #nosec G204 β€” base validated by validateGitRef + // #nosec G204 β€” base validated by ValidateGitRef out, err := exec.Command("git", "diff", "--name-only", base+"...HEAD").Output() if err != nil { return nil, fmt.Errorf("git diff --name-only failed: %w", err) @@ -129,8 +129,8 @@ func Blame(file string, startLine, endLine int) (string, error) { return parseBlameAuthors(string(out)), nil } -// validateGitRef ensures a git ref (branch, tag, SHA) contains no dangerous characters. -func validateGitRef(ref string) error { +// ValidateGitRef ensures a git ref (branch, tag, SHA) contains no dangerous characters. +func ValidateGitRef(ref string) error { if ref == "" { return fmt.Errorf("empty git ref") } diff --git a/internal/context/git_validate_test.go b/internal/context/git_validate_test.go new file mode 100644 index 0000000..6646af9 --- /dev/null +++ b/internal/context/git_validate_test.go @@ -0,0 +1,55 @@ +package context + +import ( + "strings" + "testing" +) + +func TestValidateGitRef_ValidRefs(t *testing.T) { + t.Parallel() + valid := []string{ + "main", + "feature/my-branch", + "v1.2.3", + "release/2026.01", + "abc123def456", // hex SHA + "HEAD", + "HEAD~1", + "my_branch", + "fix-123", + } + for _, ref := range valid { + if err := ValidateGitRef(ref); err != nil { + t.Errorf("ValidateGitRef(%q) = %v, want nil", ref, err) + } + } +} + +func TestValidateGitRef_RejectsInvalid(t *testing.T) { + t.Parallel() + cases := []struct { + ref string + want string + }{ + {"", "empty git ref"}, + {"-force", "starts with dash"}, + {"main;rm -rf", "forbidden characters"}, + {"feat|cat /etc/passwd", "forbidden characters"}, + {"branch$(cmd)", "forbidden characters"}, + {"name with space", "forbidden characters"}, + {"back\\slash", "forbidden characters"}, + {"new\nline", "forbidden characters"}, + {"wild*card", "forbidden characters"}, + {"ques?tion", "forbidden characters"}, + } + for _, tc := range cases { + err := ValidateGitRef(tc.ref) + if err == nil { + t.Errorf("ValidateGitRef(%q) = nil, want error containing %q", tc.ref, tc.want) + continue + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("ValidateGitRef(%q) error = %q, want containing %q", tc.ref, err.Error(), tc.want) + } + } +} diff --git a/internal/graph/graph.go b/internal/graph/graph.go index e031026..d75b3c6 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -12,7 +12,6 @@ import ( "path/filepath" "sort" "sync" - "time" ) // Node represents a code unit in the dependency graph. @@ -211,8 +210,6 @@ func (g *DependencyGraph) GetAllDependents(uri string) []string { // Build parses a Go module and builds the dependency graph. func (g *DependencyGraph) Build(ctx context.Context, modulePath string) error { - start := time.Now() - fset := token.NewFileSet() packages, err := parser.ParseDir(fset, modulePath, nil, parser.ParseComments) if err != nil { @@ -245,7 +242,6 @@ func (g *DependencyGraph) Build(ctx context.Context, modulePath string) error { wg.Wait() close(sem) - fmt.Printf("Graph built in %v\n", time.Since(start)) return nil } diff --git a/internal/hook/hook.go b/internal/hook/hook.go index 9bb9e60..15fe895 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -121,8 +121,6 @@ func (d *Dispatcher) dispatch(hookType HookType, context string) error { if err != nil { return fmt.Errorf("hook %s failed: %w, output: %s", hook.Name, err, string(output)) } - - fmt.Printf("Hook %s executed successfully\n", hook.Name) } return nil diff --git a/internal/output/output.go b/internal/output/output.go index e16b071..8700805 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -147,161 +147,6 @@ func FormatJSON(findings []Finding) (string, error) { return string(out), nil } -// SARIF 2.1.0 output types (package-level so they can reference each other). - -type outputSarifLog struct { - Version string `json:"version"` - Schema string `json:"$schema"` - Runs []outputSarifRun `json:"runs"` -} - -type outputSarifRun struct { - Tool outputSarifTool `json:"tool"` - Results []outputSarifResult `json:"results"` -} - -type outputSarifTool struct { - Driver outputSarifDriver `json:"driver"` -} - -type outputSarifDriver struct { - Name string `json:"name"` - Version string `json:"version"` - InformationURI string `json:"informationUri,omitempty"` - Rules []outputSarifRule `json:"rules,omitempty"` -} - -type outputSarifRule struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - ShortDescription outputSarifMessage `json:"shortDescription"` -} - -type outputSarifResult struct { - RuleID string `json:"ruleId"` - Level string `json:"level"` - Message outputSarifMessage `json:"message"` - Locations []outputSarifLocation `json:"locations,omitempty"` -} - -type outputSarifMessage struct { - Text string `json:"text"` -} - -type outputSarifLocation struct { - PhysicalLocation *outputSarifPhysicalLocation `json:"physicalLocation,omitempty"` -} - -type outputSarifPhysicalLocation struct { - ArtifactLocation outputSarifArtifactLocation `json:"artifactLocation"` - Region *outputSarifRegion `json:"region,omitempty"` -} - -type outputSarifArtifactLocation struct { - URI string `json:"uri"` -} - -type outputSarifRegion struct { - StartLine int `json:"startLine,omitempty"` - EndLine int `json:"endLine,omitempty"` -} - -// FormatSARIF produces a SARIF 2.1.0 JSON report from findings. -func FormatSARIF(findings []Finding, version string) string { - if version == "" { - version = "dev" - } - - severityToLevel := func(s int) string { - switch { - case s >= 4: // Critical - return "error" - case s >= 3: // High - return "error" - case s >= 2: // Medium - return "warning" - case s >= 1: // Low - return "note" - default: // Info - return "none" - } - } - - ruleSet := make(map[string]bool) - var rules []outputSarifRule - for _, f := range findings { - id := f.Concern - if id == "" { - id = "unknown" - } - if ruleSet[id] { - continue - } - ruleSet[id] = true - rules = append(rules, outputSarifRule{ - ID: id, - Name: id, - ShortDescription: outputSarifMessage{Text: id + " check"}, - }) - } - - var results []outputSarifResult - for _, f := range findings { - id := f.Concern - if id == "" { - id = "unknown" - } - msg := f.Message - if f.Fix != "" { - msg += "\n\nFix: " + f.Fix - } - r := outputSarifResult{ - RuleID: id, - Level: severityToLevel(f.Severity), - Message: outputSarifMessage{Text: msg}, - } - if f.File != "" { - region := &outputSarifRegion{} - if f.Line > 0 { - region.StartLine = f.Line - } - if f.EndLine > 0 && f.EndLine != f.Line { - region.EndLine = f.EndLine - } - if region.StartLine == 0 { - region = nil - } - r.Locations = append(r.Locations, outputSarifLocation{ - PhysicalLocation: &outputSarifPhysicalLocation{ - ArtifactLocation: outputSarifArtifactLocation{URI: f.File}, - Region: region, - }, - }) - } - results = append(results, r) - } - - log := outputSarifLog{ - Version: "2.1.0", - Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", - Runs: []outputSarifRun{{ - Tool: outputSarifTool{Driver: outputSarifDriver{ - Name: "sight", - Version: version, - InformationURI: "https://github.com/GrayCodeAI/sight", - Rules: rules, - }}, - Results: results, - }}, - } - - data, err := json.MarshalIndent(log, "", " ") - if err != nil { - return `{"error": "failed to generate SARIF"}` - } - return string(data) -} - // FormatGitHubReview formats all findings as a single GitHub PR review body. func FormatGitHubReview(findings []Finding) string { if len(findings) == 0 { diff --git a/qualitygraph/projection.go b/qualitygraph/projection.go new file mode 100644 index 0000000..f9a9ac2 --- /dev/null +++ b/qualitygraph/projection.go @@ -0,0 +1,181 @@ +// Package qualitygraph projects Sight review results into the portable +// hawk-eco graph contract without retaining source or review content. +package qualitygraph + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + "github.com/GrayCodeAI/sight" +) + +const ( + SchemaVersion = "sight.graph/v1" + maxFindings = 1000 +) + +type Export struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + Scope graphcontracts.Scope `json:"scope,omitempty"` + Nodes []graphcontracts.Node `json:"nodes"` + Edges []graphcontracts.Edge `json:"edges"` + Events []graphcontracts.Event `json:"events"` +} + +type Options struct { + ObservedAt time.Time + Scope graphcontracts.Scope + CorrelationID string + ProducerVersion string + Source string + MaxFindings int +} + +// Build creates a bounded, deterministic projection when ObservedAt is fixed. +func Build(result *sight.Result, opts Options) (*Export, error) { + if result == nil { + return nil, errors.New("qualitygraph: result is required") + } + observedAt := opts.ObservedAt.UTC() + if observedAt.IsZero() { + observedAt = time.Now().UTC() + } + limit := opts.MaxFindings + if limit <= 0 || limit > maxFindings { + limit = maxFindings + } + selected := result.Findings + if len(selected) > limit { + selected = selected[:limit] + } + sourceDigest := digest(opts.Source) + resultDigest := digest(sourceDigest, observedAt.Format(time.RFC3339Nano)) + resultRef := graphcontracts.Ref{Kind: graphcontracts.NodeQuality, ID: "sight/review/" + resultDigest} + provenance := graphcontracts.Provenance{ + Producer: "sight", + Version: strings.TrimSpace(opts.ProducerVersion), + SourceID: resultDigest, + Evidence: []graphcontracts.ArtifactRef{{URI: "sight://review/" + resultDigest}}, + } + export := &Export{ + SchemaVersion: SchemaVersion, + GeneratedAt: observedAt, + Scope: opts.Scope, + Nodes: make([]graphcontracts.Node, 0, len(selected)+1), + Edges: make([]graphcontracts.Edge, 0, len(selected)), + Events: make([]graphcontracts.Event, 0, len(selected)+1), + } + resultNode := graphcontracts.Node{ + ID: resultRef.ID, Kind: resultRef.Kind, Scope: opts.Scope, + CreatedAt: observedAt, Provenance: provenance, + Attributes: map[string]string{ + "entity": "report", + "status": status(result), + "max_severity": result.MaxSeverity().String(), + "fail_on": result.FailOn.String(), + "files_reviewed": strconv.Itoa(result.Stats.FilesReviewed), + "hunks_analyzed": strconv.Itoa(result.Stats.HunksAnalyzed), + "findings_total": strconv.Itoa(result.Stats.FindingsTotal), + "projected_findings": strconv.Itoa(len(selected)), + "truncated": strconv.FormatBool(len(result.Findings) > len(selected)), + "tokens_used": strconv.Itoa(result.Stats.TokensUsed), + "average_confidence": strconv.FormatFloat(result.Stats.AverageConfidence, 'f', -1, 64), + "high_confidence_count": strconv.Itoa(result.Stats.HighConfidenceCount), + "low_confidence_count": strconv.Itoa(result.Stats.LowConfidenceCount), + "source_digest": sourceDigest, + "report_digest": digest(result.Report), + }, + } + if err := resultNode.Validate(); err != nil { + return nil, fmt.Errorf("qualitygraph: review node: %w", err) + } + export.Nodes = append(export.Nodes, resultNode) + export.Events = append(export.Events, observed(resultRef, opts, observedAt, provenance)) + + for index, finding := range selected { + findingDigest := digest( + resultRef.ID, strconv.Itoa(index), finding.Concern, finding.Severity.String(), + finding.File, strconv.Itoa(finding.Line), strconv.Itoa(finding.EndLine), + finding.Message, finding.Fix, finding.Reasoning, finding.CWE, + ) + ref := graphcontracts.Ref{Kind: graphcontracts.NodeQuality, ID: "sight/finding/" + findingDigest} + findingProvenance := graphcontracts.Provenance{ + Producer: "sight", Version: strings.TrimSpace(opts.ProducerVersion), + SourceID: findingDigest, + Evidence: []graphcontracts.ArtifactRef{{URI: "sight://finding/" + findingDigest}}, + } + node := graphcontracts.Node{ + ID: ref.ID, Kind: ref.Kind, Scope: opts.Scope, + CreatedAt: observedAt, Provenance: findingProvenance, + Attributes: map[string]string{ + "entity": "finding", + "concern": strings.TrimSpace(finding.Concern), + "severity": finding.Severity.String(), + "line": strconv.Itoa(finding.Line), + "end_line": strconv.Itoa(finding.EndLine), + "confidence": strconv.FormatFloat(finding.Confidence, 'f', -1, 64), + "cwe": strings.TrimSpace(finding.CWE), + "sast_source": strconv.FormatBool(finding.SASTSource), + "file_digest": digest(finding.File), + "message_digest": digest(finding.Message), + "fix_digest": digest(finding.Fix), + "reasoning_digest": digest(finding.Reasoning), + }, + } + if err := node.Validate(); err != nil { + return nil, fmt.Errorf("qualitygraph: finding[%d] node: %w", index, err) + } + export.Nodes = append(export.Nodes, node) + edge := graphcontracts.Edge{ + ID: "sight/contains/" + digest(resultRef.ID, ref.ID), Kind: graphcontracts.EdgeContains, + From: resultRef, To: ref, Scope: opts.Scope, CreatedAt: observedAt, + Provenance: graphcontracts.Provenance{ + Producer: "sight", Version: strings.TrimSpace(opts.ProducerVersion), SourceID: findingDigest, + }, + } + if err := edge.Validate(); err != nil { + return nil, fmt.Errorf("qualitygraph: finding[%d] edge: %w", index, err) + } + export.Edges = append(export.Edges, edge) + export.Events = append(export.Events, observed(ref, opts, observedAt, findingProvenance)) + } + sort.Slice(export.Nodes, func(i, j int) bool { return export.Nodes[i].ID < export.Nodes[j].ID }) + sort.Slice(export.Edges, func(i, j int) bool { return export.Edges[i].ID < export.Edges[j].ID }) + sort.Slice(export.Events, func(i, j int) bool { return export.Events[i].ID < export.Events[j].ID }) + return export, nil +} + +func observed(ref graphcontracts.Ref, opts Options, at time.Time, provenance graphcontracts.Provenance) graphcontracts.Event { + return graphcontracts.Event{ + ID: "sight/observed/" + digest(ref.ID, at.Format(time.RFC3339Nano)), + Type: graphcontracts.EventObserved, Subject: ref, Scope: opts.Scope, OccurredAt: at, + CorrelationID: strings.TrimSpace(opts.CorrelationID), + IdempotencyKey: digest(ref.ID, at.Format(time.RFC3339Nano)), + Provenance: provenance, + } +} + +func status(result *sight.Result) string { + if result.Failed() { + return "failed" + } + return "passed" +} + +func digest(parts ...string) string { + hash := sha256.New() + for _, part := range parts { + _, _ = hash.Write([]byte(strconv.Itoa(len(part)))) + _, _ = hash.Write([]byte{':'}) + _, _ = hash.Write([]byte(part)) + } + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/qualitygraph/projection_test.go b/qualitygraph/projection_test.go new file mode 100644 index 0000000..9e12df1 --- /dev/null +++ b/qualitygraph/projection_test.go @@ -0,0 +1,65 @@ +package qualitygraph_test + +import ( + "encoding/json" + "strings" + "testing" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + "github.com/GrayCodeAI/sight" + "github.com/GrayCodeAI/sight/qualitygraph" +) + +func TestBuildPrivacySafeDeterministicProjection(t *testing.T) { + t.Parallel() + at := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + result := &sight.Result{ + Report: "private review report", FailOn: sight.SeverityMedium, + Stats: sight.Stats{FilesReviewed: 2, FindingsTotal: 1, TokensUsed: 42}, + Findings: []sight.Finding{{ + Concern: "security", Severity: sight.SeverityHigh, + File: "/private/repo/main.go", Line: 12, EndLine: 14, + Message: "private message", Fix: "private fix", Reasoning: "private reasoning", + CWE: "CWE-22", Confidence: .91, SASTSource: true, + }}, + } + opts := qualitygraph.Options{ + ObservedAt: at, Scope: graphcontracts.Scope{RepositoryID: "repo"}, + CorrelationID: "session-1", Source: "private source diff", + } + first, err := qualitygraph.Build(result, opts) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + second, err := qualitygraph.Build(result, opts) + if err != nil { + t.Fatalf("second Build() error = %v", err) + } + firstJSON, _ := json.Marshal(first) + secondJSON, _ := json.Marshal(second) + if string(firstJSON) != string(secondJSON) { + t.Fatal("projection is not deterministic") + } + if len(first.Nodes) != 2 || len(first.Edges) != 1 || len(first.Events) != 2 { + t.Fatalf("unexpected sizes: nodes=%d edges=%d events=%d", len(first.Nodes), len(first.Edges), len(first.Events)) + } + for _, secret := range []string{ + result.Report, result.Findings[0].File, result.Findings[0].Message, + result.Findings[0].Fix, result.Findings[0].Reasoning, opts.Source, + } { + if strings.Contains(string(firstJSON), secret) { + t.Fatalf("projection leaked %q", secret) + } + } + if first.Edges[0].Kind != graphcontracts.EdgeContains { + t.Fatalf("edge kind = %q", first.Edges[0].Kind) + } +} + +func TestBuildRejectsNilResult(t *testing.T) { + t.Parallel() + if _, err := qualitygraph.Build(nil, qualitygraph.Options{}); err == nil { + t.Fatal("expected nil result error") + } +} diff --git a/qualitygraph/quality_graph.go b/qualitygraph/quality_graph.go new file mode 100644 index 0000000..24c7859 --- /dev/null +++ b/qualitygraph/quality_graph.go @@ -0,0 +1,173 @@ +// Package qualitygraph provides graph-based code quality analysis for sight. +package qualitygraph + +import ( + "fmt" + "sort" + "sync" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" +) + +// QualityMetric represents a code quality metric. +type QualityMetric struct { + Name string `json:"name"` + Value float64 `json:"value"` + Threshold float64 `json:"threshold"` + Status string `json:"status"` // "pass", "warn", "fail" + UpdatedAt time.Time `json:"updated_at"` +} + +// QualityNode represents a code element with quality metrics. +type QualityNode struct { + ID string `json:"id"` + Type string `json:"type"` // "file", "function", "module", "package" + Path string `json:"path"` + Metrics []QualityMetric `json:"metrics"` + Children []string `json:"children,omitempty"` + Attrs map[string]interface{} `json:"attrs,omitempty"` +} + +// QualityEdge represents a quality relationship between code elements. +type QualityEdge struct { + From string `json:"from"` + To string `json:"to"` + Kind string `json:"kind"` // "depends_on", "imports", "calls", "tests" + Weight float64 `json:"weight"` +} + +// QualityGraph represents a code quality graph. +type QualityGraph struct { + mu sync.RWMutex + nodes map[string]*QualityNode + edges []QualityEdge +} + +// NewQualityGraph creates a new quality graph. +func NewQualityGraph() *QualityGraph { + return &QualityGraph{ + nodes: make(map[string]*QualityNode), + } +} + +// AddNode adds a quality node to the graph. +func (g *QualityGraph) AddNode(node *QualityNode) { + g.mu.Lock() + defer g.mu.Unlock() + g.nodes[node.ID] = node +} + +// AddEdge adds a quality edge to the graph. +func (g *QualityGraph) AddEdge(edge QualityEdge) { + g.mu.Lock() + defer g.mu.Unlock() + g.edges = append(g.edges, edge) +} + +// GetNode retrieves a node by ID. +func (g *QualityGraph) GetNode(id string) (*QualityNode, bool) { + g.mu.RLock() + defer g.mu.RUnlock() + node, ok := g.nodes[id] + return node, ok +} + +// GetNodes returns all nodes. +func (g *QualityGraph) GetNodes() []*QualityNode { + g.mu.RLock() + defer g.mu.RUnlock() + result := make([]*QualityNode, 0, len(g.nodes)) + for _, node := range g.nodes { + result = append(result, node) + } + return result +} + +// GetEdges returns all edges. +func (g *QualityGraph) GetEdges() []QualityEdge { + g.mu.RLock() + defer g.mu.RUnlock() + return g.edges +} + +// FindByPath finds a node by its file path. +func (g *QualityGraph) FindByPath(path string) []*QualityNode { + g.mu.RLock() + defer g.mu.RUnlock() + result := []*QualityNode{} + for _, node := range g.nodes { + if node.Path == path { + result = append(result, node) + } + } + return result +} + +// GetFailedMetrics returns all metrics that failed their threshold. +func (g *QualityGraph) GetFailedMetrics() []QualityMetric { + g.mu.RLock() + defer g.mu.RUnlock() + result := []QualityMetric{} + for _, node := range g.nodes { + for _, metric := range node.Metrics { + if metric.Status == "fail" { + result = append(result, metric) + } + } + } + return result +} + +// GetTopIssues returns the top N issues by severity. +func (g *QualityGraph) GetTopIssues(n int) []QualityMetric { + failed := g.GetFailedMetrics() + sort.Slice(failed, func(i, j int) bool { + return failed[i].Value > failed[j].Value + }) + if len(failed) > n { + failed = failed[:n] + } + return failed +} + +// ToGraphSpec converts the quality graph to a portable graph spec. +func (g *QualityGraph) ToGraphSpec() *graphcontracts.GraphSpec { + g.mu.RLock() + defer g.mu.RUnlock() + + nodes := make([]graphcontracts.NodeSpec, 0, len(g.nodes)) + for id, node := range g.nodes { + config := map[string]string{ + "path": node.Path, + "type": node.Type, + "metrics": fmt.Sprintf("%d", len(node.Metrics)), + } + for _, m := range node.Metrics { + config["metric_"+m.Name] = fmt.Sprintf("%.2f", m.Value) + } + + nodes = append(nodes, graphcontracts.NodeSpec{ + ID: id, + Type: graphcontracts.NodeTypeQuality, + Name: node.Path, + Config: config, + }) + } + + edges := make([]graphcontracts.EdgeSpec, 0, len(g.edges)) + for _, edge := range g.edges { + edges = append(edges, graphcontracts.EdgeSpec{ + From: edge.From, + To: edge.To, + Weight: edge.Weight, + }) + } + + return &graphcontracts.GraphSpec{ + ID: "quality-graph", + Name: "Code Quality Graph", + Nodes: nodes, + Edges: edges, + } +} diff --git a/reviewer.go b/reviewer.go index 52544bb..8731868 100644 --- a/reviewer.go +++ b/reviewer.go @@ -476,7 +476,13 @@ func matchesExclude(path string, patterns []string) bool { // Check if the pattern contains a path separator β€” if so, match // against the full path; otherwise match against the basename. if strings.Contains(pattern, "/") { - if matched, _ := filepath.Match(pattern, path); matched { + matched, err := filepath.Match(pattern, path) + if err != nil { + // Malformed glob pattern β€” skip it rather than silently + // failing to match. + continue + } + if matched { return true } } else { @@ -485,7 +491,11 @@ func matchesExclude(path string, patterns []string) bool { return true } // Glob match on basename - if matched, _ := filepath.Match(pattern, base); matched { + matched, err := filepath.Match(pattern, base) + if err != nil { + continue + } + if matched { return true } }