diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a5613..74fd7f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ All notable changes to tagged releases are documented here. republished under a second `0.1.0-draft` tag. The release gate already treats a full-length commit digest as an immutable reference; the pin moves back to a tag once a specification version carrying those identifiers is published. +- Pin the machine contract in tests: `outputVersion`, `tool.name`, the six exit classes, and the + four envelope members every JSON payload carries. These were previously enforced only by + convention, so a rename or an accidental version bump passed silently. ## 0.0.1 - 2026-07-23 diff --git a/internal/cli/app.go b/internal/cli/app.go index 03ef243..5118380 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1,9 +1,6 @@ package cli import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" "fmt" "io" @@ -16,6 +13,7 @@ import ( "github.com/Judgment-Pack/judgment-pack-runtime/internal/artifacts" "github.com/Judgment-Pack/judgment-pack-runtime/internal/carrier" "github.com/Judgment-Pack/judgment-pack-runtime/internal/conformance" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/describe" "github.com/Judgment-Pack/judgment-pack-runtime/internal/display" "github.com/Judgment-Pack/judgment-pack-runtime/internal/fssecure" "github.com/Judgment-Pack/judgment-pack-runtime/internal/result" @@ -121,14 +119,7 @@ func (a *App) versionCommand() *cobra.Command { if err != nil { return a.operational("version", format, result.ExitInternal, "JPS-ARTIFACT-INTEGRITY", "Bundled artifact metadata is unavailable.") } - output := result.Version{ - OutputVersion: result.OutputVersion, - Tool: result.CurrentTool(), - Command: "version", - Status: "valid", - SupportedSpecs: artifacts.SupportedVersions(), - ArtifactProvenance: set.Lock().Source.Kind, - } + output := describe.Runtime(set, "version") if format == "json" { if err := writeJSON(a.out, output); err != nil { return &handledExit{code: result.ExitIO} @@ -271,23 +262,11 @@ func (a *App) schemaCommand() *cobra.Command { } writtenTo = writeTarget } - var schemaDocument map[string]any - if err := json.Unmarshal(schemaBytes, &schemaDocument); err != nil { + output, err := describe.Schema(set, args[0], "spec schema", schemaBytes) + if err != nil { return a.operational("spec schema", format, result.ExitInternal, "JPS-ARTIFACT-SCHEMA", "Bundled schema metadata is invalid.") } - sum := sha256.Sum256(schemaBytes) - output := result.Schema{ - OutputVersion: result.OutputVersion, - Tool: result.CurrentTool(), - Command: "spec schema", - Status: "valid", - SpecVersion: args[0], - SchemaID: stringFrom(schemaDocument["$id"]), - Bytes: len(schemaBytes), - SHA256: hex.EncodeToString(sum[:]), - Provenance: set.Lock().Source.Kind, - WrittenTo: writtenTo, - } + output.WrittenTo = writtenTo if err := a.renderSchema(format, output); err != nil { return &handledExit{code: result.ExitIO} } @@ -376,11 +355,6 @@ func validationExit(status string) int { } } -func stringFrom(value any) string { - text, _ := value.(string) - return text -} - func requestedFormat(args []string) string { for index, argument := range args { if argument == "--format=json" { diff --git a/internal/describe/describe.go b/internal/describe/describe.go new file mode 100644 index 0000000..ac9cdbe --- /dev/null +++ b/internal/describe/describe.go @@ -0,0 +1,63 @@ +// Package describe composes machine-readable descriptions of this runtime and +// the specification artifacts embedded in it. +// +// It holds no CLI, transport, or presentation concerns, so every surface that +// reports these values -- the CLI today, other adapters later -- produces the +// same payload rather than a per-surface reimplementation of it. +package describe + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + + "github.com/Judgment-Pack/judgment-pack-runtime/internal/artifacts" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/result" +) + +// Runtime composes the runtime-description payload for a loaded artifact set. +// +// command names the operation reporting the payload so a surface can identify +// itself without forking the shape of the result. +func Runtime(set *artifacts.Set, command string) result.Version { + return result.Version{ + OutputVersion: result.OutputVersion, + Tool: result.CurrentTool(), + Command: command, + Status: "valid", + SupportedSpecs: artifacts.SupportedVersions(), + ArtifactProvenance: set.Lock().Source.Kind, + } +} + +// Schema composes metadata describing the exact bundled schema bytes of +// specVersion: the schema's declared $id, its size, and its SHA-256. +// +// schemaBytes must be the original bundled bytes, not a re-encoding, because +// the reported size and digest describe those exact bytes. An error is returned +// only when they do not decode as a JSON object. Callers that write the schema +// out set WrittenTo on the returned value themselves; it is a property of the +// caller's action, not of the artifact. +func Schema(set *artifacts.Set, specVersion, command string, schemaBytes []byte) (result.Schema, error) { + var document map[string]any + if err := json.Unmarshal(schemaBytes, &document); err != nil { + return result.Schema{}, err + } + sum := sha256.Sum256(schemaBytes) + return result.Schema{ + OutputVersion: result.OutputVersion, + Tool: result.CurrentTool(), + Command: command, + Status: "valid", + SpecVersion: specVersion, + SchemaID: stringFrom(document["$id"]), + Bytes: len(schemaBytes), + SHA256: hex.EncodeToString(sum[:]), + Provenance: set.Lock().Source.Kind, + }, nil +} + +func stringFrom(value any) string { + text, _ := value.(string) + return text +} diff --git a/internal/describe/describe_test.go b/internal/describe/describe_test.go new file mode 100644 index 0000000..5bd283c --- /dev/null +++ b/internal/describe/describe_test.go @@ -0,0 +1,82 @@ +package describe + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/Judgment-Pack/judgment-pack-runtime/internal/artifacts" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/result" +) + +func loadSet(t *testing.T) *artifacts.Set { + t.Helper() + set, err := artifacts.Load(artifacts.DraftVersion) + if err != nil { + t.Fatal(err) + } + return set +} + +func TestRuntimeReportsBundledProvenance(t *testing.T) { + set := loadSet(t) + described := Runtime(set, "version") + + if described.OutputVersion != result.OutputVersion { + t.Errorf("outputVersion = %q, want %q", described.OutputVersion, result.OutputVersion) + } + if described.Command != "version" { + t.Errorf("command = %q, want %q", described.Command, "version") + } + if described.Status != "valid" { + t.Errorf("status = %q, want %q", described.Status, "valid") + } + if described.ArtifactProvenance != set.Lock().Source.Kind { + t.Errorf("provenance = %q, want the lock's %q", described.ArtifactProvenance, set.Lock().Source.Kind) + } + if len(described.SupportedSpecs) == 0 { + t.Error("supported specification versions must not be empty") + } +} + +// The reported size and digest must describe the exact bundled bytes. A caller +// that re-encoded the schema before describing it would produce a digest that +// no longer identifies the artifact the lock verifies. +func TestSchemaDescribesTheExactBundledBytes(t *testing.T) { + set := loadSet(t) + schemaBytes, err := set.Schema() + if err != nil { + t.Fatal(err) + } + + described, err := Schema(set, artifacts.DraftVersion, "spec schema", schemaBytes) + if err != nil { + t.Fatal(err) + } + + if described.Bytes != len(schemaBytes) { + t.Errorf("bytes = %d, want %d", described.Bytes, len(schemaBytes)) + } + sum := sha256.Sum256(schemaBytes) + if want := hex.EncodeToString(sum[:]); described.SHA256 != want { + t.Errorf("sha256 = %q, want %q", described.SHA256, want) + } + if described.SpecVersion != artifacts.DraftVersion { + t.Errorf("specVersion = %q, want %q", described.SpecVersion, artifacts.DraftVersion) + } + if described.SchemaID == "" { + t.Error("schemaId must be reported from the schema's own $id") + } + // WrittenTo describes a caller's action, not the artifact, so this package + // must never populate it. + if described.WrittenTo != "" { + t.Errorf("writtenTo = %q, want empty", described.WrittenTo) + } +} + +func TestSchemaRejectsNonObjectBytes(t *testing.T) { + set := loadSet(t) + if _, err := Schema(set, artifacts.DraftVersion, "spec schema", []byte("not a schema")); err == nil { + t.Fatal("expected an error for bytes that are not a JSON object") + } +} diff --git a/internal/mcp/doc.go b/internal/mcp/doc.go new file mode 100644 index 0000000..621ce72 --- /dev/null +++ b/internal/mcp/doc.go @@ -0,0 +1,13 @@ +// Package mcp will expose this runtime over the Model Context Protocol. +// +// It is a transport adapter only: it maps MCP tool and resource calls onto the +// existing validation, conformance, and describe packages and returns their +// versioned results. It evaluates no condition, resolves no outcome, and adds +// no judgment behavior of its own -- an MCP client reaches exactly the same +// core the CLI reaches, over JSON-RPC instead of argv. +// +// The adapter is intended to run as an "mcp" subcommand of the single +// judgment-pack binary rather than as a separate program. +// +// This package is a scaffold and currently has no implementation. +package mcp diff --git a/internal/result/result_test.go b/internal/result/result_test.go new file mode 100644 index 0000000..604be83 --- /dev/null +++ b/internal/result/result_test.go @@ -0,0 +1,71 @@ +package result + +import ( + "encoding/json" + "testing" +) + +// The values below are the machine-facing contract: consumers branch on the +// exit status and on outputVersion. Changing any of them is a compatibility +// break that must be a deliberate, documented decision rather than a side +// effect of an unrelated edit, so they are pinned by literal here. + +func TestOutputVersionIsPinned(t *testing.T) { + if OutputVersion != "1" { + t.Fatalf("outputVersion is part of the machine contract: got %q, want %q", OutputVersion, "1") + } + if CLIName != "judgment-pack" { + t.Fatalf("tool.name is part of the machine contract: got %q", CLIName) + } +} + +func TestExitClassesArePinned(t *testing.T) { + for _, pinned := range []struct { + name string + got int + want int + }{ + {"ExitSuccess", ExitSuccess, 0}, + {"ExitInvalid", ExitInvalid, 1}, + {"ExitUnsupported", ExitUnsupported, 2}, + {"ExitInvocation", ExitInvocation, 3}, + {"ExitIO", ExitIO, 4}, + {"ExitInternal", ExitInternal, 5}, + } { + if pinned.got != pinned.want { + t.Errorf("%s is part of the machine contract: got %d, want %d", pinned.name, pinned.got, pinned.want) + } + } +} + +// Every payload carries the same four identifying members. A rename of any of +// them is invisible to Go's type checker but breaks every consumer, so assert +// the emitted JSON rather than the struct. +func TestEveryPayloadCarriesTheCommonEnvelope(t *testing.T) { + for _, payload := range []struct { + name string + value any + }{ + {"Validation", Validation{OutputVersion: OutputVersion, Tool: CurrentTool(), Command: "spec validate", Status: "valid"}}, + {"Suite", Suite{OutputVersion: OutputVersion, Tool: CurrentTool(), Command: "spec test-conformance", Status: "valid"}}, + {"Schema", Schema{OutputVersion: OutputVersion, Tool: CurrentTool(), Command: "spec schema", Status: "valid"}}, + {"Version", Version{OutputVersion: OutputVersion, Tool: CurrentTool(), Command: "version", Status: "valid"}}, + } { + encoded, err := json.Marshal(payload.value) + if err != nil { + t.Fatalf("%s: %v", payload.name, err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("%s: %v", payload.name, err) + } + for _, member := range []string{"outputVersion", "tool", "command", "status"} { + if _, present := decoded[member]; !present { + t.Errorf("%s payload is missing the %q member: %s", payload.name, member, encoded) + } + } + if decoded["outputVersion"] != "1" { + t.Errorf("%s payload reports outputVersion %v, want \"1\"", payload.name, decoded["outputVersion"]) + } + } +} diff --git a/sdk/doc.go b/sdk/doc.go new file mode 100644 index 0000000..d95c4bd --- /dev/null +++ b/sdk/doc.go @@ -0,0 +1,13 @@ +// Package sdk is the reserved home for a public, in-process Go composition API +// over the judgment-pack runtime -- the seam that lets a Go program embed +// validation directly instead of invoking the binary. +// +// It is distinct from a client library in another language: non-Go consumers +// reach the runtime over a wire protocol (the future HTTP or MCP adapters) or +// by invoking the binary, not by importing this package. +// +// Stability: experimental. While the project is pre-1.0 this package exports +// nothing and carries no compatibility guarantee; do not depend on it yet. +// +// This package is a scaffold and currently has no implementation. +package sdk