package contracts import ( "encoding/gob" "strings" ) // Shape is stored in graph.Node.Meta (a map[string]any), which gets // gob-encoded into the daemon snapshot. Gob refuses to encode a concrete // type reached through an interface slot unless that type is registered, // so the first node with a shape would abort the snapshot write with // "type not registered for interface: contracts.Shape". Register the // pointer form because that's what ExtractShape returns and what the // indexer writes into Meta. func init() { gob.Register((*Shape)(nil)) } // Shape captures the externally-visible structure of a type that's // referenced as a contract's request / response body. It's attached to // the type's graph node (via `Meta["shape"]`) so downstream tooling — // the UI schema pane, a `contracts validate` pass, change-impact // analysis — can diff field lists without re-reading source. // // We serialise the shape explicitly rather than relying on whoever // reads the node to re-parse the file, because field-level diffing // needs to work even when only one side of a cross-repo contract is // still indexed. type Shape struct { // Kind is the syntactic flavour of the definition: // "struct" (Go), "interface" | "type" (TS), "class" (Python), // "class" (Java/Kotlin), "message" (proto). Kind string `json:"kind"` Fields []ShapeField `json:"fields"` // Notes carries extractor diagnostics that aren't a hard error // but tell a reader why the shape might be incomplete. Notes []string `json:"notes,omitempty"` } // ShapeField is one field on a type. type ShapeField struct { // Name is the wire name (serialised form). For Go this is the JSON // tag when present, else the struct field name lower-cased? No — // we preserve the declared field name and put the wire tag in // JSONTag. Consumers choose which to display. Name string `json:"name"` // Type is a textual type expression — "string", "int64", // "[]User", "Profile", "Optional[int]", "List". May be a // bare identifier (upgradeable to a symbol ID later) or a // compound expression. Type string `json:"type"` // JSONTag is the `json:"..."` value on Go struct tags, // `@JsonProperty("...")` on Java / Jackson, `alias="..."` on // Pydantic, `json_name = "..."` on proto. Empty when none. JSONTag string `json:"json_tag,omitempty"` // Required is true when the field must be present on the wire. // Go: tag has no `omitempty` AND field is not a pointer. // TS: no trailing `?` and not unioned with `null` / `undefined`. // Python: no default value AND type isn't Optional / | None. // Java: no `@Nullable` / `Optional`. // Proto3: default is optional; `required` synthesises the flag. Required bool `json:"required"` // Repeated is true for list / array / slice types. Repeated bool `json:"repeated,omitempty"` // Comment is the adjacent docstring / line comment when present. // We keep at most one line; long comments get truncated so the // graph payload stays compact. Comment string `json:"comment,omitempty"` } // ExtractShape dispatches to the correct language-specific extractor // based on the type's file extension. Returns nil when the file isn't // a supported language or the extractor couldn't find any fields. // Callers attach the returned Shape to the type node's meta. func ExtractShape(filePath string, src []byte, startLine, endLine int) *Shape { lang := detectLanguage(filePath) switch lang { case "go": return extractGoShape(src, startLine, endLine) case "typescript", "javascript": return extractTSShape(src, startLine, endLine) case "python": return extractPythonShape(src, startLine, endLine) case "java", "kotlin": return extractJavaShape(src, startLine, endLine) case "dart": return extractDartShape(src, startLine, endLine) case "rust": return extractRustShape(src, startLine, endLine) case "csharp": return extractCSharpShape(src, startLine, endLine) } if strings.HasSuffix(filePath, ".proto") { return extractProtoShape(src, startLine, endLine) } return nil } // sliceBody returns the source lines from start_line to end_line // (1-based, inclusive). When end_line is zero or less than start the // parser didn't record a span — caller should use a brace-walked // fallback or skip. func sliceBody(src []byte, startLine, endLine int) string { if startLine <= 0 { return "" } lines := strings.Split(string(src), "\n") if startLine > len(lines) { return "" } end := endLine if end <= 0 || end > len(lines) { end = len(lines) } if end < startLine { end = startLine } return strings.Join(lines[startLine-1:end], "\n") } // braceBody is the fallback when a type node's end_line isn't // recorded. Walk from start_line forward, count braces, and stop when // the first one closes. func braceBody(src []byte, startLine int, maxLines int) string { lines := strings.Split(string(src), "\n") if startLine <= 0 || startLine > len(lines) { return "" } depth := 0 opened := false end := startLine limit := startLine + maxLines if limit > len(lines) { limit = len(lines) } for i := startLine - 1; i < limit; i++ { line := lines[i] for _, ch := range line { if ch == '{' { depth++ opened = true } else if ch == '}' && opened { depth-- } } end = i + 1 if opened && depth <= 0 { break } } return strings.Join(lines[startLine-1:end], "\n") } // truncateComment keeps a field's doc comment to a reasonable size so // the shape payload doesn't balloon on heavily-annotated types. func truncateComment(s string) string { s = strings.TrimSpace(s) if len(s) > 120 { s = s[:117] + "..." } return s }