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
170 lines
4.9 KiB
Go
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
|
|
}
|