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
552 lines
18 KiB
Go
552 lines
18 KiB
Go
package analysis
|
|
|
|
import (
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// PRRiskInput is the forge-agnostic input to ScorePRRisk: the already-mapped
|
|
// set of changed symbol IDs plus the changed file paths, with optional
|
|
// pre-computed community/process context. The scorer never reaches a forge —
|
|
// callers map a diff to symbols first (e.g. via MapGitDiff) and hand the
|
|
// result in here.
|
|
type PRRiskInput struct {
|
|
SymbolIDs []string
|
|
ChangedFiles []string
|
|
NodeToComm map[string]string
|
|
Communities *CommunityResult
|
|
Processes *ProcessResult
|
|
}
|
|
|
|
// PRRiskFactor is one scored axis of the composite, carrying a short
|
|
// human-readable reason. The ordered slice of factors doubles as the
|
|
// review-priority list a reviewer should work top-down.
|
|
type PRRiskFactor struct {
|
|
Axis string `json:"axis"`
|
|
Score float64 `json:"score"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// PRRiskResult is the output of ScorePRRisk: a 0-100 composite with a coarse
|
|
// RiskLevel, the per-axis factors (sorted highest-first), and the supporting
|
|
// counts that fed the axes.
|
|
type PRRiskResult struct {
|
|
Score float64 `json:"score"`
|
|
Risk RiskLevel `json:"risk"`
|
|
Factors []PRRiskFactor `json:"factors"`
|
|
TotalAffected int `json:"total_affected"`
|
|
UncoveredSymbols int `json:"uncovered_symbols"`
|
|
CommunitySpan int `json:"community_span"`
|
|
SecurityHits []string `json:"security_hits"`
|
|
}
|
|
|
|
// prRisk axis weights. Centrality-like axes (blast-radius flow and the raw
|
|
// caller fan-in) carry the most weight; coverage and security are mid-weight
|
|
// review-priority signals; the community-span axis is the lightest, mirroring
|
|
// the composite-impact weighting convention.
|
|
const (
|
|
prWeightFlow = 2.5
|
|
prWeightCallers = 2.5
|
|
prWeightCoverage = 1.5
|
|
prWeightSecurity = 1.5
|
|
prWeightCommunity = 1.0
|
|
)
|
|
|
|
// prRisk half-saturation points. Each raw count is mapped onto 0..100 with
|
|
// prSaturate, where the named constant is the value that yields 50.
|
|
const (
|
|
prFlowK = 12.0
|
|
prCallerK = 8.0
|
|
prCommK = 3.0
|
|
)
|
|
|
|
// securityFloor is the minimum composite a PR with any security-keyword hit
|
|
// is held to — the HIGH threshold, so a security-sensitive change always
|
|
// reaches a reviewer regardless of its blast radius.
|
|
const securityFloor = 55.0
|
|
|
|
// securityKeywords is the curated set matched against changed file paths and
|
|
// changed symbol names. A hit on any of these is a strong "a human must look
|
|
// here" signal independent of blast radius — auth/crypto/secret-handling code
|
|
// is where a subtle change does the most damage.
|
|
var securityKeywords = []string{
|
|
"auth",
|
|
"login",
|
|
"password",
|
|
"passwd",
|
|
"secret",
|
|
"token",
|
|
"credential",
|
|
"crypto",
|
|
"cipher",
|
|
"encrypt",
|
|
"decrypt",
|
|
"hash",
|
|
"jwt",
|
|
"oauth",
|
|
"session",
|
|
"permission",
|
|
"privilege",
|
|
"sudo",
|
|
"admin",
|
|
"acl",
|
|
"rbac",
|
|
"signature",
|
|
"tls",
|
|
"certificate",
|
|
"keystore",
|
|
"vault",
|
|
}
|
|
|
|
// ScorePRRisk computes a PR-level composite risk score over a set of changed
|
|
// symbols. Five 0-100 axes are blended into a weighted normalized composite
|
|
// and bucketed into a RiskLevel via the same thresholds the composite-impact
|
|
// scorer uses. The ordered Factors slice doubles as a review-priority list.
|
|
func ScorePRRisk(g graph.Store, in PRRiskInput) PRRiskResult {
|
|
ids := dedupStrings(in.SymbolIDs)
|
|
|
|
// Axis (a): blast-radius flow. AnalyzeImpact gives the total affected
|
|
// count and the d=1 direct-dependent count; both feed the flow score,
|
|
// floored by the structural assessRisk ladder so a heavily-depended-on
|
|
// change can never read as low-flow.
|
|
impact := AnalyzeImpact(g, ids, in.Communities, in.Processes)
|
|
d1 := len(impact.ByDepth[1])
|
|
flowRaw := float64(impact.TotalAffected) + float64(d1)
|
|
flowScore := prSaturate(flowRaw, prFlowK)
|
|
if floor := riskFloorScore(assessRisk(d1, len(impact.ByDepth[2]))); floor > flowScore {
|
|
flowScore = floor
|
|
}
|
|
|
|
// Axis (b): community span. A change touching many communities is a
|
|
// cross-cutting change with a wider review surface.
|
|
commSet := make(map[string]bool)
|
|
for _, id := range ids {
|
|
if cid, ok := in.NodeToComm[id]; ok && cid != "" {
|
|
commSet[cid] = true
|
|
}
|
|
}
|
|
communitySpan := len(commSet)
|
|
communityScore := prSaturate(float64(communitySpan), prCommK)
|
|
|
|
// Axis (c): coverage gap. The ratio of changed symbols with NO covering
|
|
// test, found via the EdgeTests inverse walk (a test→symbol edge pointing
|
|
// at the changed symbol). Only code-bearing symbols are counted so a
|
|
// changed import/type-alias does not dilute the ratio.
|
|
covered, uncovered := 0, 0
|
|
for _, id := range ids {
|
|
n := g.GetNode(id)
|
|
if n == nil || !isCodeSymbol(n.Kind) {
|
|
continue
|
|
}
|
|
if hasCoveringTest(g, id) {
|
|
covered++
|
|
} else {
|
|
uncovered++
|
|
}
|
|
}
|
|
coverageScore := 0.0
|
|
considered := covered + uncovered
|
|
if considered > 0 {
|
|
coverageScore = 100 * float64(uncovered) / float64(considered)
|
|
}
|
|
|
|
// Axis (d): security keyword. A hit on the curated set over file paths or
|
|
// symbol names is a hard "human must review" axis; presence alone scores
|
|
// high so the composite cannot dilute it away.
|
|
hits := securityKeywordHits(in.ChangedFiles, symbolNames(g, ids))
|
|
securityScore := 0.0
|
|
if len(hits) > 0 {
|
|
securityScore = 70.0 + prSaturate(float64(len(hits)-1), 2.0)*0.3
|
|
if securityScore > 100 {
|
|
securityScore = 100
|
|
}
|
|
}
|
|
|
|
// Axis (e): caller fan-in. The single most-called changed symbol drives
|
|
// this axis — a change to a hub is riskier than the blast-radius average.
|
|
maxFanIn := 0
|
|
for _, id := range ids {
|
|
if fi := callerFanIn(g, id); fi > maxFanIn {
|
|
maxFanIn = fi
|
|
}
|
|
}
|
|
callerScore := prSaturate(float64(maxFanIn), prCallerK)
|
|
|
|
// Weighted normalized composite.
|
|
composite := (flowScore*prWeightFlow +
|
|
callerScore*prWeightCallers +
|
|
coverageScore*prWeightCoverage +
|
|
securityScore*prWeightSecurity +
|
|
communityScore*prWeightCommunity) /
|
|
(prWeightFlow + prWeightCallers + prWeightCoverage + prWeightSecurity + prWeightCommunity)
|
|
|
|
// Security is a hard "a human must look here" axis: a hit on the curated
|
|
// set floors the composite at HIGH so the weighted blend can never dilute
|
|
// a security-sensitive change down to a low-priority review — the same
|
|
// risk-floor convention the change-impact tool uses for contract boundaries.
|
|
if len(hits) > 0 && composite < securityFloor {
|
|
composite = securityFloor
|
|
}
|
|
|
|
factors := []PRRiskFactor{
|
|
{Axis: "flow", Score: roundPR(flowScore), Reason: flowReason(impact.TotalAffected, d1)},
|
|
{Axis: "callers", Score: roundPR(callerScore), Reason: callerReason(maxFanIn)},
|
|
{Axis: "coverage", Score: roundPR(coverageScore), Reason: coverageReason(uncovered, considered)},
|
|
{Axis: "security", Score: roundPR(securityScore), Reason: securityReason(hits)},
|
|
{Axis: "community", Score: roundPR(communityScore), Reason: communityReason(communitySpan)},
|
|
}
|
|
// review_priorities — highest axis first; ties broken by axis name so the
|
|
// ordering is deterministic.
|
|
sort.SliceStable(factors, func(i, j int) bool {
|
|
if factors[i].Score != factors[j].Score {
|
|
return factors[i].Score > factors[j].Score
|
|
}
|
|
return factors[i].Axis < factors[j].Axis
|
|
})
|
|
|
|
return PRRiskResult{
|
|
Score: roundPR(composite),
|
|
Risk: prRiskLevel(composite),
|
|
Factors: factors,
|
|
TotalAffected: impact.TotalAffected,
|
|
UncoveredSymbols: uncovered,
|
|
CommunitySpan: communitySpan,
|
|
SecurityHits: hits,
|
|
}
|
|
}
|
|
|
|
// securityKeywordHits returns the distinct curated security keywords that
|
|
// appear (case-insensitive) in any changed file path or changed symbol name,
|
|
// in curated-set order so the output is stable.
|
|
func securityKeywordHits(files []string, names []string) []string {
|
|
hay := make([]string, 0, len(files)+len(names))
|
|
for _, f := range files {
|
|
hay = append(hay, strings.ToLower(f))
|
|
}
|
|
for _, n := range names {
|
|
hay = append(hay, strings.ToLower(n))
|
|
}
|
|
var out []string
|
|
for _, kw := range securityKeywords {
|
|
for _, h := range hay {
|
|
if strings.Contains(h, kw) {
|
|
out = append(out, kw)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hasCoveringTest reports whether any inbound EdgeTests edge points at the
|
|
// symbol — the same inverse-edge walk buildBlastRadius uses to find covering
|
|
// tests, run at the graph-store level (one hop, no BFS).
|
|
func hasCoveringTest(g graph.Store, symbolID string) bool {
|
|
for _, e := range g.GetInEdges(symbolID) {
|
|
if e.Kind == graph.EdgeTests {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// callerFanIn counts the distinct callers of a symbol — inbound calls/
|
|
// references edges, deduped by source node. A reference edge is included so a
|
|
// symbol used as a value (not only called) still registers fan-in.
|
|
func callerFanIn(g graph.Store, symbolID string) int {
|
|
seen := make(map[string]bool)
|
|
for _, e := range g.GetInEdges(symbolID) {
|
|
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
|
|
continue
|
|
}
|
|
if e.From == "" || e.From == symbolID {
|
|
continue
|
|
}
|
|
seen[e.From] = true
|
|
}
|
|
return len(seen)
|
|
}
|
|
|
|
// symbolNames resolves the changed symbol IDs to their node names for the
|
|
// security-keyword match. Unknown IDs fall back to the trailing segment of the
|
|
// ID so a name still feeds the matcher even when the node is not in the graph.
|
|
func symbolNames(g graph.Store, ids []string) []string {
|
|
out := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
if n := g.GetNode(id); n != nil && n.Name != "" {
|
|
out = append(out, n.Name)
|
|
continue
|
|
}
|
|
if idx := strings.LastIndex(id, "::"); idx >= 0 && idx+2 < len(id) {
|
|
out = append(out, id[idx+2:])
|
|
} else {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isCodeSymbol reports whether a node kind is a code-bearing symbol worth
|
|
// holding to a coverage standard (function/method/type). Imports, fields, and
|
|
// other structural nodes are excluded from the coverage ratio.
|
|
func isCodeSymbol(k graph.NodeKind) bool {
|
|
switch k {
|
|
case graph.KindFunction, graph.KindMethod, graph.KindType:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// prSaturate maps a non-negative raw value onto 0..100 with a half-saturation
|
|
// point of k: x==k yields 50, x==3k yields 75.
|
|
func prSaturate(x, k float64) float64 {
|
|
if x <= 0 || k <= 0 {
|
|
return 0
|
|
}
|
|
return 100 * x / (x + k)
|
|
}
|
|
|
|
// riskFloorScore turns a structural RiskLevel into a 0..100 floor so the flow
|
|
// axis can never read below what the assessRisk ladder already established.
|
|
func riskFloorScore(r RiskLevel) float64 {
|
|
switch r {
|
|
case RiskCritical:
|
|
return 80
|
|
case RiskHigh:
|
|
return 60
|
|
case RiskMedium:
|
|
return 40
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// prRiskLevel buckets the composite into a RiskLevel using the same
|
|
// thresholds as the composite-impact label (>=75 CRITICAL / >=55 HIGH /
|
|
// >=35 MEDIUM).
|
|
func prRiskLevel(score float64) RiskLevel {
|
|
switch {
|
|
case score >= 75:
|
|
return RiskCritical
|
|
case score >= 55:
|
|
return RiskHigh
|
|
case score >= 35:
|
|
return RiskMedium
|
|
default:
|
|
return RiskLow
|
|
}
|
|
}
|
|
|
|
func flowReason(total, d1 int) string {
|
|
if total == 0 {
|
|
return "no detected dependents"
|
|
}
|
|
return formatCount(d1, "direct dependent", "direct dependents") + ", " +
|
|
formatCount(total, "symbol affected", "symbols affected")
|
|
}
|
|
|
|
func callerReason(maxFanIn int) string {
|
|
if maxFanIn == 0 {
|
|
return "no callers on any changed symbol"
|
|
}
|
|
return "widest changed symbol has " + formatCount(maxFanIn, "caller", "callers")
|
|
}
|
|
|
|
func coverageReason(uncovered, considered int) string {
|
|
if considered == 0 {
|
|
return "no code symbols to cover"
|
|
}
|
|
if uncovered == 0 {
|
|
return "all changed symbols have covering tests"
|
|
}
|
|
return formatCount(uncovered, "changed symbol", "changed symbols") + " with no covering test"
|
|
}
|
|
|
|
func securityReason(hits []string) string {
|
|
if len(hits) == 0 {
|
|
return "no security-sensitive paths or names"
|
|
}
|
|
return "changed path/name matches " + strings.Join(hits, ", ")
|
|
}
|
|
|
|
func communityReason(span int) string {
|
|
if span <= 1 {
|
|
return "change is community-local"
|
|
}
|
|
return "change spans " + formatCount(span, "community", "communities")
|
|
}
|
|
|
|
func formatCount(n int, singular, plural string) string {
|
|
noun := plural
|
|
if n == 1 {
|
|
noun = singular
|
|
}
|
|
return strconv.Itoa(n) + " " + noun
|
|
}
|
|
|
|
// dedupStrings returns the input slice with empty and duplicate entries
|
|
// removed, preserving first-seen order.
|
|
func dedupStrings(in []string) []string {
|
|
seen := make(map[string]bool, len(in))
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
if s == "" || seen[s] {
|
|
continue
|
|
}
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// roundPR rounds a score to two decimals so the wire payload is stable.
|
|
func roundPR(v float64) float64 {
|
|
return float64(int64(v*100+0.5)) / 100
|
|
}
|
|
|
|
// receiptVersion is the on-the-wire schema version of a ReviewReceipt. Bump it
|
|
// only on a breaking field change so a consumer can refuse an unknown shape.
|
|
const receiptVersion = 1
|
|
|
|
// receiptSpanSplit is the community-span count at or above which a change is
|
|
// considered cross-cutting enough that splitting the PR is the next safe
|
|
// action (when nothing more urgent applies).
|
|
const receiptSpanSplit = 3
|
|
|
|
// next_safe_action vocabulary. A small, fixed enum so the receipt carries a
|
|
// machine-actionable verdict with no free-form text that could leak context.
|
|
const (
|
|
actionAddTests = "add-tests"
|
|
actionSplitPR = "split-pr"
|
|
actionReviewSecurity = "review-security"
|
|
actionMergeReady = "merge-ready"
|
|
)
|
|
|
|
// blocker_reason vocabulary. A fixed enum so the receipt never embeds a path,
|
|
// symbol ID, or any caller-supplied free text in the reason — safe to share.
|
|
const (
|
|
blockerCIFailure = "ci-failure"
|
|
blockerCriticalRisk = "critical-risk"
|
|
blockerBreakingChange = "breaking-change"
|
|
)
|
|
|
|
// ReceiptFactor is the privacy-safe projection of a PRRiskFactor: only the
|
|
// axis name and its 0-100 score. The human-readable Reason is deliberately
|
|
// dropped — it can mention changed symbol names, which a shared receipt must
|
|
// not leak.
|
|
type ReceiptFactor struct {
|
|
Axis string `json:"axis"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
// ReviewReceipt is a small, machine-readable projection of a PR-risk result: a
|
|
// structured blast-radius summary with a derived next-safe-action and a
|
|
// merge-blocker verdict. It carries only counts, tier labels, axis names, and
|
|
// a fixed-vocabulary action/reason — never a file path, symbol ID, or email —
|
|
// so (especially with scrub) it is safe to share across org boundaries.
|
|
type ReviewReceipt struct {
|
|
ReceiptVersion int `json:"receipt_version"`
|
|
RiskTier RiskLevel `json:"risk_tier"`
|
|
NextSafeAction string `json:"next_safe_action"`
|
|
MergeBlocker bool `json:"merge_blocker"`
|
|
BlockerReason string `json:"blocker_reason"`
|
|
AffectedCount int `json:"affected_count"`
|
|
UncoveredCount int `json:"uncovered_count"`
|
|
CommunitySpan int `json:"community_span"`
|
|
SecurityFlagged bool `json:"security_flagged"`
|
|
TopFactors []ReceiptFactor `json:"top_factors"`
|
|
}
|
|
|
|
// BuildReviewReceipt projects an already-computed PR-risk result into a small,
|
|
// privacy-safe receipt. ci is the normalized CI rollup (NONE / FAILURE /
|
|
// PENDING / SUCCESS); blocker reports an out-of-band hard blocker (e.g. a
|
|
// broken contract / breaking change the caller detected). merge_blocker is set
|
|
// when CI failed, the risk is the top tier, or the caller flagged a breaking
|
|
// change. When scrub is true the receipt is additionally sanitized so no
|
|
// path-like, "::"-bearing, or email-like value can leak — the counts, tier,
|
|
// action, and axis names are always retained.
|
|
func BuildReviewReceipt(result PRRiskResult, ci string, blocker bool, scrub bool) ReviewReceipt {
|
|
blocked := false
|
|
reason := ""
|
|
switch {
|
|
case strings.EqualFold(ci, "FAILURE"):
|
|
blocked, reason = true, blockerCIFailure
|
|
case result.Risk == RiskCritical:
|
|
blocked, reason = true, blockerCriticalRisk
|
|
case blocker:
|
|
blocked, reason = true, blockerBreakingChange
|
|
}
|
|
|
|
top := make([]ReceiptFactor, 0, len(result.Factors))
|
|
for _, f := range result.Factors {
|
|
top = append(top, ReceiptFactor{Axis: f.Axis, Score: f.Score})
|
|
}
|
|
|
|
r := ReviewReceipt{
|
|
ReceiptVersion: receiptVersion,
|
|
RiskTier: result.Risk,
|
|
NextSafeAction: nextSafeAction(result),
|
|
MergeBlocker: blocked,
|
|
BlockerReason: reason,
|
|
AffectedCount: result.TotalAffected,
|
|
UncoveredCount: result.UncoveredSymbols,
|
|
CommunitySpan: result.CommunitySpan,
|
|
SecurityFlagged: len(result.SecurityHits) > 0,
|
|
TopFactors: top,
|
|
}
|
|
|
|
if scrub {
|
|
scrubReceipt(&r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// nextSafeAction derives the single most useful next step from a risk result.
|
|
// Precedence (most urgent first): uncovered changed symbols → add tests; a
|
|
// cross-cutting change → split the PR; a security-sensitive change → route to
|
|
// a security reviewer; otherwise the change is merge-ready.
|
|
func nextSafeAction(r PRRiskResult) string {
|
|
switch {
|
|
case r.UncoveredSymbols > 0:
|
|
return actionAddTests
|
|
case r.CommunitySpan >= receiptSpanSplit:
|
|
return actionSplitPR
|
|
case len(r.SecurityHits) > 0:
|
|
return actionReviewSecurity
|
|
default:
|
|
return actionMergeReady
|
|
}
|
|
}
|
|
|
|
// scrubReceipt strips any path-like, symbol-ID-like, or email-like value from
|
|
// the free-text fields of a receipt so it is safe to share cross-org. The
|
|
// numeric counts, the tier label, the merge verdict, the fixed-vocabulary
|
|
// action/reason, and the axis names are structurally privacy-safe and are
|
|
// retained verbatim; only fields that could ever carry caller context are
|
|
// sanitized, defensively, in case a future field is wired in.
|
|
func scrubReceipt(r *ReviewReceipt) {
|
|
if leaksContext(r.NextSafeAction) {
|
|
r.NextSafeAction = ""
|
|
}
|
|
if leaksContext(r.BlockerReason) {
|
|
r.BlockerReason = ""
|
|
}
|
|
for i := range r.TopFactors {
|
|
if leaksContext(r.TopFactors[i].Axis) {
|
|
r.TopFactors[i].Axis = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// leaksContext reports whether a string carries a path-like, symbol-ID-like,
|
|
// or email-like value — the three context shapes a shared receipt must not
|
|
// expose. It is intentionally conservative: a slash, a "::" symbol-ID
|
|
// separator, or an "@" (email) all count as a leak.
|
|
func leaksContext(s string) bool {
|
|
return strings.ContainsAny(s, "/\\@") || strings.Contains(s, "::")
|
|
}
|