Validate schemas without relying on source filenames

This commit is contained in:
2026-11-05 15:59:30 +00:00
parent ff57dbf8b3
commit ac1e150168
5 changed files with 86 additions and 1 deletions

View File

@@ -421,6 +421,8 @@ Schemas are JSON documents selected by a prompt or request `schema_path`.
Directory and `fs.FS` schema paths resolve within the configured root. Each
schema must be self-contained: in-document references, IDs, and anchors are
supported, but references requiring another file or network resource fail.
References must also work without the source filename as a base; use fragment
references or explicit schema IDs rather than relying on that filename.
`WithSchemaFile` exposes one schema under its base name. An unreadable,
invalid, or unresolvable schema causes an operational validation error.
Valid JSON that fails the schema instead yields a failed validation result.

View File

@@ -72,7 +72,12 @@ existing cancellation policy.
### 2. Check Schema Self-Containment Independently Of Its Source Filename [P2]
**Status:** Open. **Stages:** 11–13.
**Status:** Complete. **Stages:** 11–13.
Completion: shared compilation checks both the source base and an unrelated
submission base through the same compiler rules, including unused declarations.
Host and filesystem regressions preserve fragment, explicit-ID, and embedded-ID
references while rejecting unsent source-name dependencies.
**Locations:** [schema compilation](../../internal/validate/standard_validator.go),
`compileSchemaDocument`, lines 426–445, and its host/`fs.FS` callers;

View File

@@ -0,0 +1,46 @@
package validate
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"go.maximumdirect.net/promptkit/internal/domain"
)
func TestSchemaPreparationDoesNotDependOnSourceName(t *testing.T) {
for _, test := range []struct {
name, schema string
rejected bool
}{
{"source name", `{"$defs":{"v":{"type":"integer"}},"$ref":"schema.json#/$defs/v"}`, true},
{"unused source name", `{"$defs":{"v":{"$ref":"schema.json"}}}`, true},
{"fragment", `{"$defs":{"v":{"type":"integer"}},"$ref":"#/$defs/v"}`, false},
{"explicit ID", `{"$id":"https://example.invalid/schema.json","$defs":{"v":{"type":"integer"}},"$ref":"schema.json#/$defs/v"}`, false},
{"embedded ID", `{"$defs":{"v":{"$id":"child","type":"integer"}},"$ref":"child"}`, false},
} {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "schema.json"), []byte(test.schema), 0600); err != nil {
t.Fatal(err)
}
fsys := fstest.MapFS{"schema.json": &fstest.MapFile{Data: []byte(test.schema)}}
for i, validator := range []ValidationPreparer{NewStandardValidator(dir), NewFSValidator(fsys, ".")} {
t.Run(fmt.Sprint(i), func(t *testing.T) {
_, err := validator.PrepareValidation(context.Background(), domain.OutputContract{Format: domain.FormatJSON, ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json"})
if test.rejected {
if err == nil || !strings.Contains(err.Error(), "source location") {
t.Fatalf("expected source-independent rejection, got %v", err)
}
} else if err != nil {
t.Fatal(err)
}
})
}
})
}
}

View File

@@ -424,6 +424,20 @@ func (l externalSchemaLoader) Load(resourceURL string) (any, error) {
}
func compileSchemaDocument(ctx context.Context, resourceURL string, document any) (*jsonschema.Schema, error) {
root, err := compileSchemaAt(ctx, resourceURL, document)
if err != nil {
return nil, err
}
// File and private-FS retrieval URLs are never available to the provider.
// Compiling again under an unrelated HTTPS base detects reliance on those
// aliases without rewriting references or duplicating URI resolution rules.
if _, err := compileSchemaAt(ctx, "https://promptkit.invalid/submitted-schema", document); err != nil {
return nil, fmt.Errorf("schema is not independent of its source location; use fragment references or explicit schema IDs: %w", err)
}
return root, nil
}
func compileSchemaAt(ctx context.Context, resourceURL string, document any) (*jsonschema.Schema, error) {
if err := ctx.Err(); err != nil {
return nil, err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
@@ -11,10 +12,17 @@ import (
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
type rejectWireSchemaLoad struct{}
func (rejectWireSchemaLoad) Load(uri string) (any, error) {
return nil, fmt.Errorf("unexpected external resource %s", uri)
}
func TestSchemaFilePreparationAndNativeSubmission(t *testing.T) {
firstDir, secondDir := t.TempDir(), t.TempDir()
first, second := filepath.Join(firstDir, "schema.json"), filepath.Join(secondDir, "schema.json")
@@ -57,6 +65,16 @@ func TestSchemaFilePreparationAndNativeSubmission(t *testing.T) {
if err != nil || !strings.Contains(string(encoded), `"$defs"`) || !strings.Contains(string(encoded), `#/$defs/value`) {
t.Fatalf("wire schema = %s, %v", encoded, err)
}
compiler := jsonschema.NewCompiler()
compiler.UseLoader(rejectWireSchemaLoad{})
const submission = "https://provider.invalid/request-schema"
document := payload["response_format"].(map[string]any)["json_schema"].(map[string]any)["schema"]
if err := compiler.AddResource(submission, document); err != nil {
t.Fatal(err)
}
if _, err := compiler.Compile(submission); err != nil {
t.Fatalf("outbound schema needs local source context: %v", err)
}
if _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrValidation) {
t.Fatalf("missing root error = %v", err)
}