[object Object]

← back to Cli Printing Press

fix(pipeline): handle remote URLs and YAML-to-JSON conversion in spec copy

2ce13e775ad288da78134266cd1b0124d05b5a0a · 2026-03-27 21:55:31 -0700 · Trevin Chow

- copySpecToOutput now fetches remote specs via HTTP GET (covers
  Petstore/Plaid-style runs where specURL is a URL, not a local file)
- YAML specs are automatically converted to JSON via yaml.v3 so the
  scorecard's loadOpenAPISpec (json.Unmarshal) can parse them
- Always outputs spec.json regardless of source format
- Skill simplified back to checking only spec.json
- Added httptest-based tests for remote URL and remote YAML scenarios

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 2ce13e775ad288da78134266cd1b0124d05b5a0a
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri Mar 27 21:55:31 2026 -0700

    fix(pipeline): handle remote URLs and YAML-to-JSON conversion in spec copy
    
    - copySpecToOutput now fetches remote specs via HTTP GET (covers
      Petstore/Plaid-style runs where specURL is a URL, not a local file)
    - YAML specs are automatically converted to JSON via yaml.v3 so the
      scorecard's loadOpenAPISpec (json.Unmarshal) can parse them
    - Always outputs spec.json regardless of source format
    - Skill simplified back to checking only spec.json
    - Added httptest-based tests for remote URL and remote YAML scenarios
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/pipeline/fullrun.go         |  51 ++++++++++++++---
 internal/pipeline/fullrun_test.go    | 108 +++++++++++++++++++++++++++--------
 skills/printing-press-score/SKILL.md |   2 +-
 3 files changed, 126 insertions(+), 35 deletions(-)

diff --git a/internal/pipeline/fullrun.go b/internal/pipeline/fullrun.go
index 09b2ec0c..57b63a56 100644
--- a/internal/pipeline/fullrun.go
+++ b/internal/pipeline/fullrun.go
@@ -1,7 +1,10 @@
 package pipeline
 
 import (
+	"encoding/json"
 	"fmt"
+	"io"
+	"net/http"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -9,6 +12,7 @@ import (
 	"time"
 
 	"github.com/mvanhorn/cli-printing-press/internal/llmpolish"
+	"gopkg.in/yaml.v3"
 )
 
 // FullRunResult holds everything the press produced for one API.
@@ -422,28 +426,57 @@ func PrintComparisonTable(results []*FullRunResult) string {
 	return b.String()
 }
 
-// copySpecToOutput copies the source spec file into <outputDir>/spec.<ext>
-// preserving the original extension so YAML specs stay YAML and JSON stays JSON.
-// Only copies when specFlag is "--spec" (local file). Errors are non-fatal.
+// copySpecToOutput reads the spec from a local path or remote URL, converts
+// YAML to JSON if needed, and writes it as <outputDir>/spec.json.
+// Only runs when specFlag is "--spec". Errors are non-fatal.
 func copySpecToOutput(specFlag, specURL, outputDir string) error {
-	if specFlag != "--spec" {
+	if specFlag != "--spec" || specURL == "" {
 		return nil
 	}
-	data, err := os.ReadFile(specURL)
+	data, err := readSpecBytes(specURL)
 	if err != nil {
 		return fmt.Errorf("reading spec %s: %w", specURL, err)
 	}
-	ext := filepath.Ext(specURL)
-	if ext == "" {
-		ext = ".json"
+	data, err = ensureJSON(data)
+	if err != nil {
+		return fmt.Errorf("converting spec to JSON: %w", err)
 	}
-	dst := filepath.Join(outputDir, "spec"+ext)
+	dst := filepath.Join(outputDir, "spec.json")
 	if err := os.WriteFile(dst, data, 0o644); err != nil {
 		return fmt.Errorf("writing %s: %w", dst, err)
 	}
 	return nil
 }
 
+// readSpecBytes fetches spec content from a URL or reads it from a local file.
+func readSpecBytes(specURL string) ([]byte, error) {
+	if strings.HasPrefix(specURL, "http://") || strings.HasPrefix(specURL, "https://") {
+		resp, err := http.Get(specURL) //nolint:gosec // spec URLs are operator-provided
+		if err != nil {
+			return nil, err
+		}
+		defer resp.Body.Close()
+		if resp.StatusCode != http.StatusOK {
+			return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, specURL)
+		}
+		return io.ReadAll(resp.Body)
+	}
+	return os.ReadFile(specURL)
+}
+
+// ensureJSON converts YAML content to JSON. If the input is already valid
+// JSON, it is returned as-is.
+func ensureJSON(data []byte) ([]byte, error) {
+	if json.Valid(data) {
+		return data, nil
+	}
+	var obj interface{}
+	if err := yaml.Unmarshal(data, &obj); err != nil {
+		return nil, fmt.Errorf("not valid JSON or YAML: %w", err)
+	}
+	return json.Marshal(obj)
+}
+
 func writeRow(b *strings.Builder, label string, results []*FullRunResult, fn func(*FullRunResult) string) {
 	b.WriteString(fmt.Sprintf("%-25s", label))
 	for _, r := range results {
diff --git a/internal/pipeline/fullrun_test.go b/internal/pipeline/fullrun_test.go
index 7cd1d007..f8ed81fa 100644
--- a/internal/pipeline/fullrun_test.go
+++ b/internal/pipeline/fullrun_test.go
@@ -1,11 +1,13 @@
 package pipeline
 
 import (
+	"encoding/json"
 	"fmt"
+	"net/http"
+	"net/http/httptest"
 	"os"
 	"os/exec"
 	"path/filepath"
-	"strings"
 	"testing"
 	"time"
 
@@ -65,41 +67,45 @@ func TestFullRun(t *testing.T) {
 
 func TestCopySpecToOutput(t *testing.T) {
 	tests := []struct {
-		name      string
-		specFlag  string
-		setup     func(t *testing.T, dir string) string // returns specURL
-		wantFile  string                                 // expected output filename
-		wantError bool
+		name       string
+		specFlag   string
+		setup      func(t *testing.T, dir string) string // returns specURL
+		wantCopy   bool
+		wantJSON   bool // if true, verify output is valid JSON
+		wantError  bool
 	}{
 		{
-			name:     "copies json spec preserving extension",
+			name:     "copies local json spec",
 			specFlag: "--spec",
 			setup: func(t *testing.T, dir string) string {
 				specPath := filepath.Join(dir, "input-spec.json")
 				require.NoError(t, os.WriteFile(specPath, []byte(`{"openapi":"3.0.0"}`), 0o644))
 				return specPath
 			},
-			wantFile: "spec.json",
+			wantCopy: true,
+			wantJSON: true,
 		},
 		{
-			name:     "copies yaml spec preserving extension",
+			name:     "converts local yaml spec to json",
 			specFlag: "--spec",
 			setup: func(t *testing.T, dir string) string {
 				specPath := filepath.Join(dir, "openapi.yaml")
-				require.NoError(t, os.WriteFile(specPath, []byte("openapi: 3.0.0\n"), 0o644))
+				require.NoError(t, os.WriteFile(specPath, []byte("openapi: \"3.0.0\"\ninfo:\n  title: Test\n"), 0o644))
 				return specPath
 			},
-			wantFile: "spec.yaml",
+			wantCopy: true,
+			wantJSON: true,
 		},
 		{
-			name:     "copies yml spec preserving extension",
+			name:     "converts local yml spec to json",
 			specFlag: "--spec",
 			setup: func(t *testing.T, dir string) string {
 				specPath := filepath.Join(dir, "api.yml")
-				require.NoError(t, os.WriteFile(specPath, []byte("openapi: 3.0.0\n"), 0o644))
+				require.NoError(t, os.WriteFile(specPath, []byte("openapi: \"3.0.0\"\n"), 0o644))
 				return specPath
 			},
-			wantFile: "spec.yml",
+			wantCopy: true,
+			wantJSON: true,
 		},
 		{
 			name:     "skips when flag is --docs",
@@ -116,7 +122,14 @@ func TestCopySpecToOutput(t *testing.T) {
 			},
 		},
 		{
-			name:     "returns error when spec file missing",
+			name:     "skips when specURL is empty",
+			specFlag: "--spec",
+			setup: func(t *testing.T, dir string) string {
+				return ""
+			},
+		},
+		{
+			name:     "returns error when local spec file missing",
 			specFlag: "--spec",
 			setup: func(t *testing.T, dir string) string {
 				return filepath.Join(dir, "nonexistent.json")
@@ -140,23 +153,68 @@ func TestCopySpecToOutput(t *testing.T) {
 				assert.NoError(t, err)
 			}
 
-			if tt.wantFile != "" {
-				dst := filepath.Join(outputDir, tt.wantFile)
+			dst := filepath.Join(outputDir, "spec.json")
+			if tt.wantCopy {
 				data, readErr := os.ReadFile(dst)
-				require.NoError(t, readErr, "%s should exist in output dir", tt.wantFile)
-				expected, _ := os.ReadFile(specURL)
-				assert.Equal(t, expected, data, "content should match source")
-			} else if !tt.wantError {
-				// Verify no spec file was created
-				entries, _ := os.ReadDir(outputDir)
-				for _, e := range entries {
-					assert.False(t, strings.HasPrefix(e.Name(), "spec."), "no spec file should exist, found %s", e.Name())
+				require.NoError(t, readErr, "spec.json should exist in output dir")
+				if tt.wantJSON {
+					assert.True(t, json.Valid(data), "spec.json should be valid JSON, got: %s", string(data))
 				}
+			} else if !tt.wantError {
+				_, readErr := os.ReadFile(dst)
+				assert.True(t, os.IsNotExist(readErr), "spec.json should not exist")
 			}
 		})
 	}
 }
 
+func TestCopySpecToOutput_RemoteURL(t *testing.T) {
+	// Test with a local HTTP server to verify remote URL handling
+	specJSON := `{"openapi":"3.0.0","info":{"title":"Test"}}`
+
+	ts := httpTestServer(t, specJSON)
+	defer ts.Close()
+
+	dir := t.TempDir()
+	outputDir := filepath.Join(dir, "output")
+	require.NoError(t, os.MkdirAll(outputDir, 0o755))
+
+	err := copySpecToOutput("--spec", ts.URL+"/spec.json", outputDir)
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(outputDir, "spec.json"))
+	require.NoError(t, err)
+	assert.True(t, json.Valid(data))
+	assert.Contains(t, string(data), "Test")
+}
+
+func TestCopySpecToOutput_RemoteYAML(t *testing.T) {
+	specYAML := "openapi: \"3.0.0\"\ninfo:\n  title: RemoteYAML\n"
+
+	ts := httpTestServer(t, specYAML)
+	defer ts.Close()
+
+	dir := t.TempDir()
+	outputDir := filepath.Join(dir, "output")
+	require.NoError(t, os.MkdirAll(outputDir, 0o755))
+
+	err := copySpecToOutput("--spec", ts.URL+"/spec.yaml", outputDir)
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(outputDir, "spec.json"))
+	require.NoError(t, err)
+	assert.True(t, json.Valid(data), "remote YAML should be converted to JSON")
+	assert.Contains(t, string(data), "RemoteYAML")
+}
+
+func httpTestServer(t *testing.T, body string) *httptest.Server {
+	t.Helper()
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(body))
+	}))
+}
+
 func findRepoRoot() string {
 	dir, _ := os.Getwd()
 	for {
diff --git a/skills/printing-press-score/SKILL.md b/skills/printing-press-score/SKILL.md
index 012ee41d..20cca286 100644
--- a/skills/printing-press-score/SKILL.md
+++ b/skills/printing-press-score/SKILL.md
@@ -77,7 +77,7 @@ If nothing resolves, report the error: "Could not find CLI '<name>'. Provide a p
 
 For each resolved CLI directory, find the OpenAPI spec:
 
-1. Check for a spec file in `<cli-dir>/` — look for `spec.json`, `spec.yaml`, or `spec.yml` (the extension is preserved from the original spec during generation)
+1. Check `<cli-dir>/spec.json` — the pipeline converts YAML specs to JSON during generation
 2. If not found, scan `docs/plans/*-pipeline/state.json` files for one matching this CLI's directory. Read its `spec_path` field. If that file exists on disk, use it.
 3. If no spec found, **proceed without `--spec`**. Note to the user: "No spec found — Tier 2 (domain correctness) scores will be 0. Provide a spec path to get full scoring."
 

← 5ed49878 docs(scorecard): document intentional workflow/insight prefi  ·  back to Cli Printing Press  ·  fix(scorecard): fix PassRate units, gate sync path-param cre 49f5d8b7 →