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
325 lines
9.1 KiB
Go
325 lines
9.1 KiB
Go
package analysis
|
|
|
|
import (
|
|
"fmt"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// EvaluateArchitecture checks the declarative architecture DSL — named
|
|
// layers with directional allow/deny constraints — against a set of
|
|
// changed symbols.
|
|
//
|
|
// For each changed symbol it resolves the symbol's layer, walks its
|
|
// outgoing call / reference edges, resolves each target's layer, and
|
|
// reports a violation when a cross-layer dependency breaks the source
|
|
// layer's allow/deny rules. Symbols in no declared layer, and edges
|
|
// to such symbols, are unconstrained.
|
|
func EvaluateArchitecture(g graph.Store, arch config.ArchitectureConfig, changedSymbolIDs []string) []GuardViolation {
|
|
if g == nil || arch.IsEmpty() {
|
|
return nil
|
|
}
|
|
names := sortedLayerNames(arch.Layers)
|
|
|
|
var violations []GuardViolation
|
|
seen := make(map[string]bool)
|
|
for _, id := range changedSymbolIDs {
|
|
n := g.GetNode(id)
|
|
if n == nil {
|
|
continue
|
|
}
|
|
fromLayer := layerOf(effectivePath(n), arch.Layers, names)
|
|
if fromLayer == "" {
|
|
continue
|
|
}
|
|
for _, e := range g.GetOutEdges(id) {
|
|
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
|
|
continue
|
|
}
|
|
target := g.GetNode(e.To)
|
|
if target == nil {
|
|
continue
|
|
}
|
|
toLayer := layerOf(effectivePath(target), arch.Layers, names)
|
|
if toLayer == "" || toLayer == fromLayer {
|
|
continue
|
|
}
|
|
ok, reason := layerAllows(arch.Layers[fromLayer], fromLayer, toLayer)
|
|
if ok {
|
|
continue
|
|
}
|
|
key := id + "\x00" + e.To
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
violations = append(violations, GuardViolation{
|
|
RuleName: "layer:" + fromLayer,
|
|
Kind: "layer",
|
|
Description: fmt.Sprintf("%s (layer %s) %s %s (layer %s): %s",
|
|
n.ID, fromLayer, e.Kind, target.ID, toLayer, reason),
|
|
Violator: n.ID,
|
|
LayerFrom: fromLayer,
|
|
LayerTo: toLayer,
|
|
EdgeType: string(e.Kind),
|
|
Severity: ruleSeverity(arch.Severity),
|
|
})
|
|
}
|
|
}
|
|
violations = append(violations, evaluateArchRules(g, arch, changedSymbolIDs, names)...)
|
|
return violations
|
|
}
|
|
|
|
// evaluateArchRules checks the per-layer / per-pattern dependency-cone
|
|
// rules — fan-out caps and caller-boundary restrictions — for a set
|
|
// of changed symbols.
|
|
func evaluateArchRules(g graph.Store, arch config.ArchitectureConfig, changedSymbolIDs, layerNames []string) []GuardViolation {
|
|
if len(arch.Rules) == 0 {
|
|
return nil
|
|
}
|
|
var violations []GuardViolation
|
|
for _, id := range changedSymbolIDs {
|
|
n := g.GetNode(id)
|
|
if n == nil {
|
|
continue
|
|
}
|
|
ep := effectivePath(n)
|
|
nodeLayer := layerOf(ep, arch.Layers, layerNames)
|
|
for _, rule := range arch.Rules {
|
|
if !ruleApplies(rule, ep, nodeLayer) {
|
|
continue
|
|
}
|
|
if matchesAnyGlob(ep, rule.Except) {
|
|
continue
|
|
}
|
|
label := archRuleLabel(rule)
|
|
if rule.MaxFanOut > 0 {
|
|
if fan := distinctCallTargets(g, id); fan > rule.MaxFanOut {
|
|
violations = append(violations, GuardViolation{
|
|
RuleName: label,
|
|
Kind: "fan_out",
|
|
Description: ruleMessage(rule, fmt.Sprintf(
|
|
"%s has dependency fan-out %d, exceeding the limit of %d",
|
|
n.ID, fan, rule.MaxFanOut)),
|
|
Violator: n.ID,
|
|
Severity: ruleSeverity(rule.Severity),
|
|
})
|
|
}
|
|
}
|
|
if len(rule.DenyCallersOutside) > 0 {
|
|
seen := make(map[string]bool)
|
|
for _, e := range g.GetInEdges(id) {
|
|
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
|
|
continue
|
|
}
|
|
caller := g.GetNode(e.From)
|
|
if caller == nil || seen[caller.ID] {
|
|
continue
|
|
}
|
|
seen[caller.ID] = true
|
|
cp := effectivePath(caller)
|
|
if callerWithinBoundary(cp, rule, layerOf(cp, arch.Layers, layerNames)) {
|
|
continue
|
|
}
|
|
violations = append(violations, GuardViolation{
|
|
RuleName: label,
|
|
Kind: "caller_boundary",
|
|
Description: ruleMessage(rule, fmt.Sprintf(
|
|
"%s calls into %s from outside the permitted set", caller.ID, n.ID)),
|
|
Violator: caller.ID,
|
|
EdgeType: string(e.Kind),
|
|
Severity: ruleSeverity(rule.Severity),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
// ruleApplies reports whether an architecture rule is scoped to a
|
|
// symbol. A rule with neither a Layer nor a Pattern selector matches
|
|
// nothing; when both are set the symbol must satisfy both.
|
|
func ruleApplies(rule config.ArchRule, effPath, nodeLayer string) bool {
|
|
if rule.Layer == "" && rule.Pattern == "" {
|
|
return false
|
|
}
|
|
if rule.Layer != "" && nodeLayer != rule.Layer {
|
|
return false
|
|
}
|
|
if rule.Pattern != "" && !globMatch(rule.Pattern, effPath) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// callerWithinBoundary reports whether a caller is permitted to depend
|
|
// on a symbol guarded by a deny_callers_outside rule. The guarded set
|
|
// may always call within itself; every other caller must match one of
|
|
// the allowlist globs.
|
|
func callerWithinBoundary(callerPath string, rule config.ArchRule, callerLayer string) bool {
|
|
if ruleApplies(rule, callerPath, callerLayer) {
|
|
return true
|
|
}
|
|
for _, allow := range rule.DenyCallersOutside {
|
|
if globMatch(allow, callerPath) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// distinctCallTargets counts the distinct symbols a node calls or
|
|
// references — the dependency-cone size.
|
|
func distinctCallTargets(g graph.Store, id string) int {
|
|
seen := make(map[string]bool)
|
|
for _, e := range g.GetOutEdges(id) {
|
|
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
|
|
continue
|
|
}
|
|
seen[e.To] = true
|
|
}
|
|
return len(seen)
|
|
}
|
|
|
|
// archRuleLabel derives a stable rule name for a violation.
|
|
func archRuleLabel(rule config.ArchRule) string {
|
|
switch {
|
|
case rule.Name != "":
|
|
return rule.Name
|
|
case rule.Layer != "":
|
|
return "arch:layer:" + rule.Layer
|
|
case rule.Pattern != "":
|
|
return "arch:pattern:" + rule.Pattern
|
|
default:
|
|
return "arch:rule"
|
|
}
|
|
}
|
|
|
|
// ruleMessage prefixes a rule's configured Message onto the derived
|
|
// description when one is set.
|
|
func ruleMessage(rule config.ArchRule, detail string) string {
|
|
if rule.Message != "" {
|
|
return rule.Message + ": " + detail
|
|
}
|
|
return detail
|
|
}
|
|
|
|
// layerAllows reports whether a dependency from one layer to another
|
|
// is permitted, and the human-readable reason when it is not.
|
|
//
|
|
// Precedence: an explicit deny of the target layer always wins; then
|
|
// a non-empty Allow whitelist requires the target to be listed; with
|
|
// no whitelist a wildcard deny ("*") blocks every cross-layer edge.
|
|
func layerAllows(rule config.LayerRule, from, to string) (bool, string) {
|
|
for _, d := range rule.Deny {
|
|
if d == to {
|
|
return false, fmt.Sprintf("layer %q denies dependencies on %q", from, to)
|
|
}
|
|
}
|
|
if len(rule.Allow) > 0 {
|
|
for _, a := range rule.Allow {
|
|
if a == "*" || a == to {
|
|
return true, ""
|
|
}
|
|
}
|
|
return false, fmt.Sprintf("layer %q may depend only on %s, not %q",
|
|
from, strings.Join(rule.Allow, ", "), to)
|
|
}
|
|
for _, d := range rule.Deny {
|
|
if d == "*" {
|
|
return false, fmt.Sprintf("layer %q denies all cross-layer dependencies", from)
|
|
}
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
// layerOf returns the name of the layer a file belongs to, or "" when
|
|
// no layer claims it. names must be sorted so an ambiguous file
|
|
// (matched by two layers) resolves deterministically to the first.
|
|
func layerOf(filePath string, layers map[string]config.LayerRule, names []string) string {
|
|
for _, name := range names {
|
|
rule := layers[name]
|
|
if len(rule.Paths) > 0 {
|
|
for _, p := range rule.Paths {
|
|
if globMatch(p, filePath) {
|
|
return name
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
// A layer with no explicit paths claims files that carry the
|
|
// layer name as a path segment — supports the terse config form.
|
|
if pathHasSegment(filePath, name) {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sortedLayerNames returns the layer names in a stable order.
|
|
func sortedLayerNames(layers map[string]config.LayerRule) []string {
|
|
names := make([]string, 0, len(layers))
|
|
for name := range layers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// effectivePath strips the multi-repo prefix from a node's file path
|
|
// so per-repo architecture globs match in both single- and multi-repo
|
|
// graphs.
|
|
func effectivePath(n *graph.Node) string {
|
|
if n.RepoPrefix != "" {
|
|
return strings.TrimPrefix(n.FilePath, n.RepoPrefix+"/")
|
|
}
|
|
return n.FilePath
|
|
}
|
|
|
|
// pathHasSegment reports whether seg appears as a full path segment of
|
|
// filePath (e.g. seg "domain" matches "internal/domain/user.go").
|
|
func pathHasSegment(filePath, seg string) bool {
|
|
for _, part := range strings.Split(filePath, "/") {
|
|
if part == seg {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// globMatch reports whether path matches a glob pattern. "**" matches
|
|
// any number of path segments (including zero); "*" and "?" match
|
|
// within a single segment via the stdlib path.Match rules.
|
|
func globMatch(pattern, p string) bool {
|
|
return matchSegments(strings.Split(pattern, "/"), strings.Split(p, "/"))
|
|
}
|
|
|
|
func matchSegments(pat, seg []string) bool {
|
|
for len(pat) > 0 {
|
|
if pat[0] == "**" {
|
|
if len(pat) == 1 {
|
|
return true
|
|
}
|
|
for i := 0; i <= len(seg); i++ {
|
|
if matchSegments(pat[1:], seg[i:]) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if len(seg) == 0 {
|
|
return false
|
|
}
|
|
if ok, err := path.Match(pat[0], seg[0]); err != nil || !ok {
|
|
return false
|
|
}
|
|
pat, seg = pat[1:], seg[1:]
|
|
}
|
|
return len(seg) == 0
|
|
}
|