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
403 lines
12 KiB
Go
403 lines
12 KiB
Go
package graph
|
||
|
||
import (
|
||
"go/ast"
|
||
"go/parser"
|
||
"go/token"
|
||
"io/fs"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// Schema-vs-extractor parity audit.
|
||
//
|
||
// The graph package declares ~30 NodeKinds and ~55 EdgeKinds. Without
|
||
// a regression fence, the schema drifts: a new kind gets added with a
|
||
// detailed comment, but nobody wires the emitter, and the constant
|
||
// quietly outlives its purpose. We've seen this exact failure mode
|
||
// before — `KindRelease` was declared and documented for many releases
|
||
// before any extractor actually instantiated it.
|
||
//
|
||
// TestSchemaParityAudit walks every Go source file under the repo
|
||
// (excluding worktrees, vendor, build artifacts) and asserts that
|
||
// every declared NodeKind and EdgeKind constant is referenced from
|
||
// at least one file outside the declaring file (node.go / edge.go).
|
||
//
|
||
// "Referenced" here means the bare identifier appears in production
|
||
// source — the resolver, an extractor, an enricher, an analyzer, or
|
||
// a downstream consumer. Test-only references count too: a test that
|
||
// exercises the consumer is still proof the kind has a real role.
|
||
//
|
||
// When the audit fails the failure message names the orphan constant
|
||
// and points at the file where it was declared so the next sweep
|
||
// either wires the emitter or removes the constant. Reserved kinds
|
||
// (declared today, emitter coming next sprint) go on the explicit
|
||
// allowlist with a documented reason — that turns "we forgot" into
|
||
// "we deliberately deferred."
|
||
func TestSchemaParityAudit(t *testing.T) {
|
||
repoRoot, err := findRepoRoot()
|
||
if err != nil {
|
||
t.Fatalf("locate repo root: %v", err)
|
||
}
|
||
|
||
declared, err := extractKindDeclarations(filepath.Join(repoRoot, "internal", "graph"))
|
||
if err != nil {
|
||
t.Fatalf("scan declarations: %v", err)
|
||
}
|
||
if len(declared) == 0 {
|
||
t.Fatal("scan returned zero declarations — audit is broken")
|
||
}
|
||
|
||
usage, err := scanKindUsage(repoRoot, declared)
|
||
if err != nil {
|
||
t.Fatalf("scan usage: %v", err)
|
||
}
|
||
|
||
var orphans []string
|
||
for _, d := range declared {
|
||
if reservedKinds[d.Name] {
|
||
continue
|
||
}
|
||
// "Direct" usage means we found the constant referenced
|
||
// anywhere outside its own declaration file. Mapping-table
|
||
// references inside graph/edge.go (CrossRepoKindFor etc.)
|
||
// count because they prove the constant is reachable from
|
||
// production query paths — the actual emit site can then sit
|
||
// in a resolver or enricher that calls the mapping fn.
|
||
if usage[d.Name] == 0 {
|
||
orphans = append(orphans, formatOrphan(d))
|
||
}
|
||
}
|
||
if len(orphans) > 0 {
|
||
sort.Strings(orphans)
|
||
t.Errorf("schema-parity audit found %d orphan kind(s) — wire emitter or remove from schema:\n\n%s\n\n"+
|
||
"Add to `reservedKinds` in this file with a documented reason if the gap is intentional.",
|
||
len(orphans), strings.Join(orphans, "\n"))
|
||
}
|
||
}
|
||
|
||
// reservedKinds is the explicit allowlist of constants that are
|
||
// declared today and intentionally lack a production emit site. Every
|
||
// entry must carry a comment explaining the reason — "we forgot" is
|
||
// not a valid reason; the answer is to either ship the emitter or
|
||
// delete the declaration.
|
||
var reservedKinds = map[string]bool{
|
||
// (empty — every declared kind currently has a production emit
|
||
// site. Future deferrals go here with a documented reason.)
|
||
}
|
||
|
||
// kindDecl is a single NodeKind / EdgeKind constant declaration we
|
||
// extracted from internal/graph. The Path / Line locate it for the
|
||
// failure message so the next sweep knows where to look.
|
||
type kindDecl struct {
|
||
Name string
|
||
Kind string // "NodeKind" or "EdgeKind"
|
||
Path string
|
||
Line int
|
||
}
|
||
|
||
// extractKindDeclarations parses every .go file under the given
|
||
// directory and returns each `const Name SomeKind = ...` whose RHS
|
||
// type is NodeKind or EdgeKind. Anything else is ignored — the audit
|
||
// is intentionally scoped to schema kinds.
|
||
func extractKindDeclarations(dir string) ([]kindDecl, error) {
|
||
var out []kindDecl
|
||
fset := token.NewFileSet()
|
||
|
||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||
return nil
|
||
}
|
||
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, decl := range file.Decls {
|
||
gen, ok := decl.(*ast.GenDecl)
|
||
if !ok || gen.Tok != token.CONST {
|
||
continue
|
||
}
|
||
for _, spec := range gen.Specs {
|
||
vs, ok := spec.(*ast.ValueSpec)
|
||
if !ok {
|
||
continue
|
||
}
|
||
typeName, ok := simpleTypeName(vs.Type)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if typeName != "NodeKind" && typeName != "EdgeKind" {
|
||
continue
|
||
}
|
||
for _, ident := range vs.Names {
|
||
pos := fset.Position(ident.NamePos)
|
||
out = append(out, kindDecl{
|
||
Name: ident.Name,
|
||
Kind: typeName,
|
||
Path: path,
|
||
Line: pos.Line,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// simpleTypeName pulls the identifier name out of a simple type
|
||
// expression. Returns false for any expression that isn't a bare
|
||
// identifier — qualified names, function types, etc. — because the
|
||
// audit only cares about the local NodeKind / EdgeKind types.
|
||
func simpleTypeName(expr ast.Expr) (string, bool) {
|
||
id, ok := expr.(*ast.Ident)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
return id.Name, true
|
||
}
|
||
|
||
// scanKindUsage counts how many references to each declared constant
|
||
// exist outside its declaring file. The scanner is regex-based
|
||
// (rather than AST-based) because the audit needs to catch every
|
||
// reachable mention — including string-literal mappings and test
|
||
// fixtures — without paying the parse cost of every .go file in the
|
||
// repo. Word-boundary anchors keep "EdgeReads" and "EdgeReadsCol"
|
||
// (and "EdgeReadsConfig") distinct.
|
||
func scanKindUsage(repoRoot string, declared []kindDecl) (map[string]int, error) {
|
||
declaringFiles := make(map[string]string, len(declared))
|
||
for _, d := range declared {
|
||
declaringFiles[d.Name] = d.Path
|
||
}
|
||
|
||
// One combined alternation rather than one regex per kind. Matching
|
||
// every .go file in the repo against ~100 separate \bName\b patterns
|
||
// is O(files × kinds) and grows past the CI timeout as both the tree
|
||
// and the schema grow; a single \b(?:Name1|Name2|…)\b scanned once per
|
||
// file is O(files). The \b anchors keep the identifiers as distinct as
|
||
// before — `EdgeReads` still does NOT match inside `EdgeReadsCol`,
|
||
// because the inner \b fails between two word characters and the
|
||
// engine falls through to the longer alternative.
|
||
names := make([]string, 0, len(declared))
|
||
seen := make(map[string]bool, len(declared))
|
||
for _, d := range declared {
|
||
if seen[d.Name] {
|
||
continue
|
||
}
|
||
seen[d.Name] = true
|
||
names = append(names, regexp.QuoteMeta(d.Name))
|
||
}
|
||
re := regexp.MustCompile(`\b(?:` + strings.Join(names, "|") + `)\b`)
|
||
|
||
usage := make(map[string]int, len(declared))
|
||
|
||
err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if d.IsDir() {
|
||
base := d.Name()
|
||
// Skip vendor, build caches, worktrees, eval venvs,
|
||
// transcript dumps — none of those carry production
|
||
// emit sites.
|
||
switch base {
|
||
case ".git", "vendor", "node_modules", ".claude", ".cache",
|
||
".venv", "venv", "build", "dist", "debug":
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
if !strings.HasSuffix(path, ".go") {
|
||
return nil
|
||
}
|
||
// Worktrees can hide a duplicate copy of the source tree
|
||
// under .claude/worktrees/* — already skipped by the .claude
|
||
// directory rule above; defensive double-check.
|
||
if strings.Contains(path, "/worktrees/") {
|
||
return nil
|
||
}
|
||
// Don't let the audit's own file count as a reference — it
|
||
// names every constant in its failure-message format so a
|
||
// naive regex would mark every kind as "used."
|
||
if strings.HasSuffix(path, "/parity_audit_test.go") {
|
||
return nil
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil // unreadable files don't fail the audit
|
||
}
|
||
text := stripGoComments(string(data))
|
||
found := make(map[string]bool)
|
||
for _, m := range re.FindAllString(text, -1) {
|
||
found[m] = true
|
||
}
|
||
for name := range found {
|
||
// A constant referenced only inside its own declaring file
|
||
// is not a real consumer — exclude it exactly as the
|
||
// per-pattern scan did.
|
||
if path == declaringFiles[name] {
|
||
continue
|
||
}
|
||
usage[name]++
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return usage, nil
|
||
}
|
||
|
||
// findRepoRoot walks up from the test's working directory looking
|
||
// for go.mod. Lets the audit run from any package depth — the test
|
||
// stays at internal/graph but the scan needs the repo root.
|
||
func findRepoRoot() (string, error) {
|
||
dir, err := os.Getwd()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
for {
|
||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||
return dir, nil
|
||
}
|
||
parent := filepath.Dir(dir)
|
||
if parent == dir {
|
||
return "", os.ErrNotExist
|
||
}
|
||
dir = parent
|
||
}
|
||
}
|
||
|
||
func formatOrphan(d kindDecl) string {
|
||
rel := d.Path
|
||
if root, err := findRepoRoot(); err == nil {
|
||
if r, err := filepath.Rel(root, d.Path); err == nil {
|
||
rel = r
|
||
}
|
||
}
|
||
return " " + d.Kind + "." + d.Name + " (declared at " + rel + ":" + itoa(d.Line) + ")"
|
||
}
|
||
|
||
// stripGoComments removes line and block comments from Go source so
|
||
// the usage scan only counts identifiers that actually appear in
|
||
// executable code. Without this a constant mentioned in a docstring
|
||
// elsewhere in the tree would falsely satisfy the parity audit.
|
||
//
|
||
// The implementation is intentionally simple — character-level scan
|
||
// with a small state machine — because the alternative (full Go
|
||
// parsing) is overkill for the audit's needs and slow when invoked
|
||
// across every .go file in the repo. Strings (single- and
|
||
// double-quoted, plus raw backtick) are preserved verbatim so a
|
||
// string literal containing "//" isn't mistaken for a comment.
|
||
func stripGoComments(src string) string {
|
||
var b strings.Builder
|
||
b.Grow(len(src))
|
||
const (
|
||
stateCode = iota
|
||
stateLineComment
|
||
stateBlockComment
|
||
stateString
|
||
stateRawString
|
||
stateRune
|
||
)
|
||
state := stateCode
|
||
for i := 0; i < len(src); i++ {
|
||
c := src[i]
|
||
switch state {
|
||
case stateCode:
|
||
switch {
|
||
case c == '/' && i+1 < len(src) && src[i+1] == '/':
|
||
state = stateLineComment
|
||
i++
|
||
b.WriteByte(' ')
|
||
case c == '/' && i+1 < len(src) && src[i+1] == '*':
|
||
state = stateBlockComment
|
||
i++
|
||
b.WriteByte(' ')
|
||
case c == '"':
|
||
state = stateString
|
||
b.WriteByte(c)
|
||
case c == '`':
|
||
state = stateRawString
|
||
b.WriteByte(c)
|
||
case c == '\'':
|
||
state = stateRune
|
||
b.WriteByte(c)
|
||
default:
|
||
b.WriteByte(c)
|
||
}
|
||
case stateLineComment:
|
||
if c == '\n' {
|
||
state = stateCode
|
||
b.WriteByte(c)
|
||
}
|
||
case stateBlockComment:
|
||
if c == '*' && i+1 < len(src) && src[i+1] == '/' {
|
||
state = stateCode
|
||
i++
|
||
b.WriteByte(' ')
|
||
}
|
||
case stateString:
|
||
b.WriteByte(c)
|
||
if c == '\\' && i+1 < len(src) {
|
||
b.WriteByte(src[i+1])
|
||
i++
|
||
} else if c == '"' {
|
||
state = stateCode
|
||
}
|
||
case stateRawString:
|
||
b.WriteByte(c)
|
||
if c == '`' {
|
||
state = stateCode
|
||
}
|
||
case stateRune:
|
||
b.WriteByte(c)
|
||
if c == '\\' && i+1 < len(src) {
|
||
b.WriteByte(src[i+1])
|
||
i++
|
||
} else if c == '\'' {
|
||
state = stateCode
|
||
}
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func itoa(n int) string {
|
||
// Tiny helper so the audit file doesn't need strconv just for one
|
||
// formatter. Keeps the import set minimal — easier to read at a
|
||
// glance.
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
neg := n < 0
|
||
if neg {
|
||
n = -n
|
||
}
|
||
var buf [20]byte
|
||
i := len(buf)
|
||
for n > 0 {
|
||
i--
|
||
buf[i] = byte('0' + n%10)
|
||
n /= 10
|
||
}
|
||
if neg {
|
||
i--
|
||
buf[i] = '-'
|
||
}
|
||
return string(buf[i:])
|
||
}
|