From 4fcf96286378b4deacbf7c45c257528535409f96 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:14:42 +0530 Subject: [PATCH 01/10] fix(crawler): replace mustParseCIDR panic with init-time validation Move private CIDR parsing to a package-level var initialized once at startup. A malformed CIDR entry is a programming error caught at init time with a clear panic message, rather than a runtime panic during a scan. Also eliminates repeated parsing on every isPrivateIP call. --- internal/crawler/crawler.go | 51 ++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/internal/crawler/crawler.go b/internal/crawler/crawler.go index 2f86f31..6091b50 100644 --- a/internal/crawler/crawler.go +++ b/internal/crawler/crawler.go @@ -556,37 +556,42 @@ func (c *Crawler) validateURL(rawURL string) error { return nil } +// privateCIDRs are the RFC 1918, loopback, link-local, and CGNAT ranges +// used for SSRF protection. Parsed once at init time; a malformed entry +// is a programming error caught at startup, not at scan time. +var privateCIDRs = func() []*net.IPNet { + cidrs := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "169.254.0.0/16", // link-local, incl. cloud metadata (169.254.169.254) + "100.64.0.0/10", // CGNAT shared address space + "::1/128", + "fc00::/7", + "fe80::/10", // IPv6 link-local + } + nets := make([]*net.IPNet, 0, len(cidrs)) + for _, s := range cidrs { + _, network, err := net.ParseCIDR(s) + if err != nil { + panic(fmt.Sprintf("inspect/crawler: invalid private CIDR %q: %v", s, err)) + } + nets = append(nets, network) + } + return nets +}() + // isPrivateIP checks if an IP is in a private/loopback range. func isPrivateIP(ip net.IP) bool { - privateRanges := []struct { - network *net.IPNet - }{ - {mustParseCIDR("10.0.0.0/8")}, - {mustParseCIDR("172.16.0.0/12")}, - {mustParseCIDR("192.168.0.0/16")}, - {mustParseCIDR("127.0.0.0/8")}, - {mustParseCIDR("169.254.0.0/16")}, // link-local, incl. cloud metadata (169.254.169.254) - {mustParseCIDR("100.64.0.0/10")}, // CGNAT shared address space - {mustParseCIDR("::1/128")}, - {mustParseCIDR("fc00::/7")}, - {mustParseCIDR("fe80::/10")}, // IPv6 link-local - } - for _, r := range privateRanges { - if r.network.Contains(ip) { + for _, network := range privateCIDRs { + if network.Contains(ip) { return true } } return false } -func mustParseCIDR(s string) *net.IPNet { - _, network, err := net.ParseCIDR(s) - if err != nil { - panic(err) - } - return network -} - func isRetryable(statusCode int) bool { return statusCode == 429 || statusCode == 500 || statusCode == 502 || statusCode == 503 || statusCode == 504 || statusCode == 0 From 92a77388031b494de1f31c3959d4acec3b276df4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:42:09 +0530 Subject: [PATCH 02/10] fix(scanner): validate directory in ScanDir before starting file server ScanDir now checks that the path exists and is a directory before passing it to crawler.ServeDir, providing clear error messages instead of cryptic file server failures. --- scanner.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scanner.go b/scanner.go index c9faba1..c40a52b 100644 --- a/scanner.go +++ b/scanner.go @@ -3,6 +3,7 @@ package inspect import ( "context" "fmt" + "os" "sort" "sync" "time" @@ -200,6 +201,13 @@ func (s *Scanner) Scan(ctx context.Context, target string) (*Report, error) { // ScanDir scans a local directory by starting a temporary file server. // Useful for auditing build output before deployment. func (s *Scanner) ScanDir(ctx context.Context, dir string) (*Report, error) { + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("inspect: cannot access directory %q: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("inspect: %q is not a directory", dir) + } srv, addr, err := crawler.ServeDir(ctx, dir) if err != nil { return nil, err From 4205d9277fad5805f9ad2971e61afda8dfd2e4a8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 21:04:22 +0530 Subject: [PATCH 03/10] feat(mcp): add inspect_checks and inspect_status MCP tools Add two new MCP tools: inspect_checks lists all 9 available audit checks with descriptions, and inspect_status returns server version and capabilities. This brings the MCP surface from 2 to 4 tools. --- mcp/server.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/mcp/server.go b/mcp/server.go index 727debb..3060d4e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -51,6 +51,16 @@ func (s *Server) registerTools() { mcplib.WithDescription("Scan a local directory of HTML files"), mcplib.WithString("path", mcplib.Required(), mcplib.Description("Local directory path")), ), s.handleScanDir) + + s.kit.AddTool(mcplib.NewTool( + "inspect_checks", + mcplib.WithDescription("List all available audit checks and what they detect"), + ), s.handleChecks) + + s.kit.AddTool(mcplib.NewTool( + "inspect_status", + mcplib.WithDescription("Return the inspect server version and capabilities"), + ), s.handleStatus) } func (s *Server) handleScan(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { @@ -84,3 +94,26 @@ func (s *Server) handleScanDir(ctx context.Context, req mcplib.CallToolRequest) } return mcpkit.JSONResult(report) } + +func (s *Server) handleChecks(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + checks := []map[string]string{ + {"name": "links", "description": "Broken links, redirect chains, and dead anchors"}, + {"name": "security", "description": "Security headers (CSP, HSTS, X-Frame-Options), mixed content"}, + {"name": "forms", "description": "Form accessibility, autocomplete, and sensitive field handling"}, + {"name": "a11y", "description": "Accessibility: alt text, ARIA roles, contrast, heading structure"}, + {"name": "perf", "description": "Performance: render-blocking resources, image optimization hints"}, + {"name": "seo", "description": "SEO: meta tags, canonical URLs, structured data"}, + {"name": "sri", "description": "Subresource Integrity hashes on external scripts and styles"}, + {"name": "aiready", "description": "AI-readiness: robots.txt, llms.txt, structured data for crawlers"}, + {"name": "reachability", "description": "URL reachability and HTTP status codes"}, + } + return mcpkit.JSONResult(map[string]interface{}{"checks": checks}) +} + +func (s *Server) handleStatus(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + return mcpkit.JSONResult(map[string]interface{}{ + "name": "inspect", + "version": inspect.Version, + "tools": []string{"inspect_scan", "inspect_scan_dir", "inspect_checks", "inspect_status"}, + }) +} From b63737c86ab1d73b326c4dba2e45636571182293 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 04:37:31 +0530 Subject: [PATCH 04/10] test: fix inspect/mcp tests for current tool set and ScanDir error behavior - TestProtocol_ToolsList: expect 4 tools (inspect_scan, inspect_scan_dir, inspect_checks, inspect_status) instead of 2, matching the current server registration. - TestHandleScanDir_NonexistentDir: ScanDir returns an error for nonexistent paths via os.Stat, so handleScanDir returns a tool-level error result. Updated the test to verify IsError and non-empty message instead of attempting to parse the error text as JSON. --- mcp/server_test.go | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/mcp/server_test.go b/mcp/server_test.go index 1ce3df2..a15c00e 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -200,19 +200,18 @@ func TestProtocol_ToolsList(t *testing.T) { t.Fatalf("unmarshal: %v", err) } - if len(result.Result.Tools) != 2 { - t.Fatalf("expected 2 tools, got %d", len(result.Result.Tools)) + if len(result.Result.Tools) != 4 { + t.Fatalf("expected 4 tools, got %d", len(result.Result.Tools)) } names := make(map[string]bool) for _, tool := range result.Result.Tools { names[tool.Name] = true } - if !names["inspect_scan"] { - t.Error("inspect_scan tool not found in tools/list") - } - if !names["inspect_scan_dir"] { - t.Error("inspect_scan_dir tool not found in tools/list") + for _, expected := range []string{"inspect_scan", "inspect_scan_dir", "inspect_checks", "inspect_status"} { + if !names[expected] { + t.Errorf("%s tool not found in tools/list", expected) + } } } @@ -489,18 +488,15 @@ func TestHandleScanDir_NonexistentDir(t *testing.T) { if result == nil { t.Fatal("expected result") } - // The ScanDir function starts a file server on the given directory. - // For a nonexistent path, the server may still start and serve 404s. - // The scanner reports these as findings, not as a tool error. + // ScanDir calls os.Stat on the path; a nonexistent directory returns an + // error, which handleScanDir surfaces as a tool-level error result. + if !result.IsError { + t.Fatal("expected tool-level error for nonexistent directory") + } for _, c := range result.Content { if tc, ok := c.(mcplib.TextContent); ok { - var report inspect.Report - if err := json.Unmarshal([]byte(tc.Text), &report); err != nil { - t.Fatalf("expected valid JSON report: %v", err) - } - // Report should exist with a target. - if report.Target == "" { - t.Error("expected non-empty target in report") + if tc.Text == "" { + t.Fatal("expected non-empty error message") } } } From eb5c2a402be34bc1576f8ee9d7301a658ffe299e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 10:51:26 +0530 Subject: [PATCH 05/10] test(mcp): add tools/call coverage for inspect_checks and inspect_status The two newest MCP tools were only verified by name in tools/list. Add end-to-end protocol tests that invoke each tool and assert the response payload shape: checks returns a non-empty audit catalog (spot-checking links/security/a11y), and status returns the server name, version, and tool list. --- mcp/server_test.go | 121 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/mcp/server_test.go b/mcp/server_test.go index a15c00e..8a060ee 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -640,5 +640,126 @@ func TestErrorHandling_UnknownMethod(t *testing.T) { } } +// --------------------------------------------------------------------------- +// inspect_checks and inspect_status tools +// --------------------------------------------------------------------------- + +func TestProtocol_ToolsCall_Checks(t *testing.T) { + ts, _ := newTestHTTPServer(t, inspect.Quick, inspect.WithAllowPrivateIPs()) + sid := initAndSession(t, ts) + + resp, err := postSessionJSON(ts.URL, sid, jsonRPCRequest(3, "tools/call", map[string]any{ + "name": "inspect_checks", + "arguments": map[string]any{}, + })) + if err != nil { + t.Fatalf("tools/call: %v", err) + } + + body := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + var rpcResp struct { + Result struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + } + if err := json.Unmarshal(body, &rpcResp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if rpcResp.Result.IsError { + t.Fatalf("expected success, got error: %s", rpcResp.Result.Content[0].Text) + } + if len(rpcResp.Result.Content) == 0 { + t.Fatal("expected at least one content item") + } + + // The text content should be a JSON object with a "checks" array. + var payload struct { + Checks []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"checks"` + } + if err := json.Unmarshal([]byte(rpcResp.Result.Content[0].Text), &payload); err != nil { + t.Fatalf("content is not valid JSON: %v\nraw: %s", err, rpcResp.Result.Content[0].Text) + } + if len(payload.Checks) == 0 { + t.Fatal("expected at least one audit check in the catalog") + } + + // Spot-check that a known check is present. + found := map[string]bool{} + for _, c := range payload.Checks { + found[c.Name] = true + } + for _, want := range []string{"links", "security", "a11y"} { + if !found[want] { + t.Errorf("expected %q check in catalog, not found", want) + } + } +} + +func TestProtocol_ToolsCall_Status(t *testing.T) { + ts, _ := newTestHTTPServer(t, inspect.Quick, inspect.WithAllowPrivateIPs()) + sid := initAndSession(t, ts) + + resp, err := postSessionJSON(ts.URL, sid, jsonRPCRequest(3, "tools/call", map[string]any{ + "name": "inspect_status", + "arguments": map[string]any{}, + })) + if err != nil { + t.Fatalf("tools/call: %v", err) + } + + body := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + var rpcResp struct { + Result struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + } + if err := json.Unmarshal(body, &rpcResp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if rpcResp.Result.IsError { + t.Fatalf("expected success, got error: %s", rpcResp.Result.Content[0].Text) + } + if len(rpcResp.Result.Content) == 0 { + t.Fatal("expected at least one content item") + } + + var payload struct { + Name string `json:"name"` + Version string `json:"version"` + Tools []string `json:"tools"` + } + if err := json.Unmarshal([]byte(rpcResp.Result.Content[0].Text), &payload); err != nil { + t.Fatalf("content is not valid JSON: %v\nraw: %s", err, rpcResp.Result.Content[0].Text) + } + if payload.Name != "inspect" { + t.Errorf("name: want inspect, got %q", payload.Name) + } + if payload.Version != inspect.Version { + t.Errorf("version: want %s, got %s", inspect.Version, payload.Version) + } + if len(payload.Tools) == 0 { + t.Error("expected at least one tool in status output") + } +} + // Note: additional tests for the mcp package live in server_more_test.go // (split out for file size/clarity). Shared test helpers remain in this file. From 8496d89e8fc7c457d6aea7e2a6c37449dbec1e4c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 12:31:13 +0530 Subject: [PATCH 06/10] feat: add quality graph module - Add qualitygraph/ package with quality analysis - Update README --- README.md | 12 ++ docs/architecture.md | 6 + go.mod | 4 +- go.sum | 2 - qualitygraph/projection.go | 216 ++++++++++++++++++++++++++++++++ qualitygraph/projection_test.go | 131 +++++++++++++++++++ 6 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 qualitygraph/projection.go create mode 100644 qualitygraph/projection_test.go diff --git a/README.md b/README.md index 2ce6ea6..2853e0f 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,18 @@ See the [examples/](examples/) directory for runnable code samples. See [docs/architecture.md](docs/architecture.md) for the package layout and data flow. +### Portable quality graph + +`qualitygraph.Build` projects a completed `inspect.Report` into the shared +`hawk-core-contracts/graph` vocabulary. The projection contains a quality node +for the report, bounded quality nodes for findings, `contains` edges, and +immutable observation events. + +The projection is metadata-only: scan targets, URLs, messages, DOM elements, +fixes, and evidence are represented by SHA-256 digests rather than copied into +graph attributes. Inspect remains the source of truth for scans and reports; +consumers such as Hawk may journal or compose the portable projection. + ## Ecosystem inspect is part of the hawk ecosystem: diff --git a/docs/architecture.md b/docs/architecture.md index b40348d..78d006c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,7 @@ inspect/ β”œβ”€β”€ sarif.go πŸ“Š GenerateSARIF β€” SARIF 2.1.0 output β”œβ”€β”€ browser.go 🌐 BrowserEngine interface + page-data types (no rod import) β”œβ”€β”€ browser_fetcher.go πŸ”Œ Adapts a BrowserEngine into the crawler's fetcher +β”œβ”€β”€ qualitygraph/ πŸ•ΈοΈ Privacy-safe shared quality graph projection β”œβ”€β”€ checks/ βœ… Built-in checks run against crawled responses β”‚ β”œβ”€β”€ headers.go πŸ“‹ Missing security headers (CSP, HSTS, …) β”‚ β”œβ”€β”€ cookies.go πŸͺ Cookie Secure/HttpOnly/SameSite flags @@ -165,6 +166,11 @@ and check, per-check durations) and a `FailOn` threshold; `Report.Failed()` and `Report.MaxSeverity()` summarize the run. `GenerateSARIF` converts findings to SARIF 2.1.0. +`qualitygraph.Build` is an explicit read-only projection boundary after a scan +completes. It maps the report and a bounded set of findings to shared quality +nodes, containment edges, and observation events. Sensitive report fields are +hashed, and the package neither runs scans nor persists graph state. + --- ## πŸ›‘οΈ Crawler Safeguards diff --git a/go.mod b/go.mod index 0955918..d1ca265 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/inspect go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.4 + github.com/GrayCodeAI/hawk-core-contracts v0.1.8 github.com/GrayCodeAI/hawk-mcpkit v0.1.4 github.com/mark3labs/mcp-go v0.49.0 golang.org/x/net v0.55.0 @@ -11,6 +11,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +replace github.com/GrayCodeAI/hawk-core-contracts => ../hawk-core-contracts + require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index da39a21..45ef1d6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -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-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/qualitygraph/projection.go b/qualitygraph/projection.go new file mode 100644 index 0000000..b236e9d --- /dev/null +++ b/qualitygraph/projection.go @@ -0,0 +1,216 @@ +// Package qualitygraph projects Inspect reports into the portable hawk-eco +// graph contract. Inspect remains the owner of scan execution and findings. +package qualitygraph + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + "github.com/GrayCodeAI/inspect" +) + +const ( + SchemaVersion = "inspect.graph/v1" + defaultLimit = 1000 +) + +// Export is a bounded, metadata-only quality graph projection. +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"` +} + +// Options supplies observation identity and tenancy scope. ObservedAt should be +// fixed by callers that require byte-for-byte deterministic exports. +type Options struct { + ObservedAt time.Time + Scope graphcontracts.Scope + CorrelationID string + ProducerVersion string + MaxFindings int +} + +// Build converts a completed Inspect report into a privacy-safe quality graph. +// URLs, targets, messages, elements, fixes, and evidence never enter attributes. +func Build(report *inspect.Report, opts Options) (*Export, error) { + if report == nil { + return nil, errors.New("qualitygraph: report is required") + } + observedAt := opts.ObservedAt.UTC() + if observedAt.IsZero() { + observedAt = time.Now().UTC() + } + limit := opts.MaxFindings + if limit <= 0 || limit > defaultLimit { + limit = defaultLimit + } + provenance := graphcontracts.Provenance{ + Producer: "inspect", + Version: strings.TrimSpace(opts.ProducerVersion), + } + + reportDigest := digest(strings.TrimSpace(report.Target), observedAt.Format(time.RFC3339Nano)) + reportRef := graphcontracts.Ref{ + Kind: graphcontracts.NodeQuality, + ID: "inspect/report/" + reportDigest, + } + provenance.SourceID = reportDigest + provenance.Evidence = []graphcontracts.ArtifactRef{{URI: "inspect://report/" + reportDigest}} + + selected := report.Findings + if len(selected) > limit { + selected = selected[:limit] + } + 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), + } + reportNode := graphcontracts.Node{ + ID: reportRef.ID, + Kind: reportRef.Kind, + Scope: opts.Scope, + CreatedAt: observedAt, + Provenance: provenance, + Attributes: map[string]string{ + "entity": "report", + "status": reportStatus(report), + "max_severity": report.MaxSeverity().String(), + "fail_on": report.FailOn.String(), + "pages_scanned": strconv.Itoa(report.Stats.PagesScanned), + "crawled_urls": strconv.Itoa(report.CrawledURLs), + "findings_total": strconv.Itoa(report.Stats.FindingsTotal), + "projected_findings": strconv.Itoa(len(selected)), + "truncated": strconv.FormatBool(len(report.Findings) > len(selected)), + "duration_ms": strconv.FormatInt(report.Duration.Milliseconds(), 10), + "target_digest": digest(strings.TrimSpace(report.Target)), + }, + } + if err := reportNode.Validate(); err != nil { + return nil, fmt.Errorf("qualitygraph: report node: %w", err) + } + export.Nodes = append(export.Nodes, reportNode) + export.Events = append(export.Events, observedEvent(reportRef, opts.Scope, observedAt, opts.CorrelationID, provenance)) + + for index, finding := range selected { + findingDigest := digest( + reportRef.ID, + strconv.Itoa(index), + finding.Check, + finding.Severity.String(), + finding.URL, + finding.Element, + finding.Message, + finding.Fix, + finding.Evidence, + ) + findingRef := graphcontracts.Ref{ + Kind: graphcontracts.NodeQuality, + ID: "inspect/finding/" + findingDigest, + } + findingProvenance := graphcontracts.Provenance{ + Producer: "inspect", + Version: strings.TrimSpace(opts.ProducerVersion), + SourceID: findingDigest, + Evidence: []graphcontracts.ArtifactRef{{URI: "inspect://finding/" + findingDigest}}, + } + node := graphcontracts.Node{ + ID: findingRef.ID, + Kind: findingRef.Kind, + Scope: opts.Scope, + CreatedAt: observedAt, + Provenance: findingProvenance, + Attributes: map[string]string{ + "entity": "finding", + "check": strings.TrimSpace(finding.Check), + "severity": finding.Severity.String(), + "url_digest": digest(strings.TrimSpace(finding.URL)), + "message_digest": digest(strings.TrimSpace(finding.Message)), + "element_digest": digest(strings.TrimSpace(finding.Element)), + "fix_digest": digest(strings.TrimSpace(finding.Fix)), + "evidence_digest": digest(strings.TrimSpace(finding.Evidence)), + }, + } + 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: "inspect/contains/" + digest(reportRef.ID, findingRef.ID), + Kind: graphcontracts.EdgeContains, + From: reportRef, + To: findingRef, + Scope: opts.Scope, + CreatedAt: observedAt, + Provenance: graphcontracts.Provenance{ + Producer: "inspect", + 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, observedEvent( + findingRef, opts.Scope, observedAt, opts.CorrelationID, 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 observedEvent( + subject graphcontracts.Ref, + scope graphcontracts.Scope, + observedAt time.Time, + correlationID string, + provenance graphcontracts.Provenance, +) graphcontracts.Event { + event := graphcontracts.Event{ + ID: "inspect/observed/" + digest(subject.ID, observedAt.Format(time.RFC3339Nano)), + Type: graphcontracts.EventObserved, + Subject: subject, + Scope: scope, + OccurredAt: observedAt, + CorrelationID: strings.TrimSpace(correlationID), + IdempotencyKey: digest(subject.ID, observedAt.Format(time.RFC3339Nano)), + Provenance: provenance, + } + return event +} + +func reportStatus(report *inspect.Report) string { + if report.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..48040a3 --- /dev/null +++ b/qualitygraph/projection_test.go @@ -0,0 +1,131 @@ +package qualitygraph_test + +import ( + "encoding/json" + "strings" + "testing" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + "github.com/GrayCodeAI/inspect" + "github.com/GrayCodeAI/inspect/qualitygraph" +) + +func TestBuildPrivacySafeDeterministicProjection(t *testing.T) { + t.Parallel() + + observedAt := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + report := &inspect.Report{ + Target: "https://private.example", + CrawledURLs: 2, + Duration: 1500 * time.Millisecond, + FailOn: inspect.SeverityMedium, + Stats: inspect.Stats{ + PagesScanned: 2, + FindingsTotal: 1, + }, + Findings: []inspect.Finding{{ + Check: "security", + Severity: inspect.SeverityHigh, + URL: "https://private.example/admin?token=secret", + Element: `
`, + Message: "sensitive finding message", + Fix: "private remediation", + Evidence: "private response evidence", + }}, + } + opts := qualitygraph.Options{ + ObservedAt: observedAt, + Scope: graphcontracts.Scope{RepositoryID: "inspect"}, + CorrelationID: "session-1", + } + + first, err := qualitygraph.Build(report, opts) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + second, err := qualitygraph.Build(report, 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 graph sizes: nodes=%d edges=%d events=%d", + len(first.Nodes), len(first.Edges), len(first.Events)) + } + if first.Edges[0].Kind != graphcontracts.EdgeContains { + t.Fatalf("edge kind = %q, want %q", first.Edges[0].Kind, graphcontracts.EdgeContains) + } + for _, node := range first.Nodes { + if err := node.Validate(); err != nil { + t.Fatalf("invalid node: %v", err) + } + } + for _, edge := range first.Edges { + if err := edge.Validate(); err != nil { + t.Fatalf("invalid edge: %v", err) + } + } + for _, event := range first.Events { + if err := event.Validate(); err != nil { + t.Fatalf("invalid event: %v", err) + } + } + + payload := string(firstJSON) + for _, secret := range []string{ + report.Target, + report.Findings[0].URL, + report.Findings[0].Element, + report.Findings[0].Message, + report.Findings[0].Fix, + report.Findings[0].Evidence, + } { + if strings.Contains(payload, secret) { + t.Fatalf("projection leaked sensitive value %q", secret) + } + } +} + +func TestBuildBoundsFindings(t *testing.T) { + t.Parallel() + + report := &inspect.Report{ + Target: "https://example.com", + Findings: []inspect.Finding{ + {Check: "a", Severity: inspect.SeverityLow}, + {Check: "b", Severity: inspect.SeverityHigh}, + }, + } + export, err := qualitygraph.Build(report, qualitygraph.Options{ + ObservedAt: time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC), + MaxFindings: 1, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if len(export.Nodes) != 2 { + t.Fatalf("nodes = %d, want report + one finding", len(export.Nodes)) + } + var reportNode *graphcontracts.Node + for i := range export.Nodes { + if strings.HasPrefix(export.Nodes[i].ID, "inspect/report/") { + reportNode = &export.Nodes[i] + } + } + if reportNode == nil || reportNode.Attributes["truncated"] != "true" { + t.Fatalf("expected truncated report node, got %+v", reportNode) + } +} + +func TestBuildRejectsNilReport(t *testing.T) { + t.Parallel() + + if _, err := qualitygraph.Build(nil, qualitygraph.Options{}); err == nil { + t.Fatal("expected nil report error") + } +} From d1b20f0dd3cee8f52ab64359ee28d7475b9ebdba Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 13:06:20 +0530 Subject: [PATCH 07/10] feat: add quality graph analysis - Add QualityGraph implementation - Support tracking code quality metrics and relationships - Add issue ranking and threshold tracking --- qualitygraph/quality_graph.go | 230 ++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 qualitygraph/quality_graph.go diff --git a/qualitygraph/quality_graph.go b/qualitygraph/quality_graph.go new file mode 100644 index 0000000..85b0639 --- /dev/null +++ b/qualitygraph/quality_graph.go @@ -0,0 +1,230 @@ +// Package qualitygraph provides graph-based code quality analysis. +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 +} + +// GetMetricsByType returns all metrics of a specific type. +func (g *QualityGraph) GetMetricsByType(metricType string) []QualityMetric { + g.mu.RLock() + defer g.mu.RUnlock() + result := []QualityMetric{} + for _, node := range g.nodes { + for _, metric := range node.Metrics { + if metric.Name == metricType { + result = append(result, metric) + } + } + } + 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, + } +} + +// Export exports the quality graph as a portable format. +func (g *QualityGraph) Export() map[string]interface{} { + g.mu.RLock() + defer g.mu.RUnlock() + + nodes := make([]map[string]interface{}, 0, len(g.nodes)) + for _, node := range g.nodes { + metrics := make([]map[string]interface{}, 0, len(node.Metrics)) + for _, m := range node.Metrics { + metrics = append(metrics, map[string]interface{}{ + "name": m.Name, + "value": m.Value, + "threshold": m.Threshold, + "status": m.Status, + }) + } + + nodes = append(nodes, map[string]interface{}{ + "id": node.ID, + "type": node.Type, + "path": node.Path, + "metrics": metrics, + "children": node.Children, + }) + } + + edges := make([]map[string]interface{}, 0, len(g.edges)) + for _, edge := range g.edges { + edges = append(edges, map[string]interface{}{ + "from": edge.From, + "to": edge.To, + "kind": edge.Kind, + "weight": edge.Weight, + }) + } + + return map[string]interface{}{ + "nodes": nodes, + "edges": edges, + } +} From 1301ff19f7750404d893166fdc39cb80326f2959 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 20:14:47 +0530 Subject: [PATCH 08/10] fix: resolve NodeTypeQuality reference and formatting - Add qualitygraph module (GraphSpec conversion uses NodeTypeQuality) - Fix gofmt formatting in quality_graph.go Co-Authored-By: Claude --- qualitygraph/quality_graph.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/qualitygraph/quality_graph.go b/qualitygraph/quality_graph.go index 85b0639..69e00d8 100644 --- a/qualitygraph/quality_graph.go +++ b/qualitygraph/quality_graph.go @@ -31,9 +31,9 @@ type QualityNode struct { // 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" + From string `json:"from"` + To string `json:"to"` + Kind string `json:"kind"` // "depends_on", "imports", "calls", "tests" Weight float64 `json:"weight"` } @@ -154,9 +154,9 @@ func (g *QualityGraph) ToGraphSpec() *graphcontracts.GraphSpec { 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)), + "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) @@ -180,10 +180,10 @@ func (g *QualityGraph) ToGraphSpec() *graphcontracts.GraphSpec { } return &graphcontracts.GraphSpec{ - ID: "quality-graph", - Name: "Code Quality Graph", - Nodes: nodes, - Edges: edges, + ID: "quality-graph", + Name: "Code Quality Graph", + Nodes: nodes, + Edges: edges, } } From 574f887805240e643de997211347da887d45d741 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 21:16:32 +0530 Subject: [PATCH 09/10] chore: bump hawk-core-contracts to v0.1.9 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d1ca265..2922fc8 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/inspect go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.8 + 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/net v0.55.0 From 14db3bc24a6058eab9f788d847ebb4b92d50c5d7 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 21:22:30 +0530 Subject: [PATCH 10/10] chore: remove local replace directive, use published v0.1.9 --- go.mod | 2 -- go.sum | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 2922fc8..26c8fd7 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -replace github.com/GrayCodeAI/hawk-core-contracts => ../hawk-core-contracts - require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 45ef1d6..e00a87b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +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=