| 1 | // Package protocolgen generates every committed Extension protocol artifact |
| 2 | // from the frozen Go wire registry and its canonical JSON Schema document. |
| 3 | package protocolgen |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "crypto/sha256" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "fmt" |
| 11 | "go/format" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | |
| 16 | "reasonix/internal/extension/protocol" |
| 17 | ) |
| 18 | |
| 19 | const ( |
| 20 | SchemaArtifactPath = "internal/extension/protocol/schema.generated.json" |
| 21 | HashArtifactPath = "internal/extension/protocol/schema_hash.generated.go" |
| 22 | MarkdownArtifactPath = "docs/EXTENSION_PROTOCOL.generated.md" |
| 23 | // SDKTypesArtifactPath is the Go DTO mirror compiled into the stdlib-only |
| 24 | // extension SDK module. |
| 25 | SDKTypesArtifactPath = "sdk/go/types_generated.go" |
| 26 | ) |
| 27 | |
| 28 | // Artifact is one deterministic generated file, relative to the repository |
| 29 | // root. Artifacts are always returned in the stable order declared above. |
| 30 | type Artifact struct { |
| 31 | Path string |
| 32 | Data []byte |
| 33 | } |
| 34 | |
| 35 | // Generate builds all Extension protocol artifacts without reading the |
| 36 | // committed outputs. The schema hash is calculated from the exact JSON bytes |
| 37 | // returned as the schema artifact. |
| 38 | func Generate() ([]Artifact, error) { |
| 39 | if err := protocol.ValidateRegistry(); err != nil { |
| 40 | return nil, fmt.Errorf("validate registry: %w", err) |
| 41 | } |
| 42 | document, err := protocol.BuildSchemaDocument() |
| 43 | if err != nil { |
| 44 | return nil, fmt.Errorf("build schema: %w", err) |
| 45 | } |
| 46 | canonical, err := protocol.CanonicalSchemaBytes() |
| 47 | if err != nil { |
| 48 | return nil, fmt.Errorf("canonical schema: %w", err) |
| 49 | } |
| 50 | encoded, err := json.Marshal(document) |
| 51 | if err != nil { |
| 52 | return nil, fmt.Errorf("marshal schema document: %w", err) |
| 53 | } |
| 54 | if !bytes.Equal(canonical, encoded) { |
| 55 | return nil, fmt.Errorf("canonical schema bytes do not match BuildSchemaDocument") |
| 56 | } |
| 57 | |
| 58 | digest := sha256.Sum256(canonical) |
| 59 | schemaHash := "sha256:" + hex.EncodeToString(digest[:]) |
| 60 | hashSource, err := generateSchemaHashGo(schemaHash) |
| 61 | if err != nil { |
| 62 | return nil, err |
| 63 | } |
| 64 | markdown, err := generateMarkdown(schemaHash) |
| 65 | if err != nil { |
| 66 | return nil, fmt.Errorf("generate markdown: %w", err) |
| 67 | } |
| 68 | sdkTypes, err := generateSDKTypesGo() |
| 69 | if err != nil { |
| 70 | return nil, fmt.Errorf("generate sdk types: %w", err) |
| 71 | } |
| 72 | |
| 73 | return []Artifact{ |
| 74 | {Path: SchemaArtifactPath, Data: append([]byte(nil), canonical...)}, |
| 75 | {Path: HashArtifactPath, Data: hashSource}, |
| 76 | {Path: MarkdownArtifactPath, Data: markdown}, |
| 77 | {Path: SDKTypesArtifactPath, Data: sdkTypes}, |
| 78 | }, nil |
| 79 | } |
| 80 | |
| 81 | func generateSchemaHashGo(schemaHash string) ([]byte, error) { |
| 82 | source := fmt.Sprintf(`// Code generated by cmd/extension-protocol-gen; DO NOT EDIT. |
| 83 | |
| 84 | package protocol |
| 85 | |
| 86 | // GeneratedSchemaHash is the SHA-256 of schema.generated.json. Handshake |
| 87 | // comparisons use this constant; protocol tests independently recompute it |
| 88 | // from CanonicalSchemaBytes to reject stale generated artifacts. |
| 89 | const GeneratedSchemaHash = %q |
| 90 | `, schemaHash) |
| 91 | formatted, err := format.Source([]byte(source)) |
| 92 | if err != nil { |
| 93 | return nil, fmt.Errorf("format schema hash source: %w", err) |
| 94 | } |
| 95 | return formatted, nil |
| 96 | } |
| 97 | |
| 98 | // generateMarkdown renders the generated method/event/limits/error index. |
| 99 | // Hand-written prose documentation lives elsewhere; this document is the |
| 100 | // machine-frozen contract summary and always carries the schema hash. |
| 101 | func generateMarkdown(schemaHash string) ([]byte, error) { |
| 102 | var out strings.Builder |
| 103 | out.WriteString("<!-- Code generated by cmd/extension-protocol-gen; DO NOT EDIT. -->\n\n") |
| 104 | out.WriteString("# Reasonix Extension Protocol v1 — Generated Index\n\n") |
| 105 | fmt.Fprintf(&out, "- Protocol ID: `%s`\n", protocol.ProtocolID) |
| 106 | fmt.Fprintf(&out, "- Protocol major: `%d`\n", protocol.ProtocolMajor) |
| 107 | fmt.Fprintf(&out, "- Schema: `%s`\n", SchemaArtifactPath) |
| 108 | fmt.Fprintf(&out, "- Schema hash: `%s`\n\n", schemaHash) |
| 109 | out.WriteString("Within major v1 only optional fields, new enum values, and new methods may\n") |
| 110 | out.WriteString("be added; existing required fields, directions, limits, error reasons, and\n") |
| 111 | out.WriteString("semantics never change.\n\n") |
| 112 | |
| 113 | out.WriteString("## Methods\n\n") |
| 114 | out.WriteString("| Method | Direction | Class | Params | Result |\n") |
| 115 | out.WriteString("| --- | --- | --- | --- | --- |\n") |
| 116 | for _, spec := range protocol.Registry() { |
| 117 | result := spec.ResultType.Name() |
| 118 | if spec.Notification() { |
| 119 | result = "-" |
| 120 | } |
| 121 | fmt.Fprintf(&out, "| `%s` | `%s` | `%s` | `%s` | `%s` |\n", |
| 122 | spec.Name, spec.Direction, spec.Class, spec.ParamsType.Name(), result) |
| 123 | } |
| 124 | |
| 125 | events := protocol.InterceptEvents() |
| 126 | fmt.Fprintf(&out, "\n## Intercept events (%d)\n\n", len(events)) |
| 127 | out.WriteString("`extension/intercept` (blocking) and `extension/event` (observation) share\n") |
| 128 | out.WriteString("these frozen hook points:\n\n") |
| 129 | for _, event := range events { |
| 130 | fmt.Fprintf(&out, "- `%s`\n", event) |
| 131 | } |
| 132 | |
| 133 | limits := protocol.FrozenLimits() |
| 134 | out.WriteString("\n## Limits\n\n") |
| 135 | out.WriteString("| Limit | Value |\n") |
| 136 | out.WriteString("| --- | --- |\n") |
| 137 | fmt.Fprintf(&out, "| `frameBytes` | %d |\n", limits.FrameBytes) |
| 138 | fmt.Fprintf(&out, "| `externalizeFieldBytes` | %d |\n", limits.ExternalizeFieldBytes) |
| 139 | fmt.Fprintf(&out, "| `contentRefChunkBytes` | %d |\n", limits.ContentRefChunkBytes) |
| 140 | fmt.Fprintf(&out, "| `contentRefObjectBytes` | %d |\n", limits.ContentRefObjectBytes) |
| 141 | |
| 142 | out.WriteString("\n## Errors\n\n") |
| 143 | out.WriteString("| Reason | JSON-RPC code | Retryable | Message |\n") |
| 144 | out.WriteString("| --- | --- | --- | --- |\n") |
| 145 | for _, contract := range protocol.ErrorContracts() { |
| 146 | fmt.Fprintf(&out, "| `%s` | %d | %t | %s |\n", |
| 147 | contract.Reason, contract.JSONRPCCode, contract.Retryable, contract.Message) |
| 148 | } |
| 149 | return []byte(out.String()), nil |
| 150 | } |
| 151 | |
| 152 | // Write writes a generated artifact set below root. |
| 153 | func Write(root string, artifacts []Artifact) error { |
| 154 | for _, artifact := range artifacts { |
| 155 | path := filepath.Join(root, filepath.FromSlash(artifact.Path)) |
| 156 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 157 | return fmt.Errorf("create directory for %s: %w", artifact.Path, err) |
| 158 | } |
| 159 | if err := os.WriteFile(path, artifact.Data, 0o644); err != nil { |
| 160 | return fmt.Errorf("write %s: %w", artifact.Path, err) |
| 161 | } |
| 162 | } |
| 163 | return nil |
| 164 | } |
| 165 | |
| 166 | // Check compares a generated artifact set byte-for-byte with files below root. |
| 167 | func Check(root string, artifacts []Artifact) error { |
| 168 | for _, artifact := range artifacts { |
| 169 | path := filepath.Join(root, filepath.FromSlash(artifact.Path)) |
| 170 | committed, err := os.ReadFile(path) |
| 171 | if err != nil { |
| 172 | return fmt.Errorf("read %s: %w", artifact.Path, err) |
| 173 | } |
| 174 | if !bytes.Equal(committed, artifact.Data) { |
| 175 | return fmt.Errorf("generated artifact drift: %s", artifact.Path) |
| 176 | } |
| 177 | } |
| 178 | return nil |
| 179 | } |
| 180 |