From 300f4dfa83e9f1812b64eaf62cd1f0b6910af9dd Mon Sep 17 00:00:00 2001 From: Fiachra Corcoran Date: Thu, 7 May 2026 00:12:28 +0100 Subject: [PATCH 1/6] feat(fn): add --help, --doc, and standalone file mode to AsMain Extend fn.AsMain with functional options to support: - --help: renders human-readable docs from embedded README markers - --doc: outputs machine-readable JSON for catalog tooling - Standalone file mode: accepts positional file args for local debugging New fn.WithDocs(readme, meta) option registers embedded content. Existing callers with no options are unaffected (backward-compatible). Adds internal/docs package with: - ParseMarkers: extracts mdtogo Short/Long/Examples sections - ParseMetadata: parses metadata.yaml content - RenderHelp/RenderDoc: formats output for --help and --doc Includes property-based tests (rapid) and unit tests for all new code. Signed-off-by: Fiachra Corcoran --- go/fn/go.mod | 7 +- go/fn/go.sum | 2 + go/fn/internal/docs/markers.go | 82 +++++ go/fn/internal/docs/markers_test.go | 119 +++++++ go/fn/internal/docs/metadata.go | 39 +++ go/fn/internal/docs/metadata_test.go | 301 +++++++++++++++++ go/fn/internal/docs/render.go | 102 ++++++ go/fn/internal/docs/render_test.go | 467 +++++++++++++++++++++++++++ go/fn/run.go | 150 ++++++++- go/fn/run_filemode_property_test.go | 171 ++++++++++ go/fn/run_filemode_test.go | 428 ++++++++++++++++++++++++ go/fn/run_flags_test.go | 312 ++++++++++++++++++ 12 files changed, 2176 insertions(+), 4 deletions(-) create mode 100644 go/fn/internal/docs/markers.go create mode 100644 go/fn/internal/docs/markers_test.go create mode 100644 go/fn/internal/docs/metadata.go create mode 100644 go/fn/internal/docs/metadata_test.go create mode 100644 go/fn/internal/docs/render.go create mode 100644 go/fn/internal/docs/render_test.go create mode 100644 go/fn/run_filemode_property_test.go create mode 100644 go/fn/run_filemode_test.go create mode 100644 go/fn/run_flags_test.go diff --git a/go/fn/go.mod b/go/fn/go.mod index f14b0f39..48eba0a4 100644 --- a/go/fn/go.mod +++ b/go/fn/go.mod @@ -15,7 +15,11 @@ require ( sigs.k8s.io/kustomize/kyaml v0.21.1 ) -require github.com/pkg/errors v0.9.1 +require ( + github.com/pkg/errors v0.9.1 + go.yaml.in/yaml/v3 v3.0.4 + pgregory.net/rapid v1.3.0 +) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -39,7 +43,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/xlab/treeprint v1.2.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.44.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go/fn/go.sum b/go/fn/go.sum index 7c41123e..870150a3 100644 --- a/go/fn/go.sum +++ b/go/fn/go.sum @@ -79,6 +79,8 @@ k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af h1:zLXA2Irn14q2/06WMkxViyr7YCPUO2lJ0QYE9Juy5vA= k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= +pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc= +pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= diff --git a/go/fn/internal/docs/markers.go b/go/fn/internal/docs/markers.go new file mode 100644 index 00000000..0c6eab24 --- /dev/null +++ b/go/fn/internal/docs/markers.go @@ -0,0 +1,82 @@ +// Copyright 2025 The kpt Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package docs + +import "strings" + +// Sections holds parsed README marker content. +type Sections struct { + Short string + Long string + Examples string +} + +const ( + markerShort = "" + markerLong = "" + markerExamples = "" + markerEnd = "" +) + +// ParseMarkers extracts mdtogo marker sections from README content. +// Missing markers result in empty strings for the corresponding sections. +// When no markers are present at all, the full content (trimmed) is returned +// as the Long description. +func ParseMarkers(readme []byte) Sections { + content := string(readme) + + short := extractSection(content, markerShort) + long := extractSection(content, markerLong) + examples := extractSection(content, markerExamples) + + // Fallback: if no markers are present at all, use full content as Long. + if !hasAnyMarker(content) { + return Sections{ + Long: strings.TrimSpace(content), + } + } + + return Sections{ + Short: short, + Long: long, + Examples: examples, + } +} + +// extractSection finds text between the given start marker and the next +// marker. Returns empty string if either marker is missing. +func extractSection(content, startMarker string) string { + startIdx := strings.Index(content, startMarker) + if startIdx < 0 { + return "" + } + afterStart := startIdx + len(startMarker) + remaining := content[afterStart:] + + endIdx := strings.Index(remaining, markerEnd) + if endIdx < 0 { + return "" + } + + return strings.TrimSpace(remaining[:endIdx]) +} + +// hasAnyMarker reports whether the content contains any mdtogo marker. +func hasAnyMarker(content string) bool { + return strings.Contains(content, markerShort) || + strings.Contains(content, markerLong) || + strings.Contains(content, markerExamples) || + strings.Contains(content, markerEnd) +} diff --git a/go/fn/internal/docs/markers_test.go b/go/fn/internal/docs/markers_test.go new file mode 100644 index 00000000..bc830748 --- /dev/null +++ b/go/fn/internal/docs/markers_test.go @@ -0,0 +1,119 @@ +// Copyright 2025 The kpt Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package docs + +import ( + "fmt" + "strings" + "testing" + + "pgregory.net/rapid" +) + +// Feature: sdk-alignment, Property 1: Marker parser round-trip +// +// For any three strings (short, long, examples), formatting them into a README +// with mdtogo markers and then parsing that README with ParseMarkers SHALL +// produce a Sections struct with fields equal to the original strings (after trimming). +// +// Validates: Requirements 6.1, 6.2, 6.3, 6.5, 5.2 + +// genSectionContent generates arbitrary non-empty strings that do not contain +// mdtogo markers (which would confuse the parser). +func genSectionContent() *rapid.Generator[string] { + return rapid.Custom(func(t *rapid.T) string { + s := rapid.StringMatching(`[a-zA-Z0-9 \t\n.,;:!?(){}\[\]'"/_-]{1,200}`).Draw(t, "content") + // Ensure the generated content does not accidentally contain marker strings. + s = strings.ReplaceAll(s, " +%s + + + +%s + + + +%s + +`, short, long, examples) +} + +// Feature: sdk-alignment, Property 7: Missing markers fallback +// +// For any README content that does NOT contain mdtogo markers, ParseMarkers +// SHALL return empty strings for Short and Examples, and the full content +// (trimmed) as Long. +// +// Validates: Requirements 5.4, 6.4 + +// genNoMarkerContent generates arbitrary strings guaranteed not to contain +// any mdtogo marker substrings. +func genNoMarkerContent() *rapid.Generator[string] { + return rapid.Custom(func(t *rapid.T) string { + s := rapid.StringMatching(`[a-zA-Z0-9 \t\n.,;:!?(){}\[\]'"/_-]{0,300}`).Draw(t, "content") + // Strip anything that could form a marker. + s = strings.ReplaceAll(s, " +%s + + + +%s + + + +%s + +`, short, long, examples) + + // Parse the README to get sections. + sections := ParseMarkers([]byte(readme)) + + // Generate non-empty metadata fields to ensure all appear in output. + meta := Metadata{ + Image: rapid.StringMatching(`gcr\.io/[a-z0-9-]{3,20}/[a-z0-9-]{3,20}:v[0-9]+\.[0-9]+`).Draw(t, "image"), + Description: genNonEmptySectionContent().Draw(t, "description"), + Tags: rapid.SliceOfN( + rapid.StringMatching(`[a-z]{3,10}`), 1, 5, + ).Draw(t, "tags"), + SourceURL: rapid.StringMatching(`https://github\.com/[a-z0-9-]{3,20}/[a-z0-9-]{3,20}`).Draw(t, "sourceURL"), + ExamplePackageURLs: rapid.SliceOfN(rapid.StringMatching(`https://github\.com/[a-z0-9-]{3,20}/[a-z0-9-]{3,20}`), 1, 3).Draw(t, "exampleURLs"), + License: rapid.SampledFrom([]string{"Apache-2.0", "MIT", "BSD-3-Clause"}).Draw(t, "license"), + Hidden: rapid.Bool().Draw(t, "hidden"), + } + + // Render doc JSON output. + var buf bytes.Buffer + err := RenderDoc(&buf, sections, meta) + if err != nil { + t.Fatalf("RenderDoc returned error: %v", err) + } + + // Decode the JSON output. + var output DocOutput + if err := json.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("failed to decode doc JSON: %v\n Raw: %s", err, buf.String()) + } + + // Assert all non-empty source values from README appear in output. + if output.Short != sections.Short { + t.Fatalf("doc JSON 'short' mismatch\n expected: %q\n got: %q", sections.Short, output.Short) + } + if output.Long != sections.Long { + t.Fatalf("doc JSON 'long' mismatch\n expected: %q\n got: %q", sections.Long, output.Long) + } + if output.Examples != sections.Examples { + t.Fatalf("doc JSON 'examples' mismatch\n expected: %q\n got: %q", sections.Examples, output.Examples) + } + + // Assert all non-empty source values from metadata appear in output. + if output.Image != meta.Image { + t.Fatalf("doc JSON 'image' mismatch\n expected: %q\n got: %q", meta.Image, output.Image) + } + if output.Description != meta.Description { + t.Fatalf("doc JSON 'description' mismatch\n expected: %q\n got: %q", meta.Description, output.Description) + } + if len(output.Tags) != len(meta.Tags) { + t.Fatalf("doc JSON 'tags' length mismatch\n expected: %v\n got: %v", meta.Tags, output.Tags) + } + for i, tag := range meta.Tags { + if output.Tags[i] != tag { + t.Fatalf("doc JSON 'tags[%d]' mismatch\n expected: %q\n got: %q", i, tag, output.Tags[i]) + } + } + if output.SourceURL != meta.SourceURL { + t.Fatalf("doc JSON 'sourceURL' mismatch\n expected: %q\n got: %q", meta.SourceURL, output.SourceURL) + } + if len(output.ExamplePackageURLs) != len(meta.ExamplePackageURLs) { + t.Fatalf("doc JSON 'examplePackageURLs' length mismatch\n expected: %v\n got: %v", meta.ExamplePackageURLs, output.ExamplePackageURLs) + } + for i, url := range meta.ExamplePackageURLs { + if output.ExamplePackageURLs[i] != url { + t.Fatalf("doc JSON 'examplePackageURLs[%d]' mismatch\n expected: %q\n got: %q", i, url, output.ExamplePackageURLs[i]) + } + } + if output.License != meta.License { + t.Fatalf("doc JSON 'license' mismatch\n expected: %q\n got: %q", meta.License, output.License) + } + if output.Hidden != meta.Hidden { + t.Fatalf("doc JSON 'hidden' mismatch\n expected: %v\n got: %v", meta.Hidden, output.Hidden) + } + }) +} + +func TestProperty3_HelpOutputContainsParsedSections(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + short := genNonEmptySectionContent().Draw(t, "short") + long := genNonEmptySectionContent().Draw(t, "long") + examples := genNonEmptySectionContent().Draw(t, "examples") + + // Format a README with valid mdtogo markers. + readme := fmt.Sprintf(` +%s + + + +%s + + + +%s + +`, short, long, examples) + + // Parse the README to get sections (same as runtime would). + sections := ParseMarkers([]byte(readme)) + + // Render help output. + var buf bytes.Buffer + RenderHelp(&buf, sections, Metadata{}) + output := buf.String() + + // Assert that the help output contains each parsed section. + if !strings.Contains(output, sections.Short) { + t.Fatalf("help output does not contain Short section\n Short: %q\n Output: %q", sections.Short, output) + } + if !strings.Contains(output, sections.Long) { + t.Fatalf("help output does not contain Long section\n Long: %q\n Output: %q", sections.Long, output) + } + if !strings.Contains(output, sections.Examples) { + t.Fatalf("help output does not contain Examples section\n Examples: %q\n Output: %q", sections.Examples, output) + } + }) +} + +// --- Unit Tests for Renderers --- +// Validates: Requirements 2.3, 3.3, 3.6 + +func TestRenderHelp_FullSectionsAndMetadata(t *testing.T) { + sections := Sections{ + Short: "Set labels on all resources", + Long: "The set-labels function adds or updates labels on all resources in the package.", + Examples: " kpt fn eval --image gcr.io/kpt-fn/set-labels:v0.1 -- label_name=label_value", + } + meta := Metadata{ + Image: "gcr.io/kpt-fn/set-labels:v0.1", + Description: "Set labels on all resources", + Tags: []string{"mutator", "labels"}, + } + + var buf bytes.Buffer + RenderHelp(&buf, sections, meta) + output := buf.String() + + // Verify output contains the Short description. + if !strings.Contains(output, sections.Short) { + t.Errorf("expected output to contain Short %q, got:\n%s", sections.Short, output) + } + // Verify output contains the Long description. + if !strings.Contains(output, sections.Long) { + t.Errorf("expected output to contain Long %q, got:\n%s", sections.Long, output) + } + // Verify output contains the Examples content. + if !strings.Contains(output, sections.Examples) { + t.Errorf("expected output to contain Examples %q, got:\n%s", sections.Examples, output) + } + // Verify the "Examples:" header is present. + if !strings.Contains(output, "Examples:") { + t.Errorf("expected output to contain 'Examples:' header, got:\n%s", output) + } + // Verify no cobra boilerplate. + if strings.Contains(output, "Usage:") { + t.Errorf("output should not contain 'Usage:', got:\n%s", output) + } + if strings.Contains(output, "Flags:") { + t.Errorf("output should not contain 'Flags:', got:\n%s", output) + } +} + +func TestRenderHelp_EmptySections(t *testing.T) { + sections := Sections{} + meta := Metadata{} + + var buf bytes.Buffer + RenderHelp(&buf, sections, meta) + output := buf.String() + + expected := "No documentation available. Pass fn.WithDocs to fn.AsMain to enable --help.\n" + if output != expected { + t.Errorf("expected minimal message %q, got %q", expected, output) + } +} + +func TestRenderDoc_ValidJSON_AllFields(t *testing.T) { + sections := Sections{ + Short: "Set labels on all resources", + Long: "The set-labels function adds or updates labels.", + Examples: " kpt fn eval --image gcr.io/kpt-fn/set-labels:v0.1", + } + meta := Metadata{ + Image: "gcr.io/kpt-fn/set-labels:v0.1", + Description: "Set labels on all resources", + Tags: []string{"mutator", "labels"}, + SourceURL: "https://github.com/kptdev/krm-functions/tree/main/functions/go/set-labels", + ExamplePackageURLs: []string{"https://github.com/kptdev/krm-functions/tree/main/examples/set-labels-simple"}, + License: "Apache-2.0", + Hidden: false, + } + + var buf bytes.Buffer + err := RenderDoc(&buf, sections, meta) + if err != nil { + t.Fatalf("RenderDoc returned error: %v", err) + } + + // Verify output is valid JSON. + var output DocOutput + if err := json.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("output is not valid JSON: %v\nRaw: %s", err, buf.String()) + } + + // Verify all fields are correctly populated. + if output.Short != sections.Short { + t.Errorf("Short: got %q, want %q", output.Short, sections.Short) + } + if output.Long != sections.Long { + t.Errorf("Long: got %q, want %q", output.Long, sections.Long) + } + if output.Examples != sections.Examples { + t.Errorf("Examples: got %q, want %q", output.Examples, sections.Examples) + } + if output.Image != meta.Image { + t.Errorf("Image: got %q, want %q", output.Image, meta.Image) + } + if output.Description != meta.Description { + t.Errorf("Description: got %q, want %q", output.Description, meta.Description) + } + if len(output.Tags) != len(meta.Tags) { + t.Errorf("Tags length: got %d, want %d", len(output.Tags), len(meta.Tags)) + } else { + for i, tag := range meta.Tags { + if output.Tags[i] != tag { + t.Errorf("Tags[%d]: got %q, want %q", i, output.Tags[i], tag) + } + } + } + if output.SourceURL != meta.SourceURL { + t.Errorf("SourceURL: got %q, want %q", output.SourceURL, meta.SourceURL) + } + if len(output.ExamplePackageURLs) != len(meta.ExamplePackageURLs) { + t.Errorf("ExamplePackageURLs length: got %d, want %d", len(output.ExamplePackageURLs), len(meta.ExamplePackageURLs)) + } else { + for i, url := range meta.ExamplePackageURLs { + if output.ExamplePackageURLs[i] != url { + t.Errorf("ExamplePackageURLs[%d]: got %q, want %q", i, output.ExamplePackageURLs[i], url) + } + } + } + if output.License != meta.License { + t.Errorf("License: got %q, want %q", output.License, meta.License) + } + if output.Hidden != meta.Hidden { + t.Errorf("Hidden: got %v, want %v", output.Hidden, meta.Hidden) + } +} + +func TestRenderDoc_EmptyInputs(t *testing.T) { + sections := Sections{} + meta := Metadata{} + + var buf bytes.Buffer + err := RenderDoc(&buf, sections, meta) + if err != nil { + t.Fatalf("RenderDoc returned error: %v", err) + } + + // Verify output is valid JSON. + var raw map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &raw); err != nil { + t.Fatalf("output is not valid JSON: %v\nRaw: %s", err, buf.String()) + } + + // Verify it decodes to a DocOutput with zero-value fields. + var output DocOutput + if err := json.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("failed to decode into DocOutput: %v", err) + } + + if output.Short != "" { + t.Errorf("Short: got %q, want empty", output.Short) + } + if output.Long != "" { + t.Errorf("Long: got %q, want empty", output.Long) + } + if output.Examples != "" { + t.Errorf("Examples: got %q, want empty", output.Examples) + } + if output.Image != "" { + t.Errorf("Image: got %q, want empty", output.Image) + } + if output.Hidden != false { + t.Errorf("Hidden: got %v, want false", output.Hidden) + } +} + +func TestRenderDoc_HiddenFieldSerialization(t *testing.T) { + sections := Sections{ + Short: "A hidden function", + } + meta := Metadata{ + Hidden: true, + } + + var buf bytes.Buffer + err := RenderDoc(&buf, sections, meta) + if err != nil { + t.Fatalf("RenderDoc returned error: %v", err) + } + + // Verify the raw JSON contains "hidden": true. + var raw map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &raw); err != nil { + t.Fatalf("output is not valid JSON: %v\nRaw: %s", err, buf.String()) + } + + hiddenVal, ok := raw["hidden"] + if !ok { + t.Fatal("JSON output does not contain 'hidden' field") + } + if hiddenVal != true { + t.Errorf("hidden field: got %v, want true", hiddenVal) + } + + // Also verify via struct decoding. + var output DocOutput + if err := json.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("failed to decode into DocOutput: %v", err) + } + if !output.Hidden { + t.Errorf("DocOutput.Hidden: got false, want true") + } +} diff --git a/go/fn/run.go b/go/fn/run.go index c38ebfbd..b2100199 100644 --- a/go/fn/run.go +++ b/go/fn/run.go @@ -18,16 +18,70 @@ import ( "fmt" "io" "os" + "slices" + "strings" + "github.com/kptdev/krm-functions-sdk/go/fn/internal/docs" "sigs.k8s.io/kustomize/kyaml/errors" "sigs.k8s.io/kustomize/kyaml/kio" ) -// AsMain evaluates the ResourceList from STDIN to STDOUT. +// Option configures fn.AsMain behavior. +type Option func(*mainConfig) + +// mainConfig holds configuration gathered from Options. +type mainConfig struct { + readme []byte // raw embedded README.md content + metadata []byte // raw embedded metadata.yaml content +} + +// WithDocs registers embedded README and metadata content for --help and --doc. +func WithDocs(readme []byte, meta []byte) Option { + return func(c *mainConfig) { + c.readme = readme + c.metadata = meta + } +} + +// AsMain evaluates a KRM function. By default it reads a ResourceList from +// STDIN, processes it, and writes the result to STDOUT. +// // `input` can be // - a `ResourceListProcessor` which implements `Process` method // - a function `Runner` which implements `Run` method -func AsMain(input any) error { +// +// Invocation modes (checked in this order): +// - --help: prints human-readable documentation to STDOUT and returns nil. +// - --doc: prints machine-readable JSON documentation to STDOUT and returns nil. +// - positional file args: reads KRM resources from files instead of STDIN. +// - no args: reads ResourceList from STDIN (default behavior). +// +// Options configure additional behavior such as documentation support +// via WithDocs. Existing callers with no options continue to work unchanged. +func AsMain(input any, opts ...Option) error { + // Apply options to build configuration. + var cfg mainConfig + for _, opt := range opts { + opt(&cfg) + } + + // Check for --help and --doc flags before reading STDIN. + // --help always takes precedence over --doc regardless of argument order. + if slices.Contains(os.Args[1:], "--help") { + return handleHelp(&cfg) + } + if slices.Contains(os.Args[1:], "--doc") { + return handleDoc(&cfg) + } + + // Collect non-flag positional arguments (file paths). + var filePaths []string + for _, arg := range os.Args[1:] { + if !strings.HasPrefix(arg, "--") { + filePaths = append(filePaths, arg) + } + } + err := func() error { var p ResourceListProcessor switch input := input.(type) { @@ -38,6 +92,31 @@ func AsMain(input any) error { default: return fmt.Errorf("unknown input type %T", input) } + + // If file paths are provided, use file mode instead of STDIN. + if len(filePaths) > 0 { + rl, err := readFilesAsResourceList(filePaths) + if err != nil { + return err + } + success, fnErr := p.Process(rl) + out, yamlErr := rl.ToYAML() + if yamlErr != nil { + return yamlErr + } + _, outErr := os.Stdout.Write(out) + if outErr != nil { + return outErr + } + if fnErr != nil { + return fnErr + } + if !success { + return fmt.Errorf("error: function failure") + } + return nil + } + in, err := io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("unable to read from stdin: %v", err) @@ -57,6 +136,73 @@ func AsMain(input any) error { return err } +// handleHelp renders help text to STDOUT based on registered docs. +func handleHelp(cfg *mainConfig) error { + if cfg.readme == nil && cfg.metadata == nil { + fmt.Fprint(os.Stdout, "No documentation available. Pass fn.WithDocs to fn.AsMain to enable --help.\n") + return nil + } + + sections := docs.ParseMarkers(cfg.readme) + meta, err := docs.ParseMetadata(cfg.metadata) + if err != nil { + Logf("warning: invalid metadata YAML: %v", err) + meta = docs.Metadata{} + } + + docs.RenderHelp(os.Stdout, sections, meta) + return nil +} + +// handleDoc renders JSON documentation to STDOUT based on registered docs. +func handleDoc(cfg *mainConfig) error { + if cfg.readme == nil && cfg.metadata == nil { + fmt.Fprint(os.Stdout, "{}") + return nil + } + + sections := docs.ParseMarkers(cfg.readme) + meta, err := docs.ParseMetadata(cfg.metadata) + if err != nil { + Logf("warning: invalid metadata YAML: %v", err) + meta = docs.Metadata{} + } + + return docs.RenderDoc(os.Stdout, sections, meta) +} + +// readFilesAsResourceList reads KRM YAML from the given file paths, +// assembles them into a ResourceList with an empty FunctionConfig. +// Each file is parsed as one or more KRM YAML documents (separated by ---). +// Empty files are valid (no items added). Returns a descriptive error if a +// file does not exist or contains invalid YAML. +func readFilesAsResourceList(paths []string) (*ResourceList, error) { + rl := &ResourceList{ + FunctionConfig: NewEmptyKubeObject(), + } + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %s", path) + } + return nil, fmt.Errorf("failed to read file %s: %v", path, err) + } + // Empty files are valid — proceed with no items from this file. + if len(strings.TrimSpace(string(data))) == 0 { + continue + } + objects, err := ParseKubeObjects(data) + if err != nil { + return nil, fmt.Errorf("failed to parse KRM resources from %s: %v", path, err) + } + for _, obj := range objects { + rl.Items = append(rl.Items, obj) + } + } + return rl, nil +} + // Run evaluates the function. input must be a resourceList in yaml format. An // updated resourceList will be returned. func Run(p ResourceListProcessor, input []byte) ([]byte, error) { diff --git a/go/fn/run_filemode_property_test.go b/go/fn/run_filemode_property_test.go new file mode 100644 index 00000000..5439c043 --- /dev/null +++ b/go/fn/run_filemode_property_test.go @@ -0,0 +1,171 @@ +// Copyright 2025 The kpt Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fn + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "pgregory.net/rapid" +) + +// Feature: sdk-alignment, Property 6: File mode equivalence +// +// For any valid set of KRM YAML resources, processing them via standalone file +// mode SHALL produce identical output to processing the same resources assembled +// into a ResourceList via STDIN mode. +// +// Validates: Requirements 4.4 + +// genKRMResource generates a valid KRM YAML resource (a ConfigMap) with random +// name and namespace. ConfigMaps are used because they are simple, always valid +// KRM resources that don't require complex schemas. +func genKRMResource() *rapid.Generator[string] { + return rapid.Custom(func(t *rapid.T) string { + name := rapid.StringMatching(`[a-z][a-z0-9]{2,12}`).Draw(t, "name") + namespace := rapid.StringMatching(`[a-z][a-z0-9]{2,8}`).Draw(t, "namespace") + // Generate 1-3 data entries with YAML-safe values (alphanumeric only, + // no special characters that could be misinterpreted by the YAML parser). + numEntries := rapid.IntRange(1, 3).Draw(t, "numEntries") + dataLines := "" + for i := 0; i < numEntries; i++ { + key := rapid.StringMatching(`[a-z][a-z0-9]{1,8}`).Draw(t, fmt.Sprintf("key%d", i)) + value := rapid.StringMatching(`[a-zA-Z0-9]{1,15}`).Draw(t, fmt.Sprintf("value%d", i)) + dataLines += fmt.Sprintf(" %s: %s\n", key, value) + } + return fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap +metadata: + name: %s + namespace: %s +data: +%s`, name, namespace, dataLines) + }) +} + +// genKRMResourceList generates a slice of 1-5 valid KRM YAML resource strings. +func genKRMResourceList() *rapid.Generator[[]string] { + return rapid.SliceOfN(genKRMResource(), 1, 5) +} + +func TestProperty6_FileModeEquivalence(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + resources := genKRMResourceList().Draw(t, "resources") + + // --- File mode path --- + // Write each resource to a temp file. + tmpDir, err := os.MkdirTemp("", "property6-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + var filePaths []string + for i, res := range resources { + path := filepath.Join(tmpDir, fmt.Sprintf("resource-%d.yaml", i)) + if err := os.WriteFile(path, []byte(res), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + filePaths = append(filePaths, path) + } + + // Process via file mode: readFilesAsResourceList → Process → ToYAML. + // Use a no-op processor that passes items through unchanged. + noopProc := ResourceListProcessorFunc(func(rl *ResourceList) (bool, error) { + return true, nil + }) + + fileRL, err := readFilesAsResourceList(filePaths) + if err != nil { + t.Fatalf("readFilesAsResourceList failed: %v", err) + } + _, fnErr := noopProc.Process(fileRL) + if fnErr != nil { + t.Fatalf("file mode Process failed: %v", fnErr) + } + fileOutput, err := fileRL.ToYAML() + if err != nil { + t.Fatalf("file mode ToYAML failed: %v", err) + } + + // --- STDIN mode path --- + // Assemble the same resources into a ResourceList YAML (as STDIN would provide). + stdinInput := "apiVersion: config.kubernetes.io/v1\nkind: ResourceList\nitems:\n" + for _, res := range resources { + // Indent each resource line under items as a YAML list element. + stdinInput += "- " + first := true + for _, line := range splitLines(res) { + if first { + stdinInput += line + "\n" + first = false + } else { + stdinInput += " " + line + "\n" + } + } + } + + stdinOutput, err := Run(noopProc, []byte(stdinInput)) + if err != nil { + t.Fatalf("STDIN mode Run failed: %v\n Input:\n%s", err, stdinInput) + } + + // --- Compare outputs --- + // Parse both outputs as ResourceLists and compare items. + fileResultRL, err := ParseResourceList(fileOutput) + if err != nil { + t.Fatalf("failed to parse file mode output: %v\n Output:\n%s", err, string(fileOutput)) + } + stdinResultRL, err := ParseResourceList(stdinOutput) + if err != nil { + t.Fatalf("failed to parse STDIN mode output: %v\n Output:\n%s", err, string(stdinOutput)) + } + + // Compare item counts. + if len(fileResultRL.Items) != len(stdinResultRL.Items) { + t.Fatalf("item count mismatch: file mode has %d items, STDIN mode has %d items", + len(fileResultRL.Items), len(stdinResultRL.Items)) + } + + // Compare each item by its string representation (after sorting, which + // ToYAML does automatically). + for i := range fileResultRL.Items { + fileItem := fileResultRL.Items[i].String() + stdinItem := stdinResultRL.Items[i].String() + if fileItem != stdinItem { + t.Fatalf("item %d mismatch:\n File mode:\n%s\n STDIN mode:\n%s", + i, fileItem, stdinItem) + } + } + }) +} + +// splitLines splits a string into lines, preserving empty lines. +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} diff --git a/go/fn/run_filemode_test.go b/go/fn/run_filemode_test.go new file mode 100644 index 00000000..7906eaaf --- /dev/null +++ b/go/fn/run_filemode_test.go @@ -0,0 +1,428 @@ +// Copyright 2025 The kpt Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fn + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFileMode_ValidInput verifies that valid input files produce correct output +// on STDOUT when processed via file mode. +// Requirements: 4.1, 4.2 +func TestFileMode_ValidInput(t *testing.T) { + tmpDir := t.TempDir() + + // Write a valid ConfigMap resource to a temp file. + configMap := `apiVersion: v1 +kind: ConfigMap +metadata: + name: my-config + namespace: default +data: + key1: value1 +` + filePath := filepath.Join(tmpDir, "configmap.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0644)) + + // Set os.Args to simulate file mode invocation. + setArgs(t, []string{"cmd", filePath}) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor) + assert.NoError(t, err, "file mode with valid input should succeed") + }) + + // Verify output is a valid ResourceList containing the ConfigMap. + assert.NotEmpty(t, output, "file mode should produce output on STDOUT") + assert.Contains(t, output, "kind: ResourceList") + assert.Contains(t, output, "my-config") + assert.Contains(t, output, "key1: value1") +} + +// TestFileMode_MultipleFiles verifies that multiple valid input files are +// combined into a single ResourceList output. +// Requirements: 4.1, 4.2 +func TestFileMode_MultipleFiles(t *testing.T) { + tmpDir := t.TempDir() + + cm1 := `apiVersion: v1 +kind: ConfigMap +metadata: + name: config-one + namespace: default +data: + foo: bar +` + cm2 := `apiVersion: v1 +kind: ConfigMap +metadata: + name: config-two + namespace: default +data: + baz: qux +` + file1 := filepath.Join(tmpDir, "cm1.yaml") + file2 := filepath.Join(tmpDir, "cm2.yaml") + require.NoError(t, os.WriteFile(file1, []byte(cm1), 0644)) + require.NoError(t, os.WriteFile(file2, []byte(cm2), 0644)) + + setArgs(t, []string{"cmd", file1, file2}) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor) + assert.NoError(t, err, "file mode with multiple files should succeed") + }) + + // Both resources should appear in the output. + assert.Contains(t, output, "config-one") + assert.Contains(t, output, "config-two") + assert.Contains(t, output, "foo: bar") + assert.Contains(t, output, "baz: qux") +} + +// TestFileMode_NonExistentFile verifies that a non-existent file returns a +// descriptive error message including the file path. +// Requirements: 4.3 +func TestFileMode_NonExistentFile(t *testing.T) { + nonExistentPath := filepath.Join(t.TempDir(), "does-not-exist.yaml") + + setArgs(t, []string{"cmd", nonExistentPath}) + + err := AsMain(noopProcessor) + require.Error(t, err, "non-existent file should return an error") + assert.Contains(t, err.Error(), "file not found") + assert.Contains(t, err.Error(), nonExistentPath, "error should include the file path") +} + +// TestFileMode_InvalidYAML verifies that a file with invalid YAML returns a +// parse error. +// Requirements: 4.3 +func TestFileMode_InvalidYAML(t *testing.T) { + tmpDir := t.TempDir() + + invalidYAML := `{{{this is not valid YAML at all!!!` + filePath := filepath.Join(tmpDir, "invalid.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(invalidYAML), 0644)) + + setArgs(t, []string{"cmd", filePath}) + + err := AsMain(noopProcessor) + require.Error(t, err, "invalid YAML file should return an error") + assert.Contains(t, err.Error(), filePath, "error should include the file path") + assert.Contains(t, err.Error(), "failed to parse KRM resources from") +} + +// TestFileMode_EmptyFile verifies that an empty file proceeds without error +// (valid for generators that don't require input items). +// Requirements: 4.1 +func TestFileMode_EmptyFile(t *testing.T) { + tmpDir := t.TempDir() + + // Write an empty file. + filePath := filepath.Join(tmpDir, "empty.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(""), 0644)) + + setArgs(t, []string{"cmd", filePath}) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor) + assert.NoError(t, err, "empty file should proceed without error") + }) + + // Output should be a valid ResourceList (possibly with no items). + assert.NotEmpty(t, output, "file mode should still produce output") + assert.Contains(t, output, "kind: ResourceList") +} + +// TestFileMode_WhitespaceOnlyFile verifies that a file containing only +// whitespace is treated as empty and proceeds without error. +// Requirements: 4.1 +func TestFileMode_WhitespaceOnlyFile(t *testing.T) { + tmpDir := t.TempDir() + + filePath := filepath.Join(tmpDir, "whitespace.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(" \n\n \t \n"), 0644)) + + setArgs(t, []string{"cmd", filePath}) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor) + assert.NoError(t, err, "whitespace-only file should proceed without error") + }) + + assert.Contains(t, output, "kind: ResourceList") +} + +// TestFileMode_OutputToStdout verifies that file mode output goes to STDOUT +// (not STDERR or elsewhere). +// Requirements: 4.2 +func TestFileMode_OutputToStdout(t *testing.T) { + tmpDir := t.TempDir() + + configMap := `apiVersion: v1 +kind: ConfigMap +metadata: + name: stdout-test + namespace: test +data: + hello: world +` + filePath := filepath.Join(tmpDir, "resource.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0644)) + + setArgs(t, []string{"cmd", filePath}) + + // Capture both stdout and stderr to verify output goes to stdout only. + var stdoutOutput string + stderrOutput := captureStderr(t, func() { + stdoutOutput = captureStdout(t, func() { + err := AsMain(noopProcessor) + assert.NoError(t, err) + }) + }) + + // STDOUT should have the ResourceList output. + assert.Contains(t, stdoutOutput, "stdout-test") + assert.Contains(t, stdoutOutput, "kind: ResourceList") + + // STDERR should not contain the resource output. + assert.NotContains(t, stderrOutput, "stdout-test") +} + +// TestReadFilesAsResourceList_ValidFile tests the readFilesAsResourceList helper +// directly with a valid file. +func TestReadFilesAsResourceList_ValidFile(t *testing.T) { + tmpDir := t.TempDir() + + configMap := `apiVersion: v1 +kind: ConfigMap +metadata: + name: direct-test + namespace: default +data: + key: value +` + filePath := filepath.Join(tmpDir, "cm.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0644)) + + rl, err := readFilesAsResourceList([]string{filePath}) + require.NoError(t, err) + require.NotNil(t, rl) + + // Should have one item. + assert.Len(t, rl.Items, 1) + assert.Equal(t, "direct-test", rl.Items[0].GetName()) + assert.Equal(t, "ConfigMap", rl.Items[0].GetKind()) + + // FunctionConfig should be set (empty KubeObject). + assert.NotNil(t, rl.FunctionConfig) +} + +// TestReadFilesAsResourceList_NonExistentFile tests the readFilesAsResourceList +// helper directly with a non-existent file. +func TestReadFilesAsResourceList_NonExistentFile(t *testing.T) { + nonExistentPath := "/tmp/definitely-does-not-exist-12345.yaml" + + rl, err := readFilesAsResourceList([]string{nonExistentPath}) + require.Error(t, err) + assert.Nil(t, rl) + assert.Contains(t, err.Error(), "file not found") + assert.Contains(t, err.Error(), nonExistentPath) +} + +// TestReadFilesAsResourceList_InvalidYAML tests the readFilesAsResourceList +// helper directly with invalid YAML content. +func TestReadFilesAsResourceList_InvalidYAML(t *testing.T) { + tmpDir := t.TempDir() + + filePath := filepath.Join(tmpDir, "bad.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(`{{{not yaml`), 0644)) + + rl, err := readFilesAsResourceList([]string{filePath}) + require.Error(t, err) + assert.Nil(t, rl) + assert.Contains(t, err.Error(), "failed to parse KRM resources from") + assert.Contains(t, err.Error(), filePath) +} + +// TestReadFilesAsResourceList_EmptyFile tests the readFilesAsResourceList +// helper directly with an empty file. +func TestReadFilesAsResourceList_EmptyFile(t *testing.T) { + tmpDir := t.TempDir() + + filePath := filepath.Join(tmpDir, "empty.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(""), 0644)) + + rl, err := readFilesAsResourceList([]string{filePath}) + require.NoError(t, err) + require.NotNil(t, rl) + + // Empty file should result in no items. + assert.Empty(t, rl.Items) + // FunctionConfig should still be set. + assert.NotNil(t, rl.FunctionConfig) +} + +// TestReadFilesAsResourceList_MultiDocument tests that a file with multiple +// YAML documents (separated by ---) produces multiple items. +func TestReadFilesAsResourceList_MultiDocument(t *testing.T) { + tmpDir := t.TempDir() + + multiDoc := `apiVersion: v1 +kind: ConfigMap +metadata: + name: first + namespace: default +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: second + namespace: default +` + filePath := filepath.Join(tmpDir, "multi.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(multiDoc), 0644)) + + rl, err := readFilesAsResourceList([]string{filePath}) + require.NoError(t, err) + require.NotNil(t, rl) + + assert.Len(t, rl.Items, 2) + + // Verify both items are present (order may vary). + names := []string{rl.Items[0].GetName(), rl.Items[1].GetName()} + assert.Contains(t, names, "first") + assert.Contains(t, names, "second") +} + +// TestFileMode_ProcessorReceivesItems verifies that the processor actually +// receives the items from the file and can modify them. +// Requirements: 4.1, 4.2 +func TestFileMode_ProcessorReceivesItems(t *testing.T) { + tmpDir := t.TempDir() + + configMap := `apiVersion: v1 +kind: ConfigMap +metadata: + name: to-be-labeled + namespace: default +` + filePath := filepath.Join(tmpDir, "cm.yaml") + require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0644)) + + // Use a processor that adds a label to all items. + labelProc := ResourceListProcessorFunc(func(rl *ResourceList) (bool, error) { + for _, item := range rl.Items { + if err := item.SetLabel("added-by", "test"); err != nil { + return false, err + } + } + return true, nil + }) + + setArgs(t, []string{"cmd", filePath}) + + output := captureStdout(t, func() { + err := AsMain(labelProc) + assert.NoError(t, err) + }) + + // Verify the label was added in the output. + assert.Contains(t, output, "added-by") + assert.Contains(t, output, "test") +} + +// TestFileMode_HelpTakesPrecedence verifies that --help takes precedence over +// file paths when both are present. +func TestFileMode_HelpTakesPrecedence(t *testing.T) { + tmpDir := t.TempDir() + + filePath := filepath.Join(tmpDir, "cm.yaml") + require.NoError(t, os.WriteFile(filePath, []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n"), 0644)) + + setArgs(t, []string{"cmd", "--help", filePath}) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor) + assert.NoError(t, err) + }) + + // Should show help, not process the file. + assert.Contains(t, output, "No documentation available") + assert.NotContains(t, output, "kind: ResourceList") +} + +// TestFileMode_MixedValidAndEmpty verifies that a mix of valid and empty files +// works correctly — only the valid file contributes items. +func TestFileMode_MixedValidAndEmpty(t *testing.T) { + tmpDir := t.TempDir() + + configMap := `apiVersion: v1 +kind: ConfigMap +metadata: + name: only-item + namespace: default +` + validFile := filepath.Join(tmpDir, "valid.yaml") + emptyFile := filepath.Join(tmpDir, "empty.yaml") + require.NoError(t, os.WriteFile(validFile, []byte(configMap), 0644)) + require.NoError(t, os.WriteFile(emptyFile, []byte(""), 0644)) + + setArgs(t, []string{"cmd", emptyFile, validFile}) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor) + assert.NoError(t, err) + }) + + // Should contain the item from the valid file. + assert.Contains(t, output, "only-item") + // Should still be a valid ResourceList. + assert.Contains(t, output, "kind: ResourceList") + + // Verify we can parse the output. + rl, err := ParseResourceList([]byte(output)) + require.NoError(t, err) + assert.Len(t, rl.Items, 1) + assert.Equal(t, "only-item", rl.Items[0].GetName()) +} + +// TestFileMode_NonExistentAmongValid verifies that if one file in a list +// doesn't exist, the error is returned even if other files are valid. +// Requirements: 4.3 +func TestFileMode_NonExistentAmongValid(t *testing.T) { + tmpDir := t.TempDir() + + validFile := filepath.Join(tmpDir, "valid.yaml") + require.NoError(t, os.WriteFile(validFile, []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n"), 0644)) + + nonExistent := filepath.Join(tmpDir, "missing.yaml") + + setArgs(t, []string{"cmd", validFile, nonExistent}) + + // Capture stderr to suppress the error log from AsMain. + captureStderr(t, func() { + err := AsMain(noopProcessor) + require.Error(t, err) + assert.Contains(t, err.Error(), "file not found") + assert.Contains(t, err.Error(), strings.TrimPrefix(nonExistent, "")) + }) +} diff --git a/go/fn/run_flags_test.go b/go/fn/run_flags_test.go new file mode 100644 index 00000000..bf3e1c63 --- /dev/null +++ b/go/fn/run_flags_test.go @@ -0,0 +1,312 @@ +// Copyright 2025 The kpt Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fn + +import ( + "bytes" + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/kptdev/krm-functions-sdk/go/fn/internal/docs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// noopProcessor is a minimal ResourceListProcessor that does nothing. +// Used to satisfy AsMain's input requirement without triggering STDIN reads. +var noopProcessor = ResourceListProcessorFunc(func(rl *ResourceList) (bool, error) { + return true, nil +}) + +// captureStdout redirects os.Stdout to a pipe, runs fn, and returns what was written. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + origStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + fn() + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + r.Close() + + return buf.String() +} + +// captureStderr redirects os.Stderr to a pipe, runs fn, and returns what was written. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + + origStderr := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + + fn() + + w.Close() + os.Stderr = origStderr + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + r.Close() + + return buf.String() +} + +// setArgs temporarily sets os.Args for the duration of a test. +func setArgs(t *testing.T, args []string) { + t.Helper() + origArgs := os.Args + os.Args = args + t.Cleanup(func() { os.Args = origArgs }) +} + +// TestAsMain_HelpFlag_ExitsZero verifies that --help returns nil (exit 0) +// without reading STDIN. +// Requirements: 2.1 +func TestAsMain_HelpFlag_ExitsZero(t *testing.T) { + setArgs(t, []string{"cmd", "--help"}) + + // Close stdin to prove it's not read — if AsMain tries to read STDIN, + // it would get an error or EOF immediately. + origStdin := os.Stdin + r, w, err := os.Pipe() + require.NoError(t, err) + w.Close() // Close write end immediately — reading would get EOF + os.Stdin = r + t.Cleanup(func() { + os.Stdin = origStdin + r.Close() + }) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor, WithDocs([]byte("some readme"), []byte("image: test"))) + assert.NoError(t, err, "--help should return nil (exit 0)") + }) + + // Should have produced some output + assert.NotEmpty(t, output, "--help should produce output") +} + +// TestAsMain_HelpFlag_NoDocs verifies that --help with no WithDocs prints +// a minimal message. +// Requirements: 2.3 +func TestAsMain_HelpFlag_NoDocs(t *testing.T) { + setArgs(t, []string{"cmd", "--help"}) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor) + assert.NoError(t, err, "--help with no docs should return nil") + }) + + assert.Contains(t, output, "No documentation available") + assert.Contains(t, output, "fn.WithDocs") +} + +// TestAsMain_HelpFlag_WithDocs verifies that --help with WithDocs renders +// the README sections. +// Requirements: 2.2 +func TestAsMain_HelpFlag_WithDocs(t *testing.T) { + setArgs(t, []string{"cmd", "--help"}) + + readme := []byte(` +Set labels on resources + + + +The set-labels function adds labels to all resources. + + + + kpt fn eval --image set-labels:v0.1 + +`) + meta := []byte(`image: gcr.io/kpt-fn/set-labels:v0.1 +description: Set labels on all resources +`) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor, WithDocs(readme, meta)) + assert.NoError(t, err) + }) + + assert.Contains(t, output, "Set labels on resources") + assert.Contains(t, output, "set-labels function adds labels") + assert.Contains(t, output, "kpt fn eval") +} + +// TestAsMain_DocFlag_OutputsValidJSON verifies that --doc outputs valid JSON +// and returns nil (exit 0). +// Requirements: 3.1 +func TestAsMain_DocFlag_OutputsValidJSON(t *testing.T) { + setArgs(t, []string{"cmd", "--doc"}) + + readme := []byte(` +Set labels + + + +Long description here. + +`) + meta := []byte(`image: gcr.io/kpt-fn/set-labels:v0.1 +description: Set labels on all resources +tags: + - mutator +license: Apache-2.0 +`) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor, WithDocs(readme, meta)) + assert.NoError(t, err, "--doc should return nil (exit 0)") + }) + + // Verify it's valid JSON + var docOutput docs.DocOutput + err := json.Unmarshal([]byte(output), &docOutput) + require.NoError(t, err, "--doc output should be valid JSON") + + // Verify fields are populated + assert.Equal(t, "Set labels", docOutput.Short) + assert.Equal(t, "Long description here.", docOutput.Long) + assert.Equal(t, "gcr.io/kpt-fn/set-labels:v0.1", docOutput.Image) + assert.Equal(t, "Set labels on all resources", docOutput.Description) + assert.Equal(t, []string{"mutator"}, docOutput.Tags) + assert.Equal(t, "Apache-2.0", docOutput.License) +} + +// TestAsMain_DocFlag_NoDocs verifies that --doc with no WithDocs outputs `{}`. +// Requirements: 3.3 +func TestAsMain_DocFlag_NoDocs(t *testing.T) { + setArgs(t, []string{"cmd", "--doc"}) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor) + assert.NoError(t, err, "--doc with no docs should return nil") + }) + + assert.Equal(t, "{}", strings.TrimSpace(output)) +} + +// TestAsMain_DocFlag_HiddenField verifies that hidden:true in metadata +// propagates to the --doc JSON output. +// Requirements: 3.6 +func TestAsMain_DocFlag_HiddenField(t *testing.T) { + setArgs(t, []string{"cmd", "--doc"}) + + readme := []byte(` +Hidden function + +`) + meta := []byte(`image: gcr.io/kpt-fn/hidden-fn:v0.1 +description: A hidden function +hidden: true +`) + + output := captureStdout(t, func() { + err := AsMain(noopProcessor, WithDocs(readme, meta)) + assert.NoError(t, err) + }) + + var docOutput docs.DocOutput + err := json.Unmarshal([]byte(output), &docOutput) + require.NoError(t, err, "--doc output should be valid JSON") + + assert.True(t, docOutput.Hidden, "hidden:true should propagate to JSON output") + assert.Equal(t, "gcr.io/kpt-fn/hidden-fn:v0.1", docOutput.Image) +} + +// TestAsMain_DocFlag_InvalidMetadataYAML verifies that invalid metadata YAML +// logs a warning and continues with zero-value metadata (only README fields in output). +// Requirements: 5.5, 3.6 +func TestAsMain_DocFlag_InvalidMetadataYAML(t *testing.T) { + setArgs(t, []string{"cmd", "--doc"}) + + readme := []byte(` +My function + +`) + invalidMeta := []byte(`{{{not valid yaml at all!!!`) + + var stdoutOutput string + stderrOutput := captureStderr(t, func() { + stdoutOutput = captureStdout(t, func() { + err := AsMain(noopProcessor, WithDocs(readme, invalidMeta)) + assert.NoError(t, err, "invalid metadata should not cause AsMain to fail") + }) + }) + + // Verify warning was logged to stderr + assert.Contains(t, stderrOutput, "warning") + assert.Contains(t, stderrOutput, "invalid metadata YAML") + + // Verify JSON output still contains README fields + var docOutput docs.DocOutput + err := json.Unmarshal([]byte(stdoutOutput), &docOutput) + require.NoError(t, err, "--doc output should still be valid JSON") + + assert.Equal(t, "My function", docOutput.Short) + // Metadata fields should be zero-value + assert.Empty(t, docOutput.Image) + assert.Empty(t, docOutput.Description) + assert.Empty(t, docOutput.Tags) + assert.False(t, docOutput.Hidden) +} + +// TestAsMain_HelpFlag_InvalidMetadataYAML verifies that --help with invalid +// metadata YAML logs a warning and continues rendering help from README only. +// Requirements: 5.5 +func TestAsMain_HelpFlag_InvalidMetadataYAML(t *testing.T) { + setArgs(t, []string{"cmd", "--help"}) + + readme := []byte(` +My function short desc + + + +Detailed description of the function. + +`) + // Use YAML that actually fails to parse (unclosed flow mapping) + invalidMeta := []byte(`{{{not valid yaml at all!!!`) + + var stdoutOutput string + stderrOutput := captureStderr(t, func() { + stdoutOutput = captureStdout(t, func() { + err := AsMain(noopProcessor, WithDocs(readme, invalidMeta)) + assert.NoError(t, err, "invalid metadata should not cause --help to fail") + }) + }) + + // Verify warning was logged + assert.Contains(t, stderrOutput, "warning") + assert.Contains(t, stderrOutput, "invalid metadata YAML") + + // Verify help output still contains README sections + assert.Contains(t, stdoutOutput, "My function short desc") + assert.Contains(t, stdoutOutput, "Detailed description of the function") +} From 0ed000e9c9482bed9992a67135e9e82e5b92cdf5 Mon Sep 17 00:00:00 2001 From: Fiachra Corcoran Date: Thu, 7 May 2026 09:44:27 +0100 Subject: [PATCH 2/6] chore: apply go fix modernization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical changes from 'go fix ./...': - interface{} → any (Go 1.18+ type alias) - reflect.Ptr → reflect.Pointer (deprecated constant) - strings.Index+slice → strings.Cut (Go 1.18+ idiom) Signed-off-by: Fiachra Corcoran --- go/fn/internal/docs/markers.go | 6 +++--- go/fn/internal/docs/metadata_test.go | 2 +- go/fn/internal/docs/render_test.go | 4 ++-- go/fn/internal/test/variant_test.go | 2 +- go/fn/run_filemode_property_test.go | 22 ++++++++++++---------- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/go/fn/internal/docs/markers.go b/go/fn/internal/docs/markers.go index 0c6eab24..e4586ab0 100644 --- a/go/fn/internal/docs/markers.go +++ b/go/fn/internal/docs/markers.go @@ -65,12 +65,12 @@ func extractSection(content, startMarker string) string { afterStart := startIdx + len(startMarker) remaining := content[afterStart:] - endIdx := strings.Index(remaining, markerEnd) - if endIdx < 0 { + before, _, ok := strings.Cut(remaining, markerEnd) + if !ok { return "" } - return strings.TrimSpace(remaining[:endIdx]) + return strings.TrimSpace(before) } // hasAnyMarker reports whether the content contains any mdtogo marker. diff --git a/go/fn/internal/docs/metadata_test.go b/go/fn/internal/docs/metadata_test.go index 07a8e121..6f4aaf70 100644 --- a/go/fn/internal/docs/metadata_test.go +++ b/go/fn/internal/docs/metadata_test.go @@ -18,8 +18,8 @@ import ( "reflect" "testing" - "pgregory.net/rapid" "go.yaml.in/yaml/v3" + "pgregory.net/rapid" ) // Feature: sdk-alignment, Property 2: Metadata YAML round-trip diff --git a/go/fn/internal/docs/render_test.go b/go/fn/internal/docs/render_test.go index 4631a90e..a7599086 100644 --- a/go/fn/internal/docs/render_test.go +++ b/go/fn/internal/docs/render_test.go @@ -400,7 +400,7 @@ func TestRenderDoc_EmptyInputs(t *testing.T) { } // Verify output is valid JSON. - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(buf.Bytes(), &raw); err != nil { t.Fatalf("output is not valid JSON: %v\nRaw: %s", err, buf.String()) } @@ -443,7 +443,7 @@ func TestRenderDoc_HiddenFieldSerialization(t *testing.T) { } // Verify the raw JSON contains "hidden": true. - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(buf.Bytes(), &raw); err != nil { t.Fatalf("output is not valid JSON: %v\nRaw: %s", err, buf.String()) } diff --git a/go/fn/internal/test/variant_test.go b/go/fn/internal/test/variant_test.go index 65ded0ad..0b0e1058 100644 --- a/go/fn/internal/test/variant_test.go +++ b/go/fn/internal/test/variant_test.go @@ -264,7 +264,7 @@ func TestBadNewFromTypedObject(t *testing.T) { type Foo struct { metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty" yaml:"metadata,omitempty"` + metav1.ObjectMeta `json:"metadata" yaml:"metadata,omitempty"` DesiredReplicas int `json:"desiredReplicas,omitempty" yaml:"desiredReplicas,omitempty"` } diff --git a/go/fn/run_filemode_property_test.go b/go/fn/run_filemode_property_test.go index 5439c043..2893191c 100644 --- a/go/fn/run_filemode_property_test.go +++ b/go/fn/run_filemode_property_test.go @@ -18,6 +18,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "pgregory.net/rapid" @@ -41,11 +42,11 @@ func genKRMResource() *rapid.Generator[string] { // Generate 1-3 data entries with YAML-safe values (alphanumeric only, // no special characters that could be misinterpreted by the YAML parser). numEntries := rapid.IntRange(1, 3).Draw(t, "numEntries") - dataLines := "" - for i := 0; i < numEntries; i++ { + var dataLines strings.Builder + for i := range numEntries { key := rapid.StringMatching(`[a-z][a-z0-9]{1,8}`).Draw(t, fmt.Sprintf("key%d", i)) value := rapid.StringMatching(`[a-zA-Z0-9]{1,15}`).Draw(t, fmt.Sprintf("value%d", i)) - dataLines += fmt.Sprintf(" %s: %s\n", key, value) + dataLines.WriteString(fmt.Sprintf(" %s: %s\n", key, value)) } return fmt.Sprintf(`apiVersion: v1 kind: ConfigMap @@ -53,7 +54,7 @@ metadata: name: %s namespace: %s data: -%s`, name, namespace, dataLines) +%s`, name, namespace, dataLines.String()) }) } @@ -104,24 +105,25 @@ func TestProperty6_FileModeEquivalence(t *testing.T) { // --- STDIN mode path --- // Assemble the same resources into a ResourceList YAML (as STDIN would provide). - stdinInput := "apiVersion: config.kubernetes.io/v1\nkind: ResourceList\nitems:\n" + var stdinInput strings.Builder + stdinInput.WriteString("apiVersion: config.kubernetes.io/v1\nkind: ResourceList\nitems:\n") for _, res := range resources { // Indent each resource line under items as a YAML list element. - stdinInput += "- " + stdinInput.WriteString("- ") first := true for _, line := range splitLines(res) { if first { - stdinInput += line + "\n" + stdinInput.WriteString(line + "\n") first = false } else { - stdinInput += " " + line + "\n" + stdinInput.WriteString(" " + line + "\n") } } } - stdinOutput, err := Run(noopProc, []byte(stdinInput)) + stdinOutput, err := Run(noopProc, []byte(stdinInput.String())) if err != nil { - t.Fatalf("STDIN mode Run failed: %v\n Input:\n%s", err, stdinInput) + t.Fatalf("STDIN mode Run failed: %v\n Input:\n%s", err, stdinInput.String()) } // --- Compare outputs --- From bf78605aceb0562035b06e84e6af2813236ad9b1 Mon Sep 17 00:00:00 2001 From: Fiachra Corcoran Date: Thu, 7 May 2026 10:16:58 +0100 Subject: [PATCH 3/6] chore: gitignore rapid testdata failure files Signed-off-by: Fiachra Corcoran --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1dc16f55..bd60a666 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ ts/create-kpt-functions/bin *.swo *.swp +# rapid property-based testing failure reproductions +**/testdata/rapid/ + From 035fc21d8cd8063ef9fe59b6fa76de8b3fd5d100 Mon Sep 17 00:00:00 2001 From: Fiachra Corcoran Date: Thu, 7 May 2026 13:58:08 +0100 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20CI=20drift=20=E2=80=94=20use=20slice?= =?UTF-8?q?s.Contains,=20fix=20file=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace manual loops with slices.Contains for --help/--doc checks (matches what 'go fix' produces) - Change test file permissions from 0644 to 0600 (gosec G306) Signed-off-by: Fiachra Corcoran --- go/fn/run_filemode_property_test.go | 2 +- go/fn/run_filemode_test.go | 32 ++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/go/fn/run_filemode_property_test.go b/go/fn/run_filemode_property_test.go index 2893191c..1eefda76 100644 --- a/go/fn/run_filemode_property_test.go +++ b/go/fn/run_filemode_property_test.go @@ -78,7 +78,7 @@ func TestProperty6_FileModeEquivalence(t *testing.T) { var filePaths []string for i, res := range resources { path := filepath.Join(tmpDir, fmt.Sprintf("resource-%d.yaml", i)) - if err := os.WriteFile(path, []byte(res), 0644); err != nil { + if err := os.WriteFile(path, []byte(res), 0600); err != nil { t.Fatalf("failed to write temp file: %v", err) } filePaths = append(filePaths, path) diff --git a/go/fn/run_filemode_test.go b/go/fn/run_filemode_test.go index 7906eaaf..3994e851 100644 --- a/go/fn/run_filemode_test.go +++ b/go/fn/run_filemode_test.go @@ -40,7 +40,7 @@ data: key1: value1 ` filePath := filepath.Join(tmpDir, "configmap.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0600)) // Set os.Args to simulate file mode invocation. setArgs(t, []string{"cmd", filePath}) @@ -81,8 +81,8 @@ data: ` file1 := filepath.Join(tmpDir, "cm1.yaml") file2 := filepath.Join(tmpDir, "cm2.yaml") - require.NoError(t, os.WriteFile(file1, []byte(cm1), 0644)) - require.NoError(t, os.WriteFile(file2, []byte(cm2), 0644)) + require.NoError(t, os.WriteFile(file1, []byte(cm1), 0600)) + require.NoError(t, os.WriteFile(file2, []byte(cm2), 0600)) setArgs(t, []string{"cmd", file1, file2}) @@ -120,7 +120,7 @@ func TestFileMode_InvalidYAML(t *testing.T) { invalidYAML := `{{{this is not valid YAML at all!!!` filePath := filepath.Join(tmpDir, "invalid.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(invalidYAML), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(invalidYAML), 0600)) setArgs(t, []string{"cmd", filePath}) @@ -138,7 +138,7 @@ func TestFileMode_EmptyFile(t *testing.T) { // Write an empty file. filePath := filepath.Join(tmpDir, "empty.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(""), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(""), 0600)) setArgs(t, []string{"cmd", filePath}) @@ -159,7 +159,7 @@ func TestFileMode_WhitespaceOnlyFile(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "whitespace.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(" \n\n \t \n"), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(" \n\n \t \n"), 0600)) setArgs(t, []string{"cmd", filePath}) @@ -186,7 +186,7 @@ data: hello: world ` filePath := filepath.Join(tmpDir, "resource.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0600)) setArgs(t, []string{"cmd", filePath}) @@ -221,7 +221,7 @@ data: key: value ` filePath := filepath.Join(tmpDir, "cm.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0600)) rl, err := readFilesAsResourceList([]string{filePath}) require.NoError(t, err) @@ -254,7 +254,7 @@ func TestReadFilesAsResourceList_InvalidYAML(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "bad.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(`{{{not yaml`), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(`{{{not yaml`), 0600)) rl, err := readFilesAsResourceList([]string{filePath}) require.Error(t, err) @@ -269,7 +269,7 @@ func TestReadFilesAsResourceList_EmptyFile(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "empty.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(""), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(""), 0600)) rl, err := readFilesAsResourceList([]string{filePath}) require.NoError(t, err) @@ -299,7 +299,7 @@ metadata: namespace: default ` filePath := filepath.Join(tmpDir, "multi.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(multiDoc), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(multiDoc), 0600)) rl, err := readFilesAsResourceList([]string{filePath}) require.NoError(t, err) @@ -326,7 +326,7 @@ metadata: namespace: default ` filePath := filepath.Join(tmpDir, "cm.yaml") - require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte(configMap), 0600)) // Use a processor that adds a label to all items. labelProc := ResourceListProcessorFunc(func(rl *ResourceList) (bool, error) { @@ -356,7 +356,7 @@ func TestFileMode_HelpTakesPrecedence(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "cm.yaml") - require.NoError(t, os.WriteFile(filePath, []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n"), 0644)) + require.NoError(t, os.WriteFile(filePath, []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n"), 0600)) setArgs(t, []string{"cmd", "--help", filePath}) @@ -383,8 +383,8 @@ metadata: ` validFile := filepath.Join(tmpDir, "valid.yaml") emptyFile := filepath.Join(tmpDir, "empty.yaml") - require.NoError(t, os.WriteFile(validFile, []byte(configMap), 0644)) - require.NoError(t, os.WriteFile(emptyFile, []byte(""), 0644)) + require.NoError(t, os.WriteFile(validFile, []byte(configMap), 0600)) + require.NoError(t, os.WriteFile(emptyFile, []byte(""), 0600)) setArgs(t, []string{"cmd", emptyFile, validFile}) @@ -412,7 +412,7 @@ func TestFileMode_NonExistentAmongValid(t *testing.T) { tmpDir := t.TempDir() validFile := filepath.Join(tmpDir, "valid.yaml") - require.NoError(t, os.WriteFile(validFile, []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n"), 0644)) + require.NoError(t, os.WriteFile(validFile, []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n"), 0600)) nonExistent := filepath.Join(tmpDir, "missing.yaml") From cae380b1e3dc6eb33fc7d41e27250f967ad24b72 Mon Sep 17 00:00:00 2001 From: Fiachra Corcoran Date: Thu, 7 May 2026 18:53:49 +0100 Subject: [PATCH 5/6] fix: use <- All existing tests continue tomdtogo--> as end marker (matches catalog READMEs) The catalog functions use <- All existing tests continue tomdtogo--> (bare) as the section end marker, not <- All existing tests continue tomdtogo:End-->. Updated the parser and all tests to match the real-world README format. Signed-off-by: Fiachra Corcoran --- go/fn/internal/docs/markers.go | 4 ++-- go/fn/internal/docs/markers_test.go | 6 +++--- go/fn/internal/docs/render_test.go | 12 ++++++------ go/fn/run_flags_test.go | 18 +++++++++--------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/go/fn/internal/docs/markers.go b/go/fn/internal/docs/markers.go index e4586ab0..4bcb8bea 100644 --- a/go/fn/internal/docs/markers.go +++ b/go/fn/internal/docs/markers.go @@ -27,7 +27,7 @@ const ( markerShort = "" markerLong = "" markerExamples = "" - markerEnd = "" + markerEnd = "" ) // ParseMarkers extracts mdtogo marker sections from README content. @@ -56,7 +56,7 @@ func ParseMarkers(readme []byte) Sections { } // extractSection finds text between the given start marker and the next -// marker. Returns empty string if either marker is missing. +// end marker. Returns empty string if either marker is missing. func extractSection(content, startMarker string) string { startIdx := strings.Index(content, startMarker) if startIdx < 0 { diff --git a/go/fn/internal/docs/markers_test.go b/go/fn/internal/docs/markers_test.go index bc830748..a3896223 100644 --- a/go/fn/internal/docs/markers_test.go +++ b/go/fn/internal/docs/markers_test.go @@ -46,15 +46,15 @@ func genSectionContent() *rapid.Generator[string] { func formatMarkedREADME(short, long, examples string) string { return fmt.Sprintf(` %s - + %s - + %s - + `, short, long, examples) } diff --git a/go/fn/internal/docs/render_test.go b/go/fn/internal/docs/render_test.go index a7599086..7aa793b1 100644 --- a/go/fn/internal/docs/render_test.go +++ b/go/fn/internal/docs/render_test.go @@ -131,15 +131,15 @@ func TestProperty5_DocJSONContainsAllRequiredFields(t *testing.T) { // Format a README with valid mdtogo markers. readme := fmt.Sprintf(` %s - + %s - + %s - + `, short, long, examples) // Parse the README to get sections. @@ -226,15 +226,15 @@ func TestProperty3_HelpOutputContainsParsedSections(t *testing.T) { // Format a README with valid mdtogo markers. readme := fmt.Sprintf(` %s - + %s - + %s - + `, short, long, examples) // Parse the README to get sections (same as runtime would). diff --git a/go/fn/run_flags_test.go b/go/fn/run_flags_test.go index bf3e1c63..85e02a61 100644 --- a/go/fn/run_flags_test.go +++ b/go/fn/run_flags_test.go @@ -135,15 +135,15 @@ func TestAsMain_HelpFlag_WithDocs(t *testing.T) { readme := []byte(` Set labels on resources - + The set-labels function adds labels to all resources. - + kpt fn eval --image set-labels:v0.1 - + `) meta := []byte(`image: gcr.io/kpt-fn/set-labels:v0.1 description: Set labels on all resources @@ -167,11 +167,11 @@ func TestAsMain_DocFlag_OutputsValidJSON(t *testing.T) { readme := []byte(` Set labels - + Long description here. - + `) meta := []byte(`image: gcr.io/kpt-fn/set-labels:v0.1 description: Set labels on all resources @@ -220,7 +220,7 @@ func TestAsMain_DocFlag_HiddenField(t *testing.T) { readme := []byte(` Hidden function - + `) meta := []byte(`image: gcr.io/kpt-fn/hidden-fn:v0.1 description: A hidden function @@ -248,7 +248,7 @@ func TestAsMain_DocFlag_InvalidMetadataYAML(t *testing.T) { readme := []byte(` My function - + `) invalidMeta := []byte(`{{{not valid yaml at all!!!`) @@ -285,11 +285,11 @@ func TestAsMain_HelpFlag_InvalidMetadataYAML(t *testing.T) { readme := []byte(` My function short desc - + Detailed description of the function. - + `) // Use YAML that actually fails to parse (unclosed flow mapping) invalidMeta := []byte(`{{{not valid yaml at all!!!`) From c53c550a08ac8e1797007ce76c2311a2821cc753 Mon Sep 17 00:00:00 2001 From: Fiachra Corcoran Date: Mon, 25 May 2026 13:23:26 +0100 Subject: [PATCH 6/6] fix: skip single-dash flags in file mode arg parsing Go test runner passes -test.* flags via os.Args. The file mode arg parser must skip all flag-like arguments (starting with -), not just those starting with --. Signed-off-by: Fiachra Corcoran --- go/fn/run.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/go/fn/run.go b/go/fn/run.go index b2100199..0f3cfd7d 100644 --- a/go/fn/run.go +++ b/go/fn/run.go @@ -75,11 +75,13 @@ func AsMain(input any, opts ...Option) error { } // Collect non-flag positional arguments (file paths). + // Skip any argument that looks like a flag (starts with "-" or "--"). var filePaths []string for _, arg := range os.Args[1:] { - if !strings.HasPrefix(arg, "--") { - filePaths = append(filePaths, arg) + if strings.HasPrefix(arg, "-") { + continue } + filePaths = append(filePaths, arg) } err := func() error {