← 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
M internal/cli/generate_test.goM internal/cli/library_test.goM internal/cli/publish.goM internal/cli/publish_test.goM internal/cli/root.goM internal/openapi/parser.goM internal/openapi/parser_test.go
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 →