[object Object]

← back to Cli Printing Press

fix(cli): gate publishing on transcendence features (#293)

ac74e7d2d7dcd661fc4532ecf943933591171b76 · 2026-04-25 16:13:01 -0700 · Trevin Chow

Files touched

Diff

commit ac74e7d2d7dcd661fc4532ecf943933591171b76
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Apr 25 16:13:01 2026 -0700

    fix(cli): gate publishing on transcendence features (#293)
---
 internal/cli/generate_test.go   | 138 ++++++++++++++++++++++++++++++++++++++++
 internal/cli/library_test.go    |   7 ++
 internal/cli/publish.go         |  35 ++++++----
 internal/cli/publish_test.go    |  43 +++++++++++--
 internal/cli/root.go            |  45 ++++++++-----
 internal/openapi/parser.go      |  29 ++++++++-
 internal/openapi/parser_test.go |  56 ++++++++++++++++
 7 files changed, 322 insertions(+), 31 deletions(-)

diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index e35ed600..dbdb7da6 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -1,12 +1,14 @@
 package cli
 
 import (
+	"encoding/json"
 	"go/parser"
 	"go/token"
 	"os"
 	"path/filepath"
 	"testing"
 
+	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -92,6 +94,142 @@ resources:
 	require.NoError(t, err)
 }
 
+func TestGenerateCmdDoesNotCarryPlannedNovelFeaturesIntoManifest(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	specPath := filepath.Join(dir, "spec.yaml")
+	researchDir := filepath.Join(dir, "research")
+	outputDir := filepath.Join(dir, "novelapp")
+	require.NoError(t, os.MkdirAll(researchDir, 0o755))
+
+	require.NoError(t, os.WriteFile(specPath, []byte(`name: novelapp
+description: Novel app API
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/novelapp-pp-cli/config.toml
+resources:
+  items:
+    description: Manage items
+    endpoints:
+      list:
+        method: GET
+        path: /items
+        description: List items
+`), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(researchDir, "research.json"), []byte(`{
+  "api_name": "novelapp",
+  "novelty_score": 8,
+  "alternatives": [],
+  "gaps": [],
+  "patterns": [],
+  "recommendation": "proceed",
+  "researched_at": "2026-04-25T00:00:00Z",
+  "novel_features": [
+    {
+      "name": "Item insight",
+      "command": "items insight",
+      "description": "Summarize item state",
+      "rationale": "Requires local correlation"
+    }
+  ]
+}`), 0o644))
+
+	cmd := newGenerateCmd()
+	cmd.SetArgs([]string{
+		"--spec", specPath,
+		"--output", outputDir,
+		"--validate=false",
+		"--force",
+		"--research-dir", researchDir,
+	})
+
+	require.NoError(t, cmd.Execute())
+
+	data, err := os.ReadFile(filepath.Join(outputDir, pipeline.CLIManifestFilename))
+	require.NoError(t, err)
+	var manifest pipeline.CLIManifest
+	require.NoError(t, json.Unmarshal(data, &manifest))
+	assert.Empty(t, manifest.NovelFeatures)
+}
+
+func TestGenerateCmdCarriesVerifiedNovelFeaturesIntoManifest(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	specPath := filepath.Join(dir, "spec.yaml")
+	researchDir := filepath.Join(dir, "research")
+	outputDir := filepath.Join(dir, "verifiedapp")
+	require.NoError(t, os.MkdirAll(researchDir, 0o755))
+
+	require.NoError(t, os.WriteFile(specPath, []byte(`name: verifiedapp
+description: Verified app API
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/verifiedapp-pp-cli/config.toml
+resources:
+  items:
+    description: Manage items
+    endpoints:
+      list:
+        method: GET
+        path: /items
+        description: List items
+`), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(researchDir, "research.json"), []byte(`{
+  "api_name": "verifiedapp",
+  "novelty_score": 8,
+  "alternatives": [],
+  "gaps": [],
+  "patterns": [],
+  "recommendation": "proceed",
+  "researched_at": "2026-04-25T00:00:00Z",
+  "novel_features": [
+    {
+      "name": "Planned insight",
+      "command": "items planned",
+      "description": "Planned but not verified",
+      "rationale": "Requires local correlation"
+    }
+  ],
+  "novel_features_built": [
+    {
+      "name": "Built insight",
+      "command": "items insight",
+      "description": "Summarize item state",
+      "rationale": "Requires local correlation"
+    }
+  ]
+}`), 0o644))
+
+	cmd := newGenerateCmd()
+	cmd.SetArgs([]string{
+		"--spec", specPath,
+		"--output", outputDir,
+		"--validate=false",
+		"--force",
+		"--research-dir", researchDir,
+	})
+
+	require.NoError(t, cmd.Execute())
+
+	data, err := os.ReadFile(filepath.Join(outputDir, pipeline.CLIManifestFilename))
+	require.NoError(t, err)
+	var manifest pipeline.CLIManifest
+	require.NoError(t, json.Unmarshal(data, &manifest))
+	require.Len(t, manifest.NovelFeatures, 1)
+	assert.Equal(t, "Built insight", manifest.NovelFeatures[0].Name)
+	assert.Equal(t, "items insight", manifest.NovelFeatures[0].Command)
+}
+
 func TestGenerateCmdAppliesBrowserClearanceReachability(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/cli/library_test.go b/internal/cli/library_test.go
index a05d52b5..1bf2979f 100644
--- a/internal/cli/library_test.go
+++ b/internal/cli/library_test.go
@@ -20,6 +20,13 @@ func setLibraryTestEnv(t *testing.T) string {
 
 func writeTestManifest(t *testing.T, dir string, m pipeline.CLIManifest) {
 	t.Helper()
+	if m.APIName != "" && m.CLIName != "" && len(m.NovelFeatures) == 0 {
+		m.NovelFeatures = []pipeline.NovelFeatureManifest{{
+			Name:        "Test insight",
+			Command:     "insight",
+			Description: "Exercise publish validation in tests",
+		}}
+	}
 	data, err := json.MarshalIndent(m, "", "  ")
 	require.NoError(t, err)
 	require.NoError(t, os.WriteFile(filepath.Join(dir, pipeline.CLIManifestFilename), data, 0o644))
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index f839bfe8..2349ccfc 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -480,27 +480,40 @@ func runValidation(dir string) ValidateResult {
 
 	// 1. Manifest check
 	manifestPath := filepath.Join(dir, pipeline.CLIManifestFilename)
+	var manifest pipeline.CLIManifest
 	data, err := os.ReadFile(manifestPath)
 	if err != nil {
 		result.Checks = append(result.Checks, CheckResult{Name: "manifest", Passed: false, Error: "missing .printing-press.json"})
 		allPassed = false
 	} else {
-		var m pipeline.CLIManifest
-		if err := json.Unmarshal(data, &m); err != nil {
+		if err := json.Unmarshal(data, &manifest); err != nil {
 			result.Checks = append(result.Checks, CheckResult{Name: "manifest", Passed: false, Error: fmt.Sprintf("invalid JSON: %v", err)})
 			allPassed = false
 		} else {
-			if m.APIName == "" || m.CLIName == "" {
+			if manifest.APIName == "" || manifest.CLIName == "" {
 				result.Checks = append(result.Checks, CheckResult{Name: "manifest", Passed: false, Error: "missing required fields (api_name, cli_name)"})
 				allPassed = false
 			} else {
 				result.Checks = append(result.Checks, CheckResult{Name: "manifest", Passed: true})
-				result.CLIName = m.CLIName
-				result.APIName = m.APIName
+				result.CLIName = manifest.CLIName
+				result.APIName = manifest.APIName
 			}
 		}
 	}
 
+	// 2. Transcendence check. Published CLIs are expected to carry verified
+	// novel features from the absorb phase; a bare endpoint wrapper should not
+	// pass publish validation as shippable.
+	if manifest.APIName == "" || manifest.CLIName == "" {
+		result.Checks = append(result.Checks, CheckResult{Name: "transcendence", Passed: false, Error: "manifest unavailable"})
+		allPassed = false
+	} else if len(manifest.NovelFeatures) == 0 {
+		result.Checks = append(result.Checks, CheckResult{Name: "transcendence", Passed: false, Error: "no novel features recorded; run the absorb/transcend phase and dogfood before publishing"})
+		allPassed = false
+	} else {
+		result.Checks = append(result.Checks, CheckResult{Name: "transcendence", Passed: true})
+	}
+
 	cliName := result.CLIName
 	if cliName == "" {
 		cliName = filepath.Base(dir)
@@ -509,28 +522,28 @@ func runValidation(dir string) ValidateResult {
 	restoreBuildArtifacts := snapshotFiles(buildArtifactCandidates(dir, cliName))
 	defer restoreBuildArtifacts()
 
-	// 2. go mod tidy check — snapshot files, run tidy, compare, restore
+	// 3. go mod tidy check — snapshot files, run tidy, compare, restore
 	tidyCheck := checkGoModTidy(dir)
 	if !tidyCheck.Passed {
 		allPassed = false
 	}
 	result.Checks = append(result.Checks, tidyCheck)
 
-	// 3. go vet check
+	// 4. go vet check
 	vetCheck := runGoCheck(dir, "vet", "./...")
 	if !vetCheck.Passed {
 		allPassed = false
 	}
 	result.Checks = append(result.Checks, vetCheck)
 
-	// 4. go build check
+	// 5. go build check
 	buildCheck := runGoCheck(dir, "build", "./...")
 	if !buildCheck.Passed {
 		allPassed = false
 	}
 	result.Checks = append(result.Checks, buildCheck)
 
-	// 5. --help / --version checks use a dedicated temp binary so validation
+	// 6. --help / --version checks use a dedicated temp binary so validation
 	// exercises current source without depending on or mutating source-tree artifacts.
 	binaryPath, cleanupBinary, err := buildValidationBinary(dir, cliName)
 	if cleanupBinary != nil {
@@ -558,7 +571,7 @@ func runValidation(dir string) ValidateResult {
 			result.HelpOutput = string(helpOut)
 		}
 
-		// 6. --version check
+		// 7. --version check
 		verCtx, verCancel := context.WithTimeout(context.Background(), binaryCheckTimeout)
 		defer verCancel()
 		versionCmd := exec.CommandContext(verCtx, binaryPath, "--version")
@@ -575,7 +588,7 @@ func runValidation(dir string) ValidateResult {
 		}
 	}
 
-	// 7. Manuscripts check (warn-only)
+	// 8. Manuscripts check (warn-only)
 	// Try CLI name first (new convention), then API name, then fuzzy resolve
 	apiName := result.APIName
 	if apiName == "" {
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index 0abe5a00..bb6a95fa 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -143,18 +143,53 @@ func TestPublishValidateJSONHasAllChecks(t *testing.T) {
 	var result ValidateResult
 	require.NoError(t, json.Unmarshal([]byte(output), &result))
 
-	// Should have all 7 check names
+	// Should have all publish validation check names
 	checkNames := make(map[string]bool)
 	for _, c := range result.Checks {
 		checkNames[c.Name] = true
 	}
 
-	// All 7 checks should be present (they may fail in test env, but must exist)
-	expectedChecks := []string{"manifest", "go mod tidy", "go vet", "go build", "--help", "--version", "manuscripts"}
+	// All checks should be present (they may fail in test env, but must exist)
+	expectedChecks := []string{"manifest", "transcendence", "go mod tidy", "go vet", "go build", "--help", "--version", "manuscripts"}
 	for _, name := range expectedChecks {
 		assert.True(t, checkNames[name], "should have %q check", name)
 	}
-	assert.Len(t, result.Checks, 7, "should have exactly 7 checks")
+	assert.Len(t, result.Checks, len(expectedChecks), "should have exactly the expected checks")
+}
+
+func TestPublishValidateRequiresTranscendenceFeatures(t *testing.T) {
+	home := setLibraryTestEnv(t)
+	cliDir := filepath.Join(home, "library", "test-pp-cli")
+	require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+	data, err := json.MarshalIndent(pipeline.CLIManifest{
+		SchemaVersion: 1,
+		APIName:       "test",
+		CLIName:       "test-pp-cli",
+	}, "", "  ")
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, pipeline.CLIManifestFilename), data, 0o644))
+
+	cmd := newPublishCmd()
+	cmd.SetArgs([]string{"validate", "--dir", cliDir, "--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.Error(t, err)
+
+	var result ValidateResult
+	require.NoError(t, json.Unmarshal([]byte(output), &result))
+	assert.False(t, result.Passed)
+
+	var check *CheckResult
+	for i := range result.Checks {
+		if result.Checks[i].Name == "transcendence" {
+			check = &result.Checks[i]
+			break
+		}
+	}
+	require.NotNil(t, check)
+	assert.False(t, check.Passed)
+	assert.Contains(t, check.Error, "no novel features recorded")
 }
 
 func TestPublishValidateExitCode(t *testing.T) {
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 980900e7..7b410554 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -176,7 +176,7 @@ func newGenerateCmd() *cobra.Command {
 
 				enrichSpecFromCatalog(parsed)
 				gen := generator.New(parsed, absOut)
-				loadResearchSources(gen, researchDir)
+				novelFeatures := loadResearchSources(gen, researchDir)
 				trafficAnalysis, err := loadTrafficAnalysisForGenerate(trafficAnalysisPath, nil, parsed.SpecSource)
 				if err != nil {
 					return &ExitError{Code: ExitInputError, Err: err}
@@ -212,10 +212,11 @@ func newGenerateCmd() *cobra.Command {
 				}
 
 				if err := pipeline.WriteManifestForGenerate(pipeline.GenerateManifestParams{
-					APIName:   parsed.Name,
-					DocsURL:   docsURL,
-					OutputDir: absOut,
-					Spec:      parsed,
+					APIName:       parsed.Name,
+					DocsURL:       docsURL,
+					OutputDir:     absOut,
+					Spec:          parsed,
+					NovelFeatures: novelFeatures,
 				}); err != nil {
 					fmt.Fprintf(os.Stderr, "warning: could not write manifest: %v\n", err)
 				}
@@ -384,7 +385,7 @@ func newGenerateCmd() *cobra.Command {
 
 			enrichSpecFromCatalog(apiSpec)
 			gen := generator.New(apiSpec, absOut)
-			loadResearchSources(gen, researchDir)
+			novelFeatures := loadResearchSources(gen, researchDir)
 			trafficAnalysis, err := loadTrafficAnalysisForGenerate(trafficAnalysisPath, specFiles, apiSpec.SpecSource)
 			if err != nil {
 				return &ExitError{Code: ExitInputError, Err: err}
@@ -440,11 +441,12 @@ func newGenerateCmd() *cobra.Command {
 			}
 
 			if err := pipeline.WriteManifestForGenerate(pipeline.GenerateManifestParams{
-				APIName:   apiSpec.Name,
-				SpecSrcs:  specFiles,
-				SpecURL:   specURL,
-				OutputDir: absOut,
-				Spec:      apiSpec,
+				APIName:       apiSpec.Name,
+				SpecSrcs:      specFiles,
+				SpecURL:       specURL,
+				OutputDir:     absOut,
+				Spec:          apiSpec,
+				NovelFeatures: novelFeatures,
 			}); err != nil {
 				fmt.Fprintf(os.Stderr, "warning: could not write manifest: %v\n", err)
 			}
@@ -984,12 +986,15 @@ func printDryRun(apiSpec *spec.APISpec, absOut string, specFiles []string) error
 }
 
 // loadResearchSources populates the generator's Sources, DiscoveryPages, and
-// NovelFeatures from a pipeline research directory. Silently skips if
-// researchDir is empty or data is unavailable.
-func loadResearchSources(gen *generator.Generator, researchDir string) {
+// NovelFeatures from a pipeline research directory. It returns only dogfood-
+// verified novel features in manifest form so publish validation cannot be
+// satisfied by planned-but-unbuilt absorb ideas. Silently skips if researchDir
+// is empty or data is unavailable.
+func loadResearchSources(gen *generator.Generator, researchDir string) []pipeline.NovelFeatureManifest {
 	if researchDir == "" {
-		return
+		return nil
 	}
+	var manifestNovel []pipeline.NovelFeatureManifest
 	research, err := pipeline.LoadResearch(researchDir)
 	if err == nil {
 		for _, s := range pipeline.SourcesForREADME(research) {
@@ -1022,12 +1027,22 @@ func loadResearchSources(gen *generator.Generator, researchDir string) {
 				Group:        nf.Group,
 			})
 		}
+		if research.NovelFeaturesBuilt != nil {
+			for _, nf := range *research.NovelFeaturesBuilt {
+				manifestNovel = append(manifestNovel, pipeline.NovelFeatureManifest{
+					Name:        nf.Name,
+					Command:     nf.Command,
+					Description: nf.Description,
+				})
+			}
+		}
 		if research.Narrative != nil {
 			gen.Narrative = translateNarrative(research.Narrative)
 		}
 	}
 	discoveryDir := filepath.Join(researchDir, "discovery")
 	gen.DiscoveryPages = pipeline.ParseDiscoveryPages(discoveryDir)
+	return manifestNovel
 }
 
 // translateNarrative copies an absorb-phase pipeline.ReadmeNarrative into
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 60fcda7b..094c54a0 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -254,6 +254,11 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 		baseURL = "https://api.example.com"
 	}
 
+	auth := mapAuth(doc, name)
+	if auth.Type != "none" && allOperationsAllowAnonymous(doc) {
+		auth = spec.AuthConfig{Type: "none"}
+	}
+
 	result := &spec.APISpec{
 		Name:        name,
 		Description: description,
@@ -262,7 +267,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 		BasePath:    basePath,
 		WebsiteURL:  websiteURL,
 		ProxyRoutes: proxyRoutes,
-		Auth:        mapAuth(doc, name),
+		Auth:        auth,
 		Config: spec.ConfigSpec{
 			Format: "toml",
 			Path:   fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
@@ -1155,6 +1160,28 @@ func operationAllowsAnonymous(op *openapi3.Operation, doc *openapi3.T) bool {
 	return false
 }
 
+func allOperationsAllowAnonymous(doc *openapi3.T) bool {
+	if doc == nil || doc.Paths == nil {
+		return false
+	}
+	seenOperation := false
+	for _, pathItem := range doc.Paths.Map() {
+		if pathItem == nil {
+			continue
+		}
+		for _, op := range pathItem.Operations() {
+			if op == nil {
+				continue
+			}
+			seenOperation = true
+			if !operationAllowsAnonymous(op, doc) {
+				return false
+			}
+		}
+	}
+	return seenOperation
+}
+
 // markAllEndpointsNoAuth sets NoAuth=true on every endpoint across all
 // resources and sub-resources. Used when the spec has no authentication.
 func markAllEndpointsNoAuth(resources map[string]spec.Resource) {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index e0769f2b..4f2668bf 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -909,6 +909,62 @@ paths:
 		assert.True(t, foundPrivate, "should have found /private endpoint")
 	})
 
+	t.Run("anonymous security alternative on every operation makes whole API no-auth", func(t *testing.T) {
+		t.Parallel()
+		yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Optional Auth API
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    basicAuth:
+      type: http
+      scheme: basic
+    cookieAuth:
+      type: apiKey
+      in: cookie
+      name: sessionid
+paths:
+  /pokemon:
+    get:
+      summary: List pokemon
+      security:
+        - cookieAuth: []
+        - basicAuth: []
+        - {}
+      responses:
+        "200":
+          description: OK
+  /pokemon/{id}:
+    get:
+      summary: Get pokemon
+      security:
+        - cookieAuth: []
+        - basicAuth: []
+        - {}
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: OK
+`)
+		parsed, err := Parse(yamlSpec)
+		require.NoError(t, err)
+
+		assert.Equal(t, "none", parsed.Auth.Type)
+		for _, r := range parsed.Resources {
+			for _, e := range r.Endpoints {
+				assert.True(t, e.NoAuth, "%s %s should be public", e.Method, e.Path)
+			}
+		}
+	})
+
 	t.Run("petstore still parses without regression", func(t *testing.T) {
 		t.Parallel()
 		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))

← 4a77f46c fix(catalog): update stale spec URLs + add download content-  ·  back to Cli Printing Press  ·  chore(main): release 2.3.4 (#292) a18088ad →