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

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, "::")
}