Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

170 lines
4.9 KiB
Go

package analysis
import (
"fmt"
"strings"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
)
// GuardViolation describes a single guard rule violation.
type GuardViolation struct {
RuleName string `json:"rule_name"`
Kind string `json:"kind"`
Description string `json:"description"`
// Architecture-DSL fields. Populated only on layer / architecture
// rule violations; empty on the flat co-change / boundary kinds.
Violator string `json:"violator,omitempty"`
LayerFrom string `json:"layer_from,omitempty"`
LayerTo string `json:"layer_to,omitempty"`
EdgeType string `json:"edge_type,omitempty"`
// Severity tiers the violation for the change_contract verdict mapping:
// "error" → refuse, "warn" → warn, "info" → annotate. Stamped from the
// rule; empty means the consumer applies its own default.
Severity string `json:"severity,omitempty"`
}
// ruleSeverity normalises a configured severity, defaulting to "warn" so a
// rule advises until it explicitly opts into blocking ("error").
func ruleSeverity(s string) string {
if s == "" {
return "warn"
}
return strings.ToLower(s)
}
// matchesAnyGlob reports whether path p matches any of the (possibly **-using)
// globs — the except-list check shared by the guard and architecture families.
func matchesAnyGlob(p string, globs []string) bool {
for _, g := range globs {
if g != "" && globMatch(g, p) {
return true
}
}
return false
}
// EvaluateGuards checks the given guard rules against a set of changed symbol IDs
// and the graph's edge structure, returning any violations found.
//
// For "co-change" rules: reports a violation when the change set contains symbols
// whose file paths match the rule's Source prefix but none matching the Target prefix.
//
// For "boundary" rules: reports a violation when any changed symbol whose file path
// matches the Source prefix has outgoing call or reference edges to symbols whose
// file paths match the Target prefix.
func EvaluateGuards(g graph.Store, rules []config.GuardRule, changedSymbolIDs []string) []GuardViolation {
var violations []GuardViolation
// Pre-resolve changed symbols to nodes for efficient lookup.
changedNodes := make([]*graph.Node, 0, len(changedSymbolIDs))
for _, id := range changedSymbolIDs {
if n := g.GetNode(id); n != nil {
changedNodes = append(changedNodes, n)
}
}
for _, rule := range rules {
switch rule.Kind {
case "co-change":
violations = append(violations, evaluateCoChange(rule, changedNodes)...)
case "boundary":
violations = append(violations, evaluateBoundary(g, rule, changedNodes)...)
}
}
return violations
}
// evaluateCoChange checks whether the change set includes symbols from the source
// prefix but is missing symbols from the target prefix.
func evaluateCoChange(rule config.GuardRule, changedNodes []*graph.Node) []GuardViolation {
hasSource := false
hasTarget := false
for _, n := range changedNodes {
if matchesAnyGlob(n.FilePath, rule.Except) {
continue
}
if strings.HasPrefix(n.FilePath, rule.Source) {
hasSource = true
}
if strings.HasPrefix(n.FilePath, rule.Target) {
hasTarget = true
}
if hasSource && hasTarget {
return nil // both present, no violation
}
}
if hasSource && !hasTarget {
msg := rule.Message
if msg == "" {
msg = fmt.Sprintf("changes to %s require corresponding changes to %s", rule.Source, rule.Target)
}
return []GuardViolation{{
RuleName: rule.Name,
Kind: "co-change",
Description: msg,
Severity: ruleSeverity(rule.Severity),
}}
}
return nil
}
// evaluateBoundary checks whether any changed symbol in the source prefix has
// outgoing call or reference edges targeting symbols in the target prefix.
func evaluateBoundary(g graph.Store, rule config.GuardRule, changedNodes []*graph.Node) []GuardViolation {
var violations []GuardViolation
seen := make(map[string]bool)
for _, n := range changedNodes {
if !strings.HasPrefix(n.FilePath, rule.Source) {
continue
}
if matchesAnyGlob(n.FilePath, rule.Except) {
continue
}
outEdges := g.GetOutEdges(n.ID)
for _, edge := range outEdges {
if edge.Kind != graph.EdgeCalls && edge.Kind != graph.EdgeReferences {
continue
}
target := g.GetNode(edge.To)
if target == nil {
continue
}
if !strings.HasPrefix(target.FilePath, rule.Target) {
continue
}
// Deduplicate by source→target pair.
key := n.ID + "->" + target.ID
if seen[key] {
continue
}
seen[key] = true
msg := rule.Message
if msg == "" {
msg = fmt.Sprintf("%s must not directly reference %s", rule.Source, rule.Target)
}
violations = append(violations, GuardViolation{
RuleName: rule.Name,
Kind: "boundary",
Description: fmt.Sprintf("%s: %s %s %s", msg, n.ID, edge.Kind, target.ID),
Violator: n.ID,
Severity: ruleSeverity(rule.Severity),
})
}
}
return violations
}