← 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
M internal/pipeline/fullrun.goM internal/pipeline/fullrun_test.goM skills/printing-press-score/SKILL.md
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 →