From 3c88a83ef999891ec6beede66ca0c2e2b227890a Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 9 Jun 2026 16:12:29 -0500 Subject: [PATCH 01/35] feat(cli): scaffold go module with cobra command tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OSI converters currently require manual environment setup and per-converter invocation. This lays the foundation for a unified CLI (osi) that will discover and invoke converters as plugins via a stdin/stdout JSON protocol. Creates cli/ at the repo root as a self-contained Go module (github.com/open-semantic-interchange/osi/cli). All commands are stubbed — no logic is wired in this commit. Design decisions: - Go chosen for static binary distribution; no runtime dependency for end users (brew/apt installable) - internal/osidir owns ~/.osi/plugins/ initialization and respects $OSI_PLUGIN_DIR for override; uses os.UserHomeDir() rather than $HOME for Windows portability - PersistentPreRunE on the root command ensures dir init runs before every subcommand; commented caveat that Cobra does not chain this automatically if a subcommand defines its own - MarkFlagsMutuallyExclusive("from", "to") handles the both-set case on osi convert; the neither-set case is validated manually in RunE since Cobra only guards against both being provided Build pipeline (Makefile, .goreleaser.yaml) and CI follow in separate commits. --- .gitignore | 4 ++++ cli/.tool-versions | 1 + cli/cmd/convert.go | 39 ++++++++++++++++++++++++++++++++++ cli/cmd/plugin/install.go | 22 +++++++++++++++++++ cli/cmd/plugin/list.go | 18 ++++++++++++++++ cli/cmd/plugin/plugin.go | 16 ++++++++++++++ cli/cmd/plugin/remove.go | 19 +++++++++++++++++ cli/cmd/root.go | 38 +++++++++++++++++++++++++++++++++ cli/cmd/validate.go | 24 +++++++++++++++++++++ cli/go.mod | 10 +++++++++ cli/go.sum | 10 +++++++++ cli/internal/osidir/osidir.go | 40 +++++++++++++++++++++++++++++++++++ cli/main.go | 21 ++++++++++++++++++ 13 files changed, 262 insertions(+) create mode 100644 cli/.tool-versions create mode 100644 cli/cmd/convert.go create mode 100644 cli/cmd/plugin/install.go create mode 100644 cli/cmd/plugin/list.go create mode 100644 cli/cmd/plugin/plugin.go create mode 100644 cli/cmd/plugin/remove.go create mode 100644 cli/cmd/root.go create mode 100644 cli/cmd/validate.go create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/internal/osidir/osidir.go create mode 100644 cli/main.go diff --git a/.gitignore b/.gitignore index 63e36ff..8cf6aa8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ **/__pycache__/ **/.venv/ + +# Go CLI +cli/dist/ +cli/osi diff --git a/cli/.tool-versions b/cli/.tool-versions new file mode 100644 index 0000000..05a23a6 --- /dev/null +++ b/cli/.tool-versions @@ -0,0 +1 @@ +golang 1.26.2 diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go new file mode 100644 index 0000000..aa8797d --- /dev/null +++ b/cli/cmd/convert.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var convertCmd = &cobra.Command{ + Use: "convert --from --input | --to --input ", + Short: "Convert a semantic model between OSI and a platform format", + RunE: runConvert, +} + +func init() { + convertCmd.Flags().String("from", "", "Source platform — converts platform → OSI") + convertCmd.Flags().String("to", "", "Target platform — converts OSI → platform") + convertCmd.Flags().StringP("input", "i", "", "Input file or directory path (required)") + convertCmd.Flags().StringP("output", "o", "", "Output directory path (default: ./osi-output//)") + convertCmd.Flags().String("plugin", "", "Path to plugin directory (bypasses name-based discovery)") + convertCmd.Flags().Int("timeout", 60, "Plugin invocation timeout in seconds") + convertCmd.Flags().String("max-input-size", "100MB", "Maximum total input size (e.g. 500MB)") + + _ = convertCmd.MarkFlagRequired("input") + convertCmd.MarkFlagsMutuallyExclusive("from", "to") +} + +func runConvert(cmd *cobra.Command, args []string) error { + from, _ := cmd.Flags().GetString("from") + to, _ := cmd.Flags().GetString("to") + + // MarkFlagsMutuallyExclusive handles the both-set case; handle neither here. + if from == "" && to == "" { + return fmt.Errorf("exactly one of --from or --to must be specified") + } + + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/plugin/install.go b/cli/cmd/plugin/install.go new file mode 100644 index 0000000..aabde2d --- /dev/null +++ b/cli/cmd/plugin/install.go @@ -0,0 +1,22 @@ +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var installCmd = &cobra.Command{ + Use: "install [name[@version] | url]", + Short: "Install a plugin from the registry or a URL", + RunE: runPluginInstall, +} + +func init() { + installCmd.Flags().Bool("all", false, "Install the latest version of all registry plugins") +} + +func runPluginInstall(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/plugin/list.go b/cli/cmd/plugin/list.go new file mode 100644 index 0000000..f69e8bb --- /dev/null +++ b/cli/cmd/plugin/list.go @@ -0,0 +1,18 @@ +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List installed and available plugins", + RunE: runPluginList, +} + +func runPluginList(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/plugin/plugin.go b/cli/cmd/plugin/plugin.go new file mode 100644 index 0000000..a57e103 --- /dev/null +++ b/cli/cmd/plugin/plugin.go @@ -0,0 +1,16 @@ +package plugin + +import "github.com/spf13/cobra" + +// Cmd is the parent "osi plugin" command. It is exported so cmd/root.go can +// register it. Invoking it bare prints help. +var Cmd = &cobra.Command{ + Use: "plugin", + Short: "Manage OSI plugins", +} + +func init() { + Cmd.AddCommand(listCmd) + Cmd.AddCommand(installCmd) + Cmd.AddCommand(removeCmd) +} diff --git a/cli/cmd/plugin/remove.go b/cli/cmd/plugin/remove.go new file mode 100644 index 0000000..1b88669 --- /dev/null +++ b/cli/cmd/plugin/remove.go @@ -0,0 +1,19 @@ +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove an installed plugin", + Args: cobra.ExactArgs(1), + RunE: runPluginRemove, +} + +func runPluginRemove(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go new file mode 100644 index 0000000..40d1fbc --- /dev/null +++ b/cli/cmd/root.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "github.com/open-semantic-interchange/osi/cli/cmd/plugin" + "github.com/open-semantic-interchange/osi/cli/internal/osidir" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "osi", + Short: "Open Semantic Interchange CLI", + Long: `osi is the command-line tool for the Open Semantic Interchange (OSI) project.`, + // NOTE: Cobra does NOT automatically chain PersistentPreRunE from parent to + // child. If any subcommand defines its own PersistentPreRunE or PreRunE, this + // function will not run for that subcommand. Future subcommands that define + // their own must call osidir.EnsurePluginDir() explicitly. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return osidir.EnsurePluginDir() + }, +} + +// Execute runs the root command. Called by main. +func Execute() error { + return rootCmd.Execute() +} + +// SetVersion sets the version string reported by `osi --version`. +func SetVersion(v string) { + rootCmd.Version = v +} + +func init() { + rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output (shows plugin stderr)") + + rootCmd.AddCommand(convertCmd) + rootCmd.AddCommand(validateCmd) + rootCmd.AddCommand(plugin.Cmd) +} diff --git a/cli/cmd/validate.go b/cli/cmd/validate.go new file mode 100644 index 0000000..5ef9edc --- /dev/null +++ b/cli/cmd/validate.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var validateCmd = &cobra.Command{ + Use: "validate [flags] [...]", + Short: "Validate one or more OSI YAML or JSON files", + Args: cobra.MinimumNArgs(1), + RunE: runValidate, +} + +func init() { + validateCmd.Flags().Bool("strict", false, "Promote warnings to errors") + validateCmd.Flags().String("output", "text", "Output format: text or json") +} + +func runValidate(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..eabd4e0 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,10 @@ +module github.com/open-semantic-interchange/osi/cli + +go 1.22 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cli/internal/osidir/osidir.go b/cli/internal/osidir/osidir.go new file mode 100644 index 0000000..cdb3f6b --- /dev/null +++ b/cli/internal/osidir/osidir.go @@ -0,0 +1,40 @@ +package osidir + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + defaultOSIDir = ".osi" + pluginsSubdir = "plugins" + envVar = "OSI_PLUGIN_DIR" +) + +// PluginDir returns the resolved plugin directory path. +// It respects $OSI_PLUGIN_DIR if set, otherwise defaults to ~/.osi/plugins/. +func PluginDir() (string, error) { + if override := os.Getenv(envVar); override != "" { + return override, nil + } + // Use os.UserHomeDir rather than $HOME for Windows portability. + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + return filepath.Join(home, defaultOSIDir, pluginsSubdir), nil +} + +// EnsurePluginDir ensures the plugin directory exists, creating it if needed. +// It is safe to call multiple times — os.MkdirAll is idempotent. +func EnsurePluginDir() error { + dir, err := PluginDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("could not create plugin directory %s: %w", dir, err) + } + return nil +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..9262cb5 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + "github.com/open-semantic-interchange/osi/cli/cmd" +) + +// version, commit, and date are set at build time by GoReleaser via ldflags. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + cmd.SetVersion(version) + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} From fcefdc89f9cde720af3bdfbe48ef4a45b9be1491 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 9 Jun 2026 16:33:56 -0500 Subject: [PATCH 02/35] chore(cli): add makefile and goreleaser build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables local builds and cross-platform release artifacts for the OSI CLI. Makefile provides standard targets for day-to-day development: build, test, lint, release-dry-run, and clean. All targets are designed to run from within cli/. GoReleaser config targets linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64. windows/arm64 is excluded — no widely available CI runner and negligible current demand. CGO_ENABLED=0 is set for fully static binaries, enabling cross-compilation from any host without a C toolchain. Version, commit, and date are injected at build time via ldflags from the vars declared in main.go. --- cli/.goreleaser.yaml | 48 ++++++++++++++++++++++++++++++++++++++++++++ cli/Makefile | 19 ++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 cli/.goreleaser.yaml create mode 100644 cli/Makefile diff --git a/cli/.goreleaser.yaml b/cli/.goreleaser.yaml new file mode 100644 index 0000000..2351e42 --- /dev/null +++ b/cli/.goreleaser.yaml @@ -0,0 +1,48 @@ +version: 2 + +project_name: osi + +before: + hooks: + - go mod tidy + +builds: + - id: osi + main: . + binary: osi + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + +archives: + - id: osi + format: tar.gz + format_overrides: + - goos: windows + format: zip + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/cli/Makefile b/cli/Makefile new file mode 100644 index 0000000..ed8de03 --- /dev/null +++ b/cli/Makefile @@ -0,0 +1,19 @@ +BINARY_NAME := osi +BUILD_DIR := dist + +.PHONY: build test lint release-dry-run clean + +build: + go build -o $(BUILD_DIR)/$(BINARY_NAME) . + +test: + go test ./... + +lint: + go vet ./... + +release-dry-run: + goreleaser release --snapshot --clean --config .goreleaser.yaml + +clean: + rm -rf $(BUILD_DIR) From ac660a089fc1550beff308c353c0f4ff767ebbca Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 9 Jun 2026 16:40:00 -0500 Subject: [PATCH 03/35] ci(cli): add github actions workflow for build and test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs go build, go vet, and go test on every push and pull request that touches cli/ or the workflow file itself. The paths filter prevents CLI changes from triggering unrelated workflows in this polyglot repo and vice versa. Go version is derived from go-version-file: cli/go.mod so the workflow automatically picks up any future toolchain bumps without a separate workflow edit. defaults.run.working-directory avoids repeating cd cli/ on every step. Cross-platform build testing is not included — CGO_ENABLED=0 means the linux/amd64 build is representative of all targets. GoReleaser snapshot builds are deferred to a future release workflow. --- .github/workflows/cli-ci.yml | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/cli-ci.yml diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml new file mode 100644 index 0000000..e729089 --- /dev/null +++ b/.github/workflows/cli-ci.yml @@ -0,0 +1,38 @@ +name: CLI CI + +on: + push: + paths: + - 'cli/**' + - '.github/workflows/cli-ci.yml' + pull_request: + paths: + - 'cli/**' + - '.github/workflows/cli-ci.yml' + +jobs: + build-and-test: + name: Build and test + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: go build + run: go build ./... + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... From 5f269d2c28390085a49b8affcf593051bbca5b86 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 9 Jun 2026 17:47:45 -0500 Subject: [PATCH 04/35] test(cli): add unit tests for internal/osidir Covers the only logic in F1 that warrants testing: $OSI_PLUGIN_DIR env var override, default path construction via os.UserHomeDir(), directory creation, and idempotent re-invocation of EnsurePluginDir. t.Setenv is used throughout so env var mutations are automatically restored after each test. t.TempDir is used for filesystem tests so no cleanup is needed and tests are safe to run in parallel. --- cli/internal/osidir/osidir_test.go | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 cli/internal/osidir/osidir_test.go diff --git a/cli/internal/osidir/osidir_test.go b/cli/internal/osidir/osidir_test.go new file mode 100644 index 0000000..1d39193 --- /dev/null +++ b/cli/internal/osidir/osidir_test.go @@ -0,0 +1,65 @@ +package osidir + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPluginDir_envOverride(t *testing.T) { + want := "/custom/plugin/dir" + t.Setenv(envVar, want) + + got, err := PluginDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginDir_default(t *testing.T) { + t.Setenv(envVar, "") + + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("could not determine home dir: %v", err) + } + want := filepath.Join(home, defaultOSIDir, pluginsSubdir) + + got, err := PluginDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEnsurePluginDir_createsDirectory(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "plugins") + t.Setenv(envVar, target) + + if err := EnsurePluginDir(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(target); os.IsNotExist(err) { + t.Errorf("expected directory %q to exist, but it does not", target) + } +} + +func TestEnsurePluginDir_idempotent(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "plugins") + t.Setenv(envVar, target) + + if err := EnsurePluginDir(); err != nil { + t.Fatalf("first call failed: %v", err) + } + if err := EnsurePluginDir(); err != nil { + t.Fatalf("second call failed: %v", err) + } +} From 780dcdb43703b503ef94c6b16b108b8d10b98b96 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Wed, 10 Jun 2026 15:45:12 -0500 Subject: [PATCH 05/35] chore(cli): rename osi to ossie throughout cli scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project branding has changed from OSI to OSSIE. Updates all user-facing and internal references in the CLI: - Binary name: osi → ossie - Go module path: .../osi/cli → .../ossie/cli - Default plugin directory: ~/.osi → ~/.ossie - Environment variable: OSI_PLUGIN_DIR → OSSIE_PLUGIN_DIR - GoReleaser project name and archive ids - All command descriptions and flag help text - Output directory default: ./osi-output → ./ossie-output --- cli/.goreleaser.yaml | 8 ++++---- cli/Makefile | 2 +- cli/cmd/convert.go | 8 ++++---- cli/cmd/plugin/plugin.go | 4 ++-- cli/cmd/root.go | 10 +++++----- cli/cmd/validate.go | 2 +- cli/go.mod | 2 +- cli/internal/osidir/osidir.go | 6 +++--- cli/main.go | 2 +- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/cli/.goreleaser.yaml b/cli/.goreleaser.yaml index 2351e42..3a0a17b 100644 --- a/cli/.goreleaser.yaml +++ b/cli/.goreleaser.yaml @@ -1,15 +1,15 @@ version: 2 -project_name: osi +project_name: ossie before: hooks: - go mod tidy builds: - - id: osi + - id: ossie main: . - binary: osi + binary: ossie goos: - linux - darwin @@ -29,7 +29,7 @@ builds: - -X main.date={{.Date}} archives: - - id: osi + - id: ossie format: tar.gz format_overrides: - goos: windows diff --git a/cli/Makefile b/cli/Makefile index ed8de03..9ecbcb7 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -1,4 +1,4 @@ -BINARY_NAME := osi +BINARY_NAME := ossie BUILD_DIR := dist .PHONY: build test lint release-dry-run clean diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go index aa8797d..92722ee 100644 --- a/cli/cmd/convert.go +++ b/cli/cmd/convert.go @@ -8,15 +8,15 @@ import ( var convertCmd = &cobra.Command{ Use: "convert --from --input | --to --input ", - Short: "Convert a semantic model between OSI and a platform format", + Short: "Convert a semantic model between OSSIE and a platform format", RunE: runConvert, } func init() { - convertCmd.Flags().String("from", "", "Source platform — converts platform → OSI") - convertCmd.Flags().String("to", "", "Target platform — converts OSI → platform") + convertCmd.Flags().String("from", "", "Source platform — converts platform → OSSIE") + convertCmd.Flags().String("to", "", "Target platform — converts OSSIE → platform") convertCmd.Flags().StringP("input", "i", "", "Input file or directory path (required)") - convertCmd.Flags().StringP("output", "o", "", "Output directory path (default: ./osi-output//)") + convertCmd.Flags().StringP("output", "o", "", "Output directory path (default: ./ossie-output//)") convertCmd.Flags().String("plugin", "", "Path to plugin directory (bypasses name-based discovery)") convertCmd.Flags().Int("timeout", 60, "Plugin invocation timeout in seconds") convertCmd.Flags().String("max-input-size", "100MB", "Maximum total input size (e.g. 500MB)") diff --git a/cli/cmd/plugin/plugin.go b/cli/cmd/plugin/plugin.go index a57e103..d996f13 100644 --- a/cli/cmd/plugin/plugin.go +++ b/cli/cmd/plugin/plugin.go @@ -2,11 +2,11 @@ package plugin import "github.com/spf13/cobra" -// Cmd is the parent "osi plugin" command. It is exported so cmd/root.go can +// Cmd is the parent "ossie plugin" command. It is exported so cmd/root.go can // register it. Invoking it bare prints help. var Cmd = &cobra.Command{ Use: "plugin", - Short: "Manage OSI plugins", + Short: "Manage OSSIE plugins", } func init() { diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 40d1fbc..45347bc 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -1,15 +1,15 @@ package cmd import ( - "github.com/open-semantic-interchange/osi/cli/cmd/plugin" - "github.com/open-semantic-interchange/osi/cli/internal/osidir" + "github.com/open-semantic-interchange/ossie/cli/cmd/plugin" + "github.com/open-semantic-interchange/ossie/cli/internal/osidir" "github.com/spf13/cobra" ) var rootCmd = &cobra.Command{ - Use: "osi", + Use: "ossie", Short: "Open Semantic Interchange CLI", - Long: `osi is the command-line tool for the Open Semantic Interchange (OSI) project.`, + Long: `ossie is the command-line tool for the Open Semantic Interchange (OSSIE) project.`, // NOTE: Cobra does NOT automatically chain PersistentPreRunE from parent to // child. If any subcommand defines its own PersistentPreRunE or PreRunE, this // function will not run for that subcommand. Future subcommands that define @@ -24,7 +24,7 @@ func Execute() error { return rootCmd.Execute() } -// SetVersion sets the version string reported by `osi --version`. +// SetVersion sets the version string reported by `ossie --version`. func SetVersion(v string) { rootCmd.Version = v } diff --git a/cli/cmd/validate.go b/cli/cmd/validate.go index 5ef9edc..b11d00a 100644 --- a/cli/cmd/validate.go +++ b/cli/cmd/validate.go @@ -8,7 +8,7 @@ import ( var validateCmd = &cobra.Command{ Use: "validate [flags] [...]", - Short: "Validate one or more OSI YAML or JSON files", + Short: "Validate one or more OSSIE YAML or JSON files", Args: cobra.MinimumNArgs(1), RunE: runValidate, } diff --git a/cli/go.mod b/cli/go.mod index eabd4e0..a828425 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,4 +1,4 @@ -module github.com/open-semantic-interchange/osi/cli +module github.com/open-semantic-interchange/ossie/cli go 1.22 diff --git a/cli/internal/osidir/osidir.go b/cli/internal/osidir/osidir.go index cdb3f6b..27fb742 100644 --- a/cli/internal/osidir/osidir.go +++ b/cli/internal/osidir/osidir.go @@ -7,13 +7,13 @@ import ( ) const ( - defaultOSIDir = ".osi" + defaultOSIDir = ".ossie" pluginsSubdir = "plugins" - envVar = "OSI_PLUGIN_DIR" + envVar = "OSSIE_PLUGIN_DIR" ) // PluginDir returns the resolved plugin directory path. -// It respects $OSI_PLUGIN_DIR if set, otherwise defaults to ~/.osi/plugins/. +// It respects $OSSIE_PLUGIN_DIR if set, otherwise defaults to ~/.ossie/plugins/. func PluginDir() (string, error) { if override := os.Getenv(envVar); override != "" { return override, nil diff --git a/cli/main.go b/cli/main.go index 9262cb5..9d2a202 100644 --- a/cli/main.go +++ b/cli/main.go @@ -3,7 +3,7 @@ package main import ( "os" - "github.com/open-semantic-interchange/osi/cli/cmd" + "github.com/open-semantic-interchange/ossie/cli/cmd" ) // version, commit, and date are set at build time by GoReleaser via ldflags. From 22e986e148d575bfa00585079242114ee1088386 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Fri, 12 Jun 2026 10:04:42 -0500 Subject: [PATCH 06/35] chore(cli): rename internal osidir package to ossiedir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the OSI → OSSIE rename by updating the internal package directory, package declaration, and import reference in cmd/root.go. --- cli/cmd/root.go | 6 +++--- cli/internal/{osidir => ossiedir}/osidir.go | 2 +- cli/internal/{osidir => ossiedir}/osidir_test.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename cli/internal/{osidir => ossiedir}/osidir.go (98%) rename cli/internal/{osidir => ossiedir}/osidir_test.go (98%) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 45347bc..772d87b 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -2,7 +2,7 @@ package cmd import ( "github.com/open-semantic-interchange/ossie/cli/cmd/plugin" - "github.com/open-semantic-interchange/ossie/cli/internal/osidir" + "github.com/open-semantic-interchange/ossie/cli/internal/ossiedir" "github.com/spf13/cobra" ) @@ -13,9 +13,9 @@ var rootCmd = &cobra.Command{ // NOTE: Cobra does NOT automatically chain PersistentPreRunE from parent to // child. If any subcommand defines its own PersistentPreRunE or PreRunE, this // function will not run for that subcommand. Future subcommands that define - // their own must call osidir.EnsurePluginDir() explicitly. + // their own must call ossiedir.EnsurePluginDir() explicitly. PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - return osidir.EnsurePluginDir() + return ossiedir.EnsurePluginDir() }, } diff --git a/cli/internal/osidir/osidir.go b/cli/internal/ossiedir/osidir.go similarity index 98% rename from cli/internal/osidir/osidir.go rename to cli/internal/ossiedir/osidir.go index 27fb742..9396041 100644 --- a/cli/internal/osidir/osidir.go +++ b/cli/internal/ossiedir/osidir.go @@ -1,4 +1,4 @@ -package osidir +package ossiedir import ( "fmt" diff --git a/cli/internal/osidir/osidir_test.go b/cli/internal/ossiedir/osidir_test.go similarity index 98% rename from cli/internal/osidir/osidir_test.go rename to cli/internal/ossiedir/osidir_test.go index 1d39193..0fe341c 100644 --- a/cli/internal/osidir/osidir_test.go +++ b/cli/internal/ossiedir/osidir_test.go @@ -1,4 +1,4 @@ -package osidir +package ossiedir import ( "os" From 32955eb43e302fc25c2c46f486d8762d21ceeaf8 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 15:32:53 -0500 Subject: [PATCH 07/35] fix(ossiedir): rename defaultOSIDir constant to defaultOssieDir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The osi → ossie rename missed this unexported constant. The value (.ossie) was already correct; only the identifier name was stale. --- cli/internal/ossiedir/osidir.go | 4 ++-- cli/internal/ossiedir/osidir_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/internal/ossiedir/osidir.go b/cli/internal/ossiedir/osidir.go index 9396041..051a8bd 100644 --- a/cli/internal/ossiedir/osidir.go +++ b/cli/internal/ossiedir/osidir.go @@ -7,7 +7,7 @@ import ( ) const ( - defaultOSIDir = ".ossie" + defaultOssieDir = ".ossie" pluginsSubdir = "plugins" envVar = "OSSIE_PLUGIN_DIR" ) @@ -23,7 +23,7 @@ func PluginDir() (string, error) { if err != nil { return "", fmt.Errorf("could not determine home directory: %w", err) } - return filepath.Join(home, defaultOSIDir, pluginsSubdir), nil + return filepath.Join(home, defaultOssieDir, pluginsSubdir), nil } // EnsurePluginDir ensures the plugin directory exists, creating it if needed. diff --git a/cli/internal/ossiedir/osidir_test.go b/cli/internal/ossiedir/osidir_test.go index 0fe341c..76a8981 100644 --- a/cli/internal/ossiedir/osidir_test.go +++ b/cli/internal/ossiedir/osidir_test.go @@ -26,7 +26,7 @@ func TestPluginDir_default(t *testing.T) { if err != nil { t.Fatalf("could not determine home dir: %v", err) } - want := filepath.Join(home, defaultOSIDir, pluginsSubdir) + want := filepath.Join(home, defaultOssieDir, pluginsSubdir) got, err := PluginDir() if err != nil { From c707545a6c93d432bb2f087fc6b8e92830b0227a Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 15:33:28 -0500 Subject: [PATCH 08/35] chore(cli): fix .gitignore to ignore cli/ossie build artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The osi → ossie rename missed the gitignore entry, so the local build artifact cli/ossie would no longer be ignored by git. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8cf6aa8..5f39ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ # Go CLI cli/dist/ -cli/osi +cli/ossie From 7f9fdb75510e69259b4e4b4b7e8a8ad856060347 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Wed, 24 Jun 2026 16:11:52 -0500 Subject: [PATCH 09/35] chore(ossiedir): rename osidir.go and osidir_test.go to ossiedir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File names were inconsistent with the package directory name (ossiedir/). Pure rename — no code changes. --- cli/internal/ossiedir/{osidir.go => ossiedir.go} | 0 cli/internal/ossiedir/{osidir_test.go => ossiedir_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename cli/internal/ossiedir/{osidir.go => ossiedir.go} (100%) rename cli/internal/ossiedir/{osidir_test.go => ossiedir_test.go} (100%) diff --git a/cli/internal/ossiedir/osidir.go b/cli/internal/ossiedir/ossiedir.go similarity index 100% rename from cli/internal/ossiedir/osidir.go rename to cli/internal/ossiedir/ossiedir.go diff --git a/cli/internal/ossiedir/osidir_test.go b/cli/internal/ossiedir/ossiedir_test.go similarity index 100% rename from cli/internal/ossiedir/osidir_test.go rename to cli/internal/ossiedir/ossiedir_test.go From d11a37524cfedd6fce5b8e7d9adae8938505d24d Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:11:37 -0500 Subject: [PATCH 10/35] feat(cli): add plugin discovery infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downstream segments (convert, plugin list, plugin install) all need a way to find and parse installed plugins. This adds the shared foundation: a typed Plugin struct and a Discover() function that scans ~/.ossie/plugins/ for valid plugin.yaml files. Design decisions: - internal/plugin accepts pluginsDir as a parameter rather than calling ossiedir.PluginDir() internally — keeps the package testable without env var manipulation - plugin.yaml supports both ossie_* (preferred) and osi_* (deprecated fallback) key families to avoid breaking plugins written before the OSI → OSSIE rename; ossie_* wins when both are present - Malformed plugin dirs emit a warning to an io.Writer and are skipped; only an unreadable plugins directory is a hard error, matching the behavior specified in the architecture doc - Non-directory entries in the plugins dir are silently skipped (no warning); non-existent dir returns nil, nil Caveats: - Symlinked plugin directories are not yet handled — os.ReadDir reports symlinks with ModeSymlink, not IsDir(); a TODO comment marks the location for a future follow-up - cmd/plugin/list.go wiring is deferred to P1, which also requires the embedded registry before the full table output can be rendered --- cli/go.mod | 5 +- cli/go.sum | 3 + cli/internal/plugin/discover.go | 72 +++++++ cli/internal/plugin/discover_test.go | 299 +++++++++++++++++++++++++++ cli/internal/plugin/plugin.go | 123 +++++++++++ 5 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 cli/internal/plugin/discover.go create mode 100644 cli/internal/plugin/discover_test.go create mode 100644 cli/internal/plugin/plugin.go diff --git a/cli/go.mod b/cli/go.mod index a828425..d17729d 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -2,7 +2,10 @@ module github.com/open-semantic-interchange/ossie/cli go 1.22 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index a6ee3e0..47edb24 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -7,4 +7,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/internal/plugin/discover.go b/cli/internal/plugin/discover.go new file mode 100644 index 0000000..700dec1 --- /dev/null +++ b/cli/internal/plugin/discover.go @@ -0,0 +1,72 @@ +package plugin + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const pluginYAML = "plugin.yaml" + +// Discover scans pluginsDir for subdirectories that contain a plugin.yaml, +// parses and validates each one, and returns all valid plugins. +// +// Malformed or invalid plugin directories are skipped with a warning written +// to stderr in the format: +// +// warning: skipping plugin at : +// +// A non-existent pluginsDir is treated as empty and returns (nil, nil). +// A hard error is only returned if pluginsDir itself cannot be read. +func Discover(pluginsDir string, stderr io.Writer) ([]*Plugin, error) { + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("could not read plugin directory %s: %w", pluginsDir, err) + } + + var plugins []*Plugin + for _, entry := range entries { + // TODO: handle symlinked plugin directories + // (entry.Type()&os.ModeSymlink != 0 requires os.Stat to resolve) + if !entry.IsDir() { + continue + } + pluginPath := filepath.Join(pluginsDir, entry.Name()) + p, err := loadPlugin(pluginPath) + if err != nil { + fmt.Fprintf(stderr, "warning: skipping plugin at %s: %s\n", pluginPath, err) + continue + } + plugins = append(plugins, p) + } + return plugins, nil +} + +// loadPlugin reads, parses, and validates the plugin.yaml inside dir. +func loadPlugin(dir string) (*Plugin, error) { + yamlPath := filepath.Join(dir, pluginYAML) + data, err := os.ReadFile(yamlPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("no plugin.yaml found") + } + return nil, fmt.Errorf("could not read plugin.yaml: %w", err) + } + + var raw rawPlugin + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("invalid YAML: %w", err) + } + + if err := raw.validate(); err != nil { + return nil, err + } + + return raw.toPlugin(dir), nil +} diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go new file mode 100644 index 0000000..1ed7ce6 --- /dev/null +++ b/cli/internal/plugin/discover_test.go @@ -0,0 +1,299 @@ +package plugin_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" +) + +// validPluginYAML is the canonical plugin.yaml fixture using ossie_* keys. +const validPluginYAML = ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +platform: + name: dbt + vendor: dbt Labs +convert: + to_osi: + invoke: ["ossie-plugin-dbt", "to-osi"] + accepts: [".yaml", ".json"] + from_osi: + invoke: ["ossie-plugin-dbt", "from-osi"] +` + +// writePlugin creates a plugin directory under root with the given plugin.yaml content. +func writePlugin(t *testing.T, root, name, content string) string { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("could not create plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte(content), 0644); err != nil { + t.Fatalf("could not write plugin.yaml: %v", err) + } + return dir +} + +func TestDiscover_emptyDir(t *testing.T) { + dir := t.TempDir() + var stderr strings.Builder + + plugins, err := plugin.Discover(dir, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected no plugins, got %d", len(plugins)) + } + if stderr.Len() != 0 { + t.Errorf("expected no warnings, got: %q", stderr.String()) + } +} + +func TestDiscover_nonExistentDir(t *testing.T) { + var stderr strings.Builder + + plugins, err := plugin.Discover("/this/path/does/not/exist", &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if plugins != nil { + t.Errorf("expected nil slice, got %v", plugins) + } +} + +func TestDiscover_validPlugin(t *testing.T) { + root := t.TempDir() + pluginDir := writePlugin(t, root, "dbt", validPluginYAML) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + p := plugins[0] + if p.Path != pluginDir { + t.Errorf("Path: got %q, want %q", p.Path, pluginDir) + } + if p.Platform.Name != "dbt" { + t.Errorf("Platform.Name: got %q, want %q", p.Platform.Name, "dbt") + } + if p.Platform.Vendor != "dbt Labs" { + t.Errorf("Platform.Vendor: got %q, want %q", p.Platform.Vendor, "dbt Labs") + } + if p.OSSIEPluginSpec != "0.1.0" { + t.Errorf("OSSIEPluginSpec: got %q, want %q", p.OSSIEPluginSpec, "0.1.0") + } + if p.OSSIESpecVersion != ">=0.2.0" { + t.Errorf("OSSIESpecVersion: got %q, want %q", p.OSSIESpecVersion, ">=0.2.0") + } + wantInvoke := []string{"ossie-plugin-dbt", "to-osi"} + if !equalStringSlice(p.Convert.ToOSI.Invoke, wantInvoke) { + t.Errorf("ToOSI.Invoke: got %v, want %v", p.Convert.ToOSI.Invoke, wantInvoke) + } + wantAccepts := []string{".yaml", ".json"} + if !equalStringSlice(p.Convert.ToOSI.Accepts, wantAccepts) { + t.Errorf("ToOSI.Accepts: got %v, want %v", p.Convert.ToOSI.Accepts, wantAccepts) + } + wantFromInvoke := []string{"ossie-plugin-dbt", "from-osi"} + if !equalStringSlice(p.Convert.FromOSI.Invoke, wantFromInvoke) { + t.Errorf("FromOSI.Invoke: got %v, want %v", p.Convert.FromOSI.Invoke, wantFromInvoke) + } + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) + } +} + +func TestDiscover_malformedYAML(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "bad", "this: is: not: valid: yaml: ][") + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if !strings.Contains(stderr.String(), filepath.Join(root, "bad")) { + t.Errorf("warning should contain plugin path, got: %q", stderr.String()) + } +} + +func TestDiscover_missingRequiredField(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "noplat", ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +platform: + vendor: dbt Labs +convert: + to_osi: + invoke: ["bin/convert"] + accepts: [".yaml"] + from_osi: + invoke: ["bin/convert"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if !strings.Contains(stderr.String(), filepath.Join(root, "noplat")) { + t.Errorf("warning should contain plugin path, got: %q", stderr.String()) + } +} + +func TestDiscover_missingPluginYAML(t *testing.T) { + root := t.TempDir() + // Create a directory with no plugin.yaml inside it. + if err := os.MkdirAll(filepath.Join(root, "empty-plugin"), 0755); err != nil { + t.Fatal(err) + } + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if !strings.Contains(stderr.String(), filepath.Join(root, "empty-plugin")) { + t.Errorf("warning should contain plugin path, got: %q", stderr.String()) + } +} + +func TestDiscover_multipleMixed(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "valid", validPluginYAML) + writePlugin(t, root, "malformed", "bad: yaml: ][") + if err := os.MkdirAll(filepath.Join(root, "no-yaml"), 0755); err != nil { + t.Fatal(err) + } + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Errorf("expected 1 plugin, got %d", len(plugins)) + } + warnings := stderr.String() + if !strings.Contains(warnings, filepath.Join(root, "malformed")) { + t.Errorf("expected warning for malformed, got: %q", warnings) + } + if !strings.Contains(warnings, filepath.Join(root, "no-yaml")) { + t.Errorf("expected warning for no-yaml, got: %q", warnings) + } +} + +func TestDiscover_ossieKeyPreferred(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "both-keys", ` +ossie_plugin_spec: "ossie-value" +osi_plugin_spec: "osi-value" +ossie_spec_version: ">=1.0.0" +osi_spec_version: ">=0.1.0" +platform: + name: test +convert: + to_osi: + invoke: ["bin/convert"] + accepts: [".yaml"] + from_osi: + invoke: ["bin/convert"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].OSSIEPluginSpec != "ossie-value" { + t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "ossie-value") + } + if plugins[0].OSSIESpecVersion != ">=1.0.0" { + t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=1.0.0") + } +} + +func TestDiscover_osiKeyFallback(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "osi-only", ` +osi_plugin_spec: "0.2.0" +osi_spec_version: ">=0.1.0" +platform: + name: legacy +convert: + to_osi: + invoke: ["bin/convert"] + accepts: [".json"] + from_osi: + invoke: ["bin/convert"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].OSSIEPluginSpec != "0.2.0" { + t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "0.2.0") + } + if plugins[0].OSSIESpecVersion != ">=0.1.0" { + t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=0.1.0") + } +} + +func TestDiscover_strayFileIgnored(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "valid", validPluginYAML) + // Place a plain file alongside the plugin directory. + if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("not a plugin"), 0644); err != nil { + t.Fatal(err) + } + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Errorf("expected 1 plugin, got %d", len(plugins)) + } + if stderr.Len() != 0 { + t.Errorf("expected no warnings for stray file, got: %q", stderr.String()) + } +} + +// equalStringSlice returns true if a and b contain the same elements in the same order. +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cli/internal/plugin/plugin.go b/cli/internal/plugin/plugin.go new file mode 100644 index 0000000..00fa5a1 --- /dev/null +++ b/cli/internal/plugin/plugin.go @@ -0,0 +1,123 @@ +package plugin + +import "errors" + +// Plugin is the parsed and validated representation of a single installed plugin. +// Path is the absolute path to the plugin's installation directory on disk. +type Plugin struct { + Path string // absolute path to plugin dir on disk + OSSIEPluginSpec string + OSSIESpecVersion string + Platform Platform + Setup string // relative path to setup script; empty = no setup + Convert ConvertConfig +} + +// Platform identifies the semantic platform this plugin handles. +type Platform struct { + Name string // required; matched against --from/--to values + Vendor string // optional; human-readable +} + +// ConvertConfig holds both conversion directions. +type ConvertConfig struct { + ToOSI Direction + FromOSI Direction +} + +// Direction describes one conversion direction. +type Direction struct { + Invoke []string // command + args; first element is the executable + Accepts []string // file extensions e.g. [".yaml"]; populated on ToOSI only +} + +// rawPlugin mirrors the on-disk plugin.yaml layout for YAML unmarshaling. +// +// ossie_* keys are the preferred (current brand) names. osi_* keys are +// deprecated aliases kept for backward compatibility with plugins written +// before the OSI → OSSIE rename. When both are present, ossie_* wins. +// TODO: remove osi_* fallback support in a future major version. +type rawPlugin struct { + OSSIEPluginSpec string `yaml:"ossie_plugin_spec"` + OSSIESpecVersion string `yaml:"ossie_spec_version"` + OSIPluginSpec string `yaml:"osi_plugin_spec"` // deprecated + OSISpecVersion string `yaml:"osi_spec_version"` // deprecated + + Platform struct { + Name string `yaml:"name"` + Vendor string `yaml:"vendor"` + } `yaml:"platform"` + + Setup string `yaml:"setup"` + + Convert struct { + ToOSI struct { + Invoke []string `yaml:"invoke"` + Accepts []string `yaml:"accepts"` + } `yaml:"to_osi"` + FromOSI struct { + Invoke []string `yaml:"invoke"` + } `yaml:"from_osi"` + } `yaml:"convert"` +} + +// validate checks that all required fields are present. +// It performs presence checks only — not format validation. +func (r *rawPlugin) validate() error { + pluginSpec := r.OSSIEPluginSpec + if pluginSpec == "" { + pluginSpec = r.OSIPluginSpec + } + specVersion := r.OSSIESpecVersion + if specVersion == "" { + specVersion = r.OSISpecVersion + } + + switch { + case pluginSpec == "": + return errors.New("missing required field: ossie_plugin_spec (or osi_plugin_spec)") + case specVersion == "": + return errors.New("missing required field: ossie_spec_version (or osi_spec_version)") + case r.Platform.Name == "": + return errors.New("missing required field: platform.name") + case len(r.Convert.ToOSI.Invoke) == 0: + return errors.New("missing required field: convert.to_osi.invoke") + case len(r.Convert.ToOSI.Accepts) == 0: + return errors.New("missing required field: convert.to_osi.accepts") + case len(r.Convert.FromOSI.Invoke) == 0: + return errors.New("missing required field: convert.from_osi.invoke") + } + return nil +} + +// toPlugin maps a validated rawPlugin to the exported Plugin type. +// path is the absolute directory path of the plugin's installation. +func (r *rawPlugin) toPlugin(path string) *Plugin { + pluginSpec := r.OSSIEPluginSpec + if pluginSpec == "" { + pluginSpec = r.OSIPluginSpec + } + specVersion := r.OSSIESpecVersion + if specVersion == "" { + specVersion = r.OSISpecVersion + } + return &Plugin{ + Path: path, + OSSIEPluginSpec: pluginSpec, + OSSIESpecVersion: specVersion, + Platform: Platform{ + Name: r.Platform.Name, + Vendor: r.Platform.Vendor, + }, + Setup: r.Setup, + Convert: ConvertConfig{ + ToOSI: Direction{ + Invoke: r.Convert.ToOSI.Invoke, + Accepts: r.Convert.ToOSI.Accepts, + }, + FromOSI: Direction{ + Invoke: r.Convert.FromOSI.Invoke, + }, + }, + } +} From ca72dc9912564f1f5e5e3ea26f2000ab0718fa24 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:20:24 -0500 Subject: [PATCH 11/35] test(cli): replace equalStringSlice helper with slices.Equal go.mod declares go 1.22 so slices.Equal (stdlib since 1.21) is available. Removes the hand-rolled utility in favour of the standard library equivalent. --- cli/internal/plugin/discover_test.go | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index 1ed7ce6..1ff22c2 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -3,6 +3,7 @@ package plugin_test import ( "os" "path/filepath" + "slices" "strings" "testing" @@ -94,15 +95,15 @@ func TestDiscover_validPlugin(t *testing.T) { t.Errorf("OSSIESpecVersion: got %q, want %q", p.OSSIESpecVersion, ">=0.2.0") } wantInvoke := []string{"ossie-plugin-dbt", "to-osi"} - if !equalStringSlice(p.Convert.ToOSI.Invoke, wantInvoke) { + if !slices.Equal(p.Convert.ToOSI.Invoke, wantInvoke) { t.Errorf("ToOSI.Invoke: got %v, want %v", p.Convert.ToOSI.Invoke, wantInvoke) } wantAccepts := []string{".yaml", ".json"} - if !equalStringSlice(p.Convert.ToOSI.Accepts, wantAccepts) { + if !slices.Equal(p.Convert.ToOSI.Accepts, wantAccepts) { t.Errorf("ToOSI.Accepts: got %v, want %v", p.Convert.ToOSI.Accepts, wantAccepts) } wantFromInvoke := []string{"ossie-plugin-dbt", "from-osi"} - if !equalStringSlice(p.Convert.FromOSI.Invoke, wantFromInvoke) { + if !slices.Equal(p.Convert.FromOSI.Invoke, wantFromInvoke) { t.Errorf("FromOSI.Invoke: got %v, want %v", p.Convert.FromOSI.Invoke, wantFromInvoke) } if stderr.Len() != 0 { @@ -285,15 +286,3 @@ func TestDiscover_strayFileIgnored(t *testing.T) { } } -// equalStringSlice returns true if a and b contain the same elements in the same order. -func equalStringSlice(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} From afa1813878cde7d8280467a83f35c647e577816f Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:25:51 -0500 Subject: [PATCH 12/35] fix(cli): use errors.Is for ErrNotExist checks in discover os.IsNotExist does not handle wrapped errors correctly. Replace both occurrences with errors.Is(err, os.ErrNotExist), the modern idiom since Go 1.13. --- cli/internal/plugin/discover.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/internal/plugin/discover.go b/cli/internal/plugin/discover.go index 700dec1..d49bf3d 100644 --- a/cli/internal/plugin/discover.go +++ b/cli/internal/plugin/discover.go @@ -1,6 +1,7 @@ package plugin import ( + "errors" "fmt" "io" "os" @@ -24,7 +25,7 @@ const pluginYAML = "plugin.yaml" func Discover(pluginsDir string, stderr io.Writer) ([]*Plugin, error) { entries, err := os.ReadDir(pluginsDir) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, fmt.Errorf("could not read plugin directory %s: %w", pluginsDir, err) @@ -53,7 +54,7 @@ func loadPlugin(dir string) (*Plugin, error) { yamlPath := filepath.Join(dir, pluginYAML) data, err := os.ReadFile(yamlPath) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("no plugin.yaml found") } return nil, fmt.Errorf("could not read plugin.yaml: %w", err) From 7b8bb117554332c1ddf6c15cad0d15aaec9d984d Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:28:43 -0500 Subject: [PATCH 13/35] test(cli): use t.TempDir for nonexistent dir test fixture Replace hardcoded /this/path/does/not/exist with a path derived from t.TempDir(). The parent is guaranteed to exist by the test harness but the child subdir does not, making the fixture deterministically nonexistent without relying on filesystem assumptions. --- cli/internal/plugin/discover_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index 1ff22c2..557cb4d 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -57,7 +57,7 @@ func TestDiscover_emptyDir(t *testing.T) { func TestDiscover_nonExistentDir(t *testing.T) { var stderr strings.Builder - plugins, err := plugin.Discover("/this/path/does/not/exist", &stderr) + plugins, err := plugin.Discover(filepath.Join(t.TempDir(), "nonexistent"), &stderr) if err != nil { t.Fatalf("unexpected error: %v", err) } From 8fdac36ae6a94beef3b9a02783003a9daeed56c3 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:30:10 -0500 Subject: [PATCH 14/35] test(cli): add test coverage for plugin setup field Adds two cases: setup path is correctly parsed when present, and setup is empty string when the field is absent from plugin.yaml. The setup path is consumed by the install segment (P2) when running the plugin's setup script. --- cli/internal/plugin/discover_test.go | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index 557cb4d..2c82475 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -286,3 +286,51 @@ func TestDiscover_strayFileIgnored(t *testing.T) { } } +func TestDiscover_setupFieldPopulated(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "with-setup", ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +platform: + name: dbt +setup: bin/setup +convert: + to_osi: + invoke: ["bin/convert", "to-osi"] + accepts: [".yaml"] + from_osi: + invoke: ["bin/convert", "from-osi"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Setup != "bin/setup" { + t.Errorf("Setup: got %q, want %q", plugins[0].Setup, "bin/setup") + } + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) + } +} + +func TestDiscover_setupFieldAbsent(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "no-setup", validPluginYAML) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Setup != "" { + t.Errorf("Setup: got %q, want empty string", plugins[0].Setup) + } +} From 8747de387c8e9a3e4650508ad207743cfc774370 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:33:15 -0500 Subject: [PATCH 15/35] docs(cli): document intentional lenient YAML unmarshaling in loadPlugin Unknown fields in plugin.yaml are silently ignored by yaml.Unmarshal. Adding a comment to make clear this is a deliberate forward- compatibility choice, not an oversight. --- cli/internal/plugin/discover.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/internal/plugin/discover.go b/cli/internal/plugin/discover.go index d49bf3d..7263e32 100644 --- a/cli/internal/plugin/discover.go +++ b/cli/internal/plugin/discover.go @@ -60,6 +60,9 @@ func loadPlugin(dir string) (*Plugin, error) { return nil, fmt.Errorf("could not read plugin.yaml: %w", err) } + // yaml.Unmarshal is lenient by default: unknown fields are silently ignored. + // This is intentional — future spec versions may add fields that older CLI + // versions should tolerate rather than reject. var raw rawPlugin if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("invalid YAML: %w", err) From fb5069d6eb336570ac9f190d8b3c66f303c99978 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 16:20:35 -0500 Subject: [PATCH 16/35] =?UTF-8?q?refactor(plugin):=20rename=20osi=20?= =?UTF-8?q?=E2=86=92=20ossie=20in=20ConvertConfig,=20yaml=20fields,=20and?= =?UTF-8?q?=20invoke=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConvertConfig.ToOSI/FromOSI and the plugin.yaml fields to_osi/from_osi were left using the old brand name. This sweeps them to ossie consistently with the rest of the CLI rename. to_ossie/from_ossie are now the canonical yaml keys. to_osi/from_osi are kept as deprecated fallbacks in rawPlugin, using the same ossie-wins merge pattern already established for ossie_plugin_spec/osi_plugin_spec. The invoke-arg convention (to-ossie, from-ossie) is updated in all canonical test fixtures; the two backward-compat tests (TestDiscover_ossieKeyPreferred, TestDiscover_osiKeyFallback) intentionally retain to_osi/from_osi since they exercise the deprecated-key path. --- cli/internal/plugin/discover_test.go | 36 +++++++-------- cli/internal/plugin/plugin.go | 66 +++++++++++++++++++++------- 2 files changed, 68 insertions(+), 34 deletions(-) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index 2c82475..71ee85e 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -18,11 +18,11 @@ platform: name: dbt vendor: dbt Labs convert: - to_osi: - invoke: ["ossie-plugin-dbt", "to-osi"] + to_ossie: + invoke: ["ossie-plugin-dbt", "to-ossie"] accepts: [".yaml", ".json"] - from_osi: - invoke: ["ossie-plugin-dbt", "from-osi"] + from_ossie: + invoke: ["ossie-plugin-dbt", "from-ossie"] ` // writePlugin creates a plugin directory under root with the given plugin.yaml content. @@ -94,17 +94,17 @@ func TestDiscover_validPlugin(t *testing.T) { if p.OSSIESpecVersion != ">=0.2.0" { t.Errorf("OSSIESpecVersion: got %q, want %q", p.OSSIESpecVersion, ">=0.2.0") } - wantInvoke := []string{"ossie-plugin-dbt", "to-osi"} - if !slices.Equal(p.Convert.ToOSI.Invoke, wantInvoke) { - t.Errorf("ToOSI.Invoke: got %v, want %v", p.Convert.ToOSI.Invoke, wantInvoke) + wantInvoke := []string{"ossie-plugin-dbt", "to-ossie"} + if !slices.Equal(p.Convert.ToOssie.Invoke, wantInvoke) { + t.Errorf("ToOssie.Invoke: got %v, want %v", p.Convert.ToOssie.Invoke, wantInvoke) } wantAccepts := []string{".yaml", ".json"} - if !slices.Equal(p.Convert.ToOSI.Accepts, wantAccepts) { - t.Errorf("ToOSI.Accepts: got %v, want %v", p.Convert.ToOSI.Accepts, wantAccepts) + if !slices.Equal(p.Convert.ToOssie.Accepts, wantAccepts) { + t.Errorf("ToOssie.Accepts: got %v, want %v", p.Convert.ToOssie.Accepts, wantAccepts) } - wantFromInvoke := []string{"ossie-plugin-dbt", "from-osi"} - if !slices.Equal(p.Convert.FromOSI.Invoke, wantFromInvoke) { - t.Errorf("FromOSI.Invoke: got %v, want %v", p.Convert.FromOSI.Invoke, wantFromInvoke) + wantFromInvoke := []string{"ossie-plugin-dbt", "from-ossie"} + if !slices.Equal(p.Convert.FromOssie.Invoke, wantFromInvoke) { + t.Errorf("FromOssie.Invoke: got %v, want %v", p.Convert.FromOssie.Invoke, wantFromInvoke) } if stderr.Len() != 0 { t.Errorf("unexpected warning: %q", stderr.String()) @@ -136,10 +136,10 @@ ossie_spec_version: ">=0.2.0" platform: vendor: dbt Labs convert: - to_osi: + to_ossie: invoke: ["bin/convert"] accepts: [".yaml"] - from_osi: + from_ossie: invoke: ["bin/convert"] `) var stderr strings.Builder @@ -295,11 +295,11 @@ platform: name: dbt setup: bin/setup convert: - to_osi: - invoke: ["bin/convert", "to-osi"] + to_ossie: + invoke: ["bin/convert", "to-ossie"] accepts: [".yaml"] - from_osi: - invoke: ["bin/convert", "from-osi"] + from_ossie: + invoke: ["bin/convert", "from-ossie"] `) var stderr strings.Builder diff --git a/cli/internal/plugin/plugin.go b/cli/internal/plugin/plugin.go index 00fa5a1..f370bf7 100644 --- a/cli/internal/plugin/plugin.go +++ b/cli/internal/plugin/plugin.go @@ -21,14 +21,14 @@ type Platform struct { // ConvertConfig holds both conversion directions. type ConvertConfig struct { - ToOSI Direction - FromOSI Direction + ToOssie Direction + FromOssie Direction } // Direction describes one conversion direction. type Direction struct { Invoke []string // command + args; first element is the executable - Accepts []string // file extensions e.g. [".yaml"]; populated on ToOSI only + Accepts []string // file extensions e.g. [".yaml"]; populated on ToOssie only } // rawPlugin mirrors the on-disk plugin.yaml layout for YAML unmarshaling. @@ -51,11 +51,18 @@ type rawPlugin struct { Setup string `yaml:"setup"` Convert struct { - ToOSI struct { + ToOssie struct { + Invoke []string `yaml:"invoke"` + Accepts []string `yaml:"accepts"` + } `yaml:"to_ossie"` + ToOSI struct { // deprecated Invoke []string `yaml:"invoke"` Accepts []string `yaml:"accepts"` } `yaml:"to_osi"` - FromOSI struct { + FromOssie struct { + Invoke []string `yaml:"invoke"` + } `yaml:"from_ossie"` + FromOSI struct { // deprecated Invoke []string `yaml:"invoke"` } `yaml:"from_osi"` } `yaml:"convert"` @@ -73,6 +80,19 @@ func (r *rawPlugin) validate() error { specVersion = r.OSISpecVersion } + toInvoke := r.Convert.ToOssie.Invoke + if len(toInvoke) == 0 { + toInvoke = r.Convert.ToOSI.Invoke + } + toAccepts := r.Convert.ToOssie.Accepts + if len(toAccepts) == 0 { + toAccepts = r.Convert.ToOSI.Accepts + } + fromInvoke := r.Convert.FromOssie.Invoke + if len(fromInvoke) == 0 { + fromInvoke = r.Convert.FromOSI.Invoke + } + switch { case pluginSpec == "": return errors.New("missing required field: ossie_plugin_spec (or osi_plugin_spec)") @@ -80,12 +100,12 @@ func (r *rawPlugin) validate() error { return errors.New("missing required field: ossie_spec_version (or osi_spec_version)") case r.Platform.Name == "": return errors.New("missing required field: platform.name") - case len(r.Convert.ToOSI.Invoke) == 0: - return errors.New("missing required field: convert.to_osi.invoke") - case len(r.Convert.ToOSI.Accepts) == 0: - return errors.New("missing required field: convert.to_osi.accepts") - case len(r.Convert.FromOSI.Invoke) == 0: - return errors.New("missing required field: convert.from_osi.invoke") + case len(toInvoke) == 0: + return errors.New("missing required field: convert.to_ossie.invoke (or convert.to_osi.invoke)") + case len(toAccepts) == 0: + return errors.New("missing required field: convert.to_ossie.accepts (or convert.to_osi.accepts)") + case len(fromInvoke) == 0: + return errors.New("missing required field: convert.from_ossie.invoke (or convert.from_osi.invoke)") } return nil } @@ -101,6 +121,20 @@ func (r *rawPlugin) toPlugin(path string) *Plugin { if specVersion == "" { specVersion = r.OSISpecVersion } + + toInvoke := r.Convert.ToOssie.Invoke + if len(toInvoke) == 0 { + toInvoke = r.Convert.ToOSI.Invoke + } + toAccepts := r.Convert.ToOssie.Accepts + if len(toAccepts) == 0 { + toAccepts = r.Convert.ToOSI.Accepts + } + fromInvoke := r.Convert.FromOssie.Invoke + if len(fromInvoke) == 0 { + fromInvoke = r.Convert.FromOSI.Invoke + } + return &Plugin{ Path: path, OSSIEPluginSpec: pluginSpec, @@ -111,12 +145,12 @@ func (r *rawPlugin) toPlugin(path string) *Plugin { }, Setup: r.Setup, Convert: ConvertConfig{ - ToOSI: Direction{ - Invoke: r.Convert.ToOSI.Invoke, - Accepts: r.Convert.ToOSI.Accepts, + ToOssie: Direction{ + Invoke: toInvoke, + Accepts: toAccepts, }, - FromOSI: Direction{ - Invoke: r.Convert.FromOSI.Invoke, + FromOssie: Direction{ + Invoke: fromInvoke, }, }, } From bc5abe9e0e030a8d2360e37c44e2bf81c4b800cd Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 16:27:41 -0500 Subject: [PATCH 17/35] refactor(plugin): drop osi_* backward compat from rawPlugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preemptive osi_* fallback fields (osi_plugin_spec, osi_spec_version, to_osi, from_osi) were added in anticipation of backward compatibility, but no plugins exist yet using that format — the rename is happening before any external consumers. Carrying dead compatibility code with no real users adds noise and keeps osi in the codebase. Drops all deprecated fields and merge logic from rawPlugin, simplifying validate() and toPlugin() back to direct field access. TestDiscover_ossieKeyPreferred and TestDiscover_osiKeyFallback (which existed solely to exercise the now-removed paths) are replaced with TestDiscover_unknownFieldsIgnored, which covers the lenient-YAML behavior (future spec fields are silently tolerated) that discover.go explicitly documents. --- cli/internal/plugin/discover_test.go | 56 ++++-------------- cli/internal/plugin/plugin.go | 88 +++++----------------------- 2 files changed, 26 insertions(+), 118 deletions(-) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index 71ee85e..951f785 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -201,51 +201,20 @@ func TestDiscover_multipleMixed(t *testing.T) { } } -func TestDiscover_ossieKeyPreferred(t *testing.T) { +func TestDiscover_unknownFieldsIgnored(t *testing.T) { root := t.TempDir() - writePlugin(t, root, "both-keys", ` -ossie_plugin_spec: "ossie-value" -osi_plugin_spec: "osi-value" -ossie_spec_version: ">=1.0.0" -osi_spec_version: ">=0.1.0" + writePlugin(t, root, "extra-fields", ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +future_field: "some value" platform: name: test + future_platform_field: "ignored" convert: - to_osi: + to_ossie: invoke: ["bin/convert"] accepts: [".yaml"] - from_osi: - invoke: ["bin/convert"] -`) - var stderr strings.Builder - - plugins, err := plugin.Discover(root, &stderr) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(plugins) != 1 { - t.Fatalf("expected 1 plugin, got %d", len(plugins)) - } - if plugins[0].OSSIEPluginSpec != "ossie-value" { - t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "ossie-value") - } - if plugins[0].OSSIESpecVersion != ">=1.0.0" { - t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=1.0.0") - } -} - -func TestDiscover_osiKeyFallback(t *testing.T) { - root := t.TempDir() - writePlugin(t, root, "osi-only", ` -osi_plugin_spec: "0.2.0" -osi_spec_version: ">=0.1.0" -platform: - name: legacy -convert: - to_osi: - invoke: ["bin/convert"] - accepts: [".json"] - from_osi: + from_ossie: invoke: ["bin/convert"] `) var stderr strings.Builder @@ -255,13 +224,10 @@ convert: t.Fatalf("unexpected error: %v", err) } if len(plugins) != 1 { - t.Fatalf("expected 1 plugin, got %d", len(plugins)) + t.Fatalf("expected 1 plugin, got %d: future spec fields should be silently ignored", len(plugins)) } - if plugins[0].OSSIEPluginSpec != "0.2.0" { - t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "0.2.0") - } - if plugins[0].OSSIESpecVersion != ">=0.1.0" { - t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=0.1.0") + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) } } diff --git a/cli/internal/plugin/plugin.go b/cli/internal/plugin/plugin.go index f370bf7..aa83cd8 100644 --- a/cli/internal/plugin/plugin.go +++ b/cli/internal/plugin/plugin.go @@ -32,16 +32,9 @@ type Direction struct { } // rawPlugin mirrors the on-disk plugin.yaml layout for YAML unmarshaling. -// -// ossie_* keys are the preferred (current brand) names. osi_* keys are -// deprecated aliases kept for backward compatibility with plugins written -// before the OSI → OSSIE rename. When both are present, ossie_* wins. -// TODO: remove osi_* fallback support in a future major version. type rawPlugin struct { OSSIEPluginSpec string `yaml:"ossie_plugin_spec"` OSSIESpecVersion string `yaml:"ossie_spec_version"` - OSIPluginSpec string `yaml:"osi_plugin_spec"` // deprecated - OSISpecVersion string `yaml:"osi_spec_version"` // deprecated Platform struct { Name string `yaml:"name"` @@ -55,57 +48,28 @@ type rawPlugin struct { Invoke []string `yaml:"invoke"` Accepts []string `yaml:"accepts"` } `yaml:"to_ossie"` - ToOSI struct { // deprecated - Invoke []string `yaml:"invoke"` - Accepts []string `yaml:"accepts"` - } `yaml:"to_osi"` FromOssie struct { Invoke []string `yaml:"invoke"` } `yaml:"from_ossie"` - FromOSI struct { // deprecated - Invoke []string `yaml:"invoke"` - } `yaml:"from_osi"` } `yaml:"convert"` } // validate checks that all required fields are present. // It performs presence checks only — not format validation. func (r *rawPlugin) validate() error { - pluginSpec := r.OSSIEPluginSpec - if pluginSpec == "" { - pluginSpec = r.OSIPluginSpec - } - specVersion := r.OSSIESpecVersion - if specVersion == "" { - specVersion = r.OSISpecVersion - } - - toInvoke := r.Convert.ToOssie.Invoke - if len(toInvoke) == 0 { - toInvoke = r.Convert.ToOSI.Invoke - } - toAccepts := r.Convert.ToOssie.Accepts - if len(toAccepts) == 0 { - toAccepts = r.Convert.ToOSI.Accepts - } - fromInvoke := r.Convert.FromOssie.Invoke - if len(fromInvoke) == 0 { - fromInvoke = r.Convert.FromOSI.Invoke - } - switch { - case pluginSpec == "": - return errors.New("missing required field: ossie_plugin_spec (or osi_plugin_spec)") - case specVersion == "": - return errors.New("missing required field: ossie_spec_version (or osi_spec_version)") + case r.OSSIEPluginSpec == "": + return errors.New("missing required field: ossie_plugin_spec") + case r.OSSIESpecVersion == "": + return errors.New("missing required field: ossie_spec_version") case r.Platform.Name == "": return errors.New("missing required field: platform.name") - case len(toInvoke) == 0: - return errors.New("missing required field: convert.to_ossie.invoke (or convert.to_osi.invoke)") - case len(toAccepts) == 0: - return errors.New("missing required field: convert.to_ossie.accepts (or convert.to_osi.accepts)") - case len(fromInvoke) == 0: - return errors.New("missing required field: convert.from_ossie.invoke (or convert.from_osi.invoke)") + case len(r.Convert.ToOssie.Invoke) == 0: + return errors.New("missing required field: convert.to_ossie.invoke") + case len(r.Convert.ToOssie.Accepts) == 0: + return errors.New("missing required field: convert.to_ossie.accepts") + case len(r.Convert.FromOssie.Invoke) == 0: + return errors.New("missing required field: convert.from_ossie.invoke") } return nil } @@ -113,32 +77,10 @@ func (r *rawPlugin) validate() error { // toPlugin maps a validated rawPlugin to the exported Plugin type. // path is the absolute directory path of the plugin's installation. func (r *rawPlugin) toPlugin(path string) *Plugin { - pluginSpec := r.OSSIEPluginSpec - if pluginSpec == "" { - pluginSpec = r.OSIPluginSpec - } - specVersion := r.OSSIESpecVersion - if specVersion == "" { - specVersion = r.OSISpecVersion - } - - toInvoke := r.Convert.ToOssie.Invoke - if len(toInvoke) == 0 { - toInvoke = r.Convert.ToOSI.Invoke - } - toAccepts := r.Convert.ToOssie.Accepts - if len(toAccepts) == 0 { - toAccepts = r.Convert.ToOSI.Accepts - } - fromInvoke := r.Convert.FromOssie.Invoke - if len(fromInvoke) == 0 { - fromInvoke = r.Convert.FromOSI.Invoke - } - return &Plugin{ Path: path, - OSSIEPluginSpec: pluginSpec, - OSSIESpecVersion: specVersion, + OSSIEPluginSpec: r.OSSIEPluginSpec, + OSSIESpecVersion: r.OSSIESpecVersion, Platform: Platform{ Name: r.Platform.Name, Vendor: r.Platform.Vendor, @@ -146,11 +88,11 @@ func (r *rawPlugin) toPlugin(path string) *Plugin { Setup: r.Setup, Convert: ConvertConfig{ ToOssie: Direction{ - Invoke: toInvoke, - Accepts: toAccepts, + Invoke: r.Convert.ToOssie.Invoke, + Accepts: r.Convert.ToOssie.Accepts, }, FromOssie: Direction{ - Invoke: fromInvoke, + Invoke: r.Convert.FromOssie.Invoke, }, }, } From aa56370db8c361702fdd4cf16f9f9ff8bf8d47e1 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 16:43:03 -0500 Subject: [PATCH 18/35] feat(plugin): implement basic ossie plugin list command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires plugin list to the discovery infrastructure added in F2. Reads from the resolved plugin directory, prints a NAME/PLATFORM/SPEC table via tabwriter, and falls back to a "no plugins installed" message when the directory is empty or missing. This is a minimal first pass — the full P1 spec calls for cross-referencing against the embedded plugin registry (installed vs. available, latest version, update indicator), which depends on registry embedding (F4) not yet implemented. --- cli/cmd/plugin/list.go | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/cli/cmd/plugin/list.go b/cli/cmd/plugin/list.go index f69e8bb..01f52d0 100644 --- a/cli/cmd/plugin/list.go +++ b/cli/cmd/plugin/list.go @@ -2,17 +2,46 @@ package plugin import ( "fmt" + "os" + "path/filepath" + "text/tabwriter" "github.com/spf13/cobra" + + "github.com/open-semantic-interchange/ossie/cli/internal/ossiedir" + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" ) var listCmd = &cobra.Command{ Use: "list", - Short: "List installed and available plugins", - RunE: runPluginList, + Short: "List installed plugins", + // TODO(P1): cross-reference against the embedded plugin registry to show + // latest available versions and update indicators once registry embedding + // (F4) is implemented. + RunE: runPluginList, } func runPluginList(cmd *cobra.Command, args []string) error { - fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") - return nil + pluginsDir, err := ossiedir.PluginDir() + if err != nil { + return err + } + + plugins, err := plugin.Discover(pluginsDir, os.Stderr) + if err != nil { + return err + } + + if len(plugins) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no plugins installed") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tPLATFORM\tSPEC") + for _, p := range plugins { + name := filepath.Base(p.Path) + fmt.Fprintf(w, "%s\t%s\t%s\n", name, p.Platform.Name, p.OSSIEPluginSpec) + } + return w.Flush() } From e6b0d743d8146f03a139792900075808cc6c4fd3 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 16:53:02 -0500 Subject: [PATCH 19/35] chore(cli): add make install target for local development go install . names the binary after the module path's last segment (cli) rather than the intended ossie. make install uses go build -o directly into $GOPATH/bin with the correct binary name. --- cli/Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/Makefile b/cli/Makefile index 9ecbcb7..d1f7d66 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -1,11 +1,14 @@ BINARY_NAME := ossie BUILD_DIR := dist -.PHONY: build test lint release-dry-run clean +.PHONY: build install test lint release-dry-run clean build: go build -o $(BUILD_DIR)/$(BINARY_NAME) . +install: + go build -o $(shell go env GOPATH)/bin/$(BINARY_NAME) . + test: go test ./... From e64ca8fa85af74a729fc48c2218ddff1a6ef1085 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 12:33:45 -0500 Subject: [PATCH 20/35] feat(cli): add plugin invocation protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 (osi convert base) needs a subprocess layer to pipe requests to plugins and receive responses. This adds the Request/Response/Issue envelope types and an Invoke() function that owns the full lifecycle: marshal request to stdin, capture stdout, forward stderr, enforce context timeout, and decode the JSON response. Design decisions: - Invoke takes pluginDir and invoke []string directly rather than *Plugin — decouples the invocation layer from the discovery types and makes the function independently testable - cmd.Stdout assigned as *bytes.Buffer rather than using cmd.Output() — cmd.Output() is incompatible with a pre-assigned cmd.Stderr writer and would silently discard the caller's stderr destination - ctx.Err() checked after cmd.Run() for timeout detection — exec kills the process with SIGKILL and returns "signal: killed", not context.DeadlineExceeded, so the context must be checked explicitly - Issue.Path uses omitempty — an absent key is semantically distinct from an empty string; plugins that have no path omit the field - A non-empty Issues slice is NOT a Go error — the caller (C1) is responsible for inspecting severities and determining exit code Tests use the TestMain subprocess self-invocation pattern: the test binary re-invokes itself with GO_TEST_PLUGIN=1, enters the fake plugin branch in TestMain, and exits without running any tests. This avoids compiling a separate helper binary and works portably. Caveats: - File collection, output writing, issue rendering, and wiring into cmd/convert.go are deferred to C1 --- cli/internal/plugin/invoke.go | 74 ++++++++ cli/internal/plugin/invoke_test.go | 263 +++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 cli/internal/plugin/invoke.go create mode 100644 cli/internal/plugin/invoke_test.go diff --git a/cli/internal/plugin/invoke.go b/cli/internal/plugin/invoke.go new file mode 100644 index 0000000..d7e4018 --- /dev/null +++ b/cli/internal/plugin/invoke.go @@ -0,0 +1,74 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" +) + +// Request is the JSON envelope sent to a plugin on stdin. +type Request struct { + Files map[string]string `json:"files"` +} + +// Response is the JSON envelope received from a plugin on stdout. +type Response struct { + Files map[string]string `json:"files"` + Issues []Issue `json:"issues,omitempty"` +} + +// Issue is a single diagnostic emitted by a plugin during conversion. +// Severity is one of "error", "warning", or "info". +// Path is optional; it is omitted for issues not tied to a specific location. +type Issue struct { + Severity string `json:"severity"` + Message string `json:"message"` + Path string `json:"path,omitempty"` +} + +// Invoke runs a plugin subprocess, pipes req as JSON to its stdin, +// and decodes its stdout as a JSON Response. +// +// pluginDir is used as the working directory for the subprocess so that +// relative paths in the invoke array resolve correctly. +// invoke is the command and its arguments (invoke[0] is the executable). +// pluginStderr receives the plugin's stderr verbatim; pass io.Discard to suppress. +// +// Hard errors: failed to marshal request, process start failure, context +// deadline exceeded, non-zero exit code, or invalid JSON in the response. +// A non-empty Issues slice in the response is NOT itself a Go error — the +// caller is responsible for inspecting severities and setting the exit code. +func Invoke(ctx context.Context, pluginDir string, invoke []string, req Request, pluginStderr io.Writer) (*Response, error) { + reqJSON, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal plugin request: %w", err) + } + + var stdout bytes.Buffer + cmd := exec.CommandContext(ctx, invoke[0], invoke[1:]...) + cmd.Dir = pluginDir + cmd.Stdin = bytes.NewReader(reqJSON) + // Use an explicit buffer rather than cmd.Output() — cmd.Output() is + // incompatible with a pre-assigned cmd.Stderr writer. + cmd.Stdout = &stdout + cmd.Stderr = pluginStderr + + if err := cmd.Run(); err != nil { + // ctx.Err() is the authoritative signal for timeout/cancellation. + // exec.CommandContext kills the process and returns "signal: killed", + // not context.DeadlineExceeded, so we must check ctx.Err() explicitly. + if ctx.Err() != nil { + return nil, fmt.Errorf("plugin timed out: %w", ctx.Err()) + } + return nil, fmt.Errorf("plugin process failed: %w", err) + } + + var resp Response + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("invalid JSON response from plugin: %w", err) + } + return &resp, nil +} diff --git a/cli/internal/plugin/invoke_test.go b/cli/internal/plugin/invoke_test.go new file mode 100644 index 0000000..8012c88 --- /dev/null +++ b/cli/internal/plugin/invoke_test.go @@ -0,0 +1,263 @@ +package plugin_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" +) + +// TestMain handles two roles: +// 1. When GO_TEST_PLUGIN=1 it acts as a fake plugin subprocess, reads stdin, +// and writes a canned response based on GO_TEST_PLUGIN_MODE. +// 2. Otherwise it runs the normal test suite. +func TestMain(m *testing.M) { + if os.Getenv("GO_TEST_PLUGIN") == "1" { + runFakePlugin() + // runFakePlugin calls os.Exit; this line is never reached. + } + os.Exit(m.Run()) +} + +// runFakePlugin implements a minimal plugin subprocess used by invoke tests. +// It reads all of stdin (the JSON request), then dispatches on GO_TEST_PLUGIN_MODE. +func runFakePlugin() { + stdinBytes, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintln(os.Stderr, "fake plugin: failed to read stdin:", err) + os.Exit(2) + } + + switch mode := os.Getenv("GO_TEST_PLUGIN_MODE"); mode { + case "success": + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{"output.yaml": "converted content"}, + }) + + case "warning_issue": + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{"output.yaml": "converted content"}, + "issues": []map[string]string{ + {"severity": "warning", "message": "some warning", "path": "input.yaml"}, + }, + }) + + case "error_issue": + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{}, + "issues": []map[string]string{ + {"severity": "error", "message": "conversion failed"}, + }, + }) + + case "invalid_json": + fmt.Fprint(os.Stdout, "not json") + + case "stderr_output": + fmt.Fprint(os.Stderr, "stderr from plugin") + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{"output.yaml": "ok"}, + }) + + case "nonzero_exit": + os.Exit(1) + + case "timeout": + time.Sleep(30 * time.Second) + + case "echo_request": + // Echo the raw stdin bytes back in files["received_request"] so the + // test can assert the plugin received the correct request JSON. + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{"received_request": string(stdinBytes)}, + }) + + default: + fmt.Fprintln(os.Stderr, "fake plugin: unknown mode:", mode) + os.Exit(2) + } + + os.Exit(0) +} + +// fakePluginInvoke sets up env vars for the fake plugin and returns the +// invoke slice. The test binary re-invokes itself with -test.run=^$ so that +// no tests run in the child — TestMain sees GO_TEST_PLUGIN=1 and exits early. +func fakePluginInvoke(t *testing.T, mode string) []string { + t.Helper() + t.Setenv("GO_TEST_PLUGIN", "1") + t.Setenv("GO_TEST_PLUGIN_MODE", mode) + return []string{os.Args[0], "-test.run=^$"} +} + +func TestInvoke_success(t *testing.T) { + invoke := fakePluginInvoke(t, "success") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{Files: map[string]string{}}, io.Discard) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + if _, ok := resp.Files["output.yaml"]; !ok { + t.Errorf("expected output.yaml in response files, got: %v", resp.Files) + } + if len(resp.Issues) != 0 { + t.Errorf("expected no issues, got %d", len(resp.Issues)) + } +} + +func TestInvoke_warningIssue(t *testing.T) { + invoke := fakePluginInvoke(t, "warning_issue") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Files) != 1 { + t.Errorf("expected 1 output file, got %d", len(resp.Files)) + } + if len(resp.Issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(resp.Issues)) + } + if resp.Issues[0].Severity != "warning" { + t.Errorf("issue severity: got %q, want %q", resp.Issues[0].Severity, "warning") + } +} + +func TestInvoke_errorIssue(t *testing.T) { + invoke := fakePluginInvoke(t, "error_issue") + ctx := context.Background() + + // An error-severity issue is NOT a Go error — Invoke must return nil err. + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err != nil { + t.Fatalf("unexpected Go error: %v", err) + } + if len(resp.Issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(resp.Issues)) + } + if resp.Issues[0].Severity != "error" { + t.Errorf("issue severity: got %q, want %q", resp.Issues[0].Severity, "error") + } +} + +func TestInvoke_invalidResponseJSON(t *testing.T) { + invoke := fakePluginInvoke(t, "invalid_json") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err == nil { + t.Fatal("expected error for invalid JSON response, got nil") + } + if resp != nil { + t.Errorf("expected nil response, got %+v", resp) + } +} + +func TestInvoke_nonZeroExit(t *testing.T) { + invoke := fakePluginInvoke(t, "nonzero_exit") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err == nil { + t.Fatal("expected error for non-zero exit, got nil") + } + if resp != nil { + t.Errorf("expected nil response, got %+v", resp) + } +} + +func TestInvoke_stderrForwarded(t *testing.T) { + invoke := fakePluginInvoke(t, "stderr_output") + ctx := context.Background() + var buf strings.Builder + + _, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, &buf) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "stderr from plugin") { + t.Errorf("expected stderr content in writer, got: %q", buf.String()) + } +} + +func TestInvoke_stderrSuppressed(t *testing.T) { + invoke := fakePluginInvoke(t, "stderr_output") + ctx := context.Background() + + // Passing io.Discard must not panic and must return a valid response. + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } +} + +func TestInvoke_timeout(t *testing.T) { + invoke := fakePluginInvoke(t, "timeout") + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if resp != nil { + t.Errorf("expected nil response on timeout, got %+v", resp) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("expected DeadlineExceeded in error chain, got: %v", err) + } +} + +func TestInvoke_requestPayloadReachesPlugin(t *testing.T) { + invoke := fakePluginInvoke(t, "echo_request") + ctx := context.Background() + + req := plugin.Request{ + Files: map[string]string{ + "core/orders.yaml": "version: 2\nmodels:\n - name: orders", + }, + } + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, req, io.Discard) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw, ok := resp.Files["received_request"] + if !ok { + t.Fatal("expected received_request key in response files") + } + + // Decode the echoed request and compare — avoids brittle JSON key-ordering checks. + var echoed plugin.Request + if err := json.Unmarshal([]byte(raw), &echoed); err != nil { + t.Fatalf("could not decode echoed request: %v", err) + } + want := req.Files["core/orders.yaml"] + if echoed.Files["core/orders.yaml"] != want { + t.Errorf("echoed file content:\ngot: %q\nwant: %q", echoed.Files["core/orders.yaml"], want) + } +} From 3c4f5ec6fad80ed9f60a88359ac132d11a8f9926 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 13:48:54 -0500 Subject: [PATCH 21/35] fix(cli): normalise nil Request.Files to empty map before marshaling A Request with nil Files marshals as {"files":null} rather than {"files":{}}. Plugins that iterate over the files object may crash on a null value. Guard added at the top of Invoke(); test added to verify the wire format is always an object. --- cli/internal/plugin/invoke.go | 5 +++++ cli/internal/plugin/invoke_test.go | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/cli/internal/plugin/invoke.go b/cli/internal/plugin/invoke.go index d7e4018..10f3b4a 100644 --- a/cli/internal/plugin/invoke.go +++ b/cli/internal/plugin/invoke.go @@ -42,6 +42,11 @@ type Issue struct { // A non-empty Issues slice in the response is NOT itself a Go error — the // caller is responsible for inspecting severities and setting the exit code. func Invoke(ctx context.Context, pluginDir string, invoke []string, req Request, pluginStderr io.Writer) (*Response, error) { + // Normalise nil to empty map so plugins always receive {"files":{}} not {"files":null}. + if req.Files == nil { + req.Files = map[string]string{} + } + reqJSON, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal plugin request: %w", err) diff --git a/cli/internal/plugin/invoke_test.go b/cli/internal/plugin/invoke_test.go index 8012c88..62550c1 100644 --- a/cli/internal/plugin/invoke_test.go +++ b/cli/internal/plugin/invoke_test.go @@ -261,3 +261,21 @@ func TestInvoke_requestPayloadReachesPlugin(t *testing.T) { t.Errorf("echoed file content:\ngot: %q\nwant: %q", echoed.Files["core/orders.yaml"], want) } } + +func TestInvoke_nilFilesNormalisedToEmptyObject(t *testing.T) { + // A Request with nil Files must marshal as {"files":{}} not {"files":null}. + // Plugins receiving null instead of an object may crash on iteration. + invoke := fakePluginInvoke(t, "echo_request") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw := resp.Files["received_request"] + + // The echoed bytes must contain `"files":{}` not `"files":null`. + if !strings.Contains(raw, `"files":{}`) { + t.Errorf("expected files to be serialised as {}, got: %s", raw) + } +} From f2f493ef18b3265065f131cfe0f1025460165e1a Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 14:52:15 -0500 Subject: [PATCH 22/35] fix(cli): guard against empty invoke slice in Invoke An empty invoke slice would panic with an index out of range on invoke[0]. While F2 validation prevents this in normal usage, an explicit check with a descriptive error is safer and makes the failure mode obvious if the invariant is ever violated. --- cli/internal/plugin/invoke.go | 4 ++++ cli/internal/plugin/invoke_test.go | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/cli/internal/plugin/invoke.go b/cli/internal/plugin/invoke.go index 10f3b4a..17bf848 100644 --- a/cli/internal/plugin/invoke.go +++ b/cli/internal/plugin/invoke.go @@ -42,6 +42,10 @@ type Issue struct { // A non-empty Issues slice in the response is NOT itself a Go error — the // caller is responsible for inspecting severities and setting the exit code. func Invoke(ctx context.Context, pluginDir string, invoke []string, req Request, pluginStderr io.Writer) (*Response, error) { + if len(invoke) == 0 { + return nil, fmt.Errorf("plugin invoke command is empty") + } + // Normalise nil to empty map so plugins always receive {"files":{}} not {"files":null}. if req.Files == nil { req.Files = map[string]string{} diff --git a/cli/internal/plugin/invoke_test.go b/cli/internal/plugin/invoke_test.go index 62550c1..b2edf43 100644 --- a/cli/internal/plugin/invoke_test.go +++ b/cli/internal/plugin/invoke_test.go @@ -279,3 +279,16 @@ func TestInvoke_nilFilesNormalisedToEmptyObject(t *testing.T) { t.Errorf("expected files to be serialised as {}, got: %s", raw) } } + +func TestInvoke_emptyInvokeReturnsError(t *testing.T) { + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), []string{}, plugin.Request{}, io.Discard) + + if err == nil { + t.Fatal("expected error for empty invoke slice, got nil") + } + if resp != nil { + t.Errorf("expected nil response, got %+v", resp) + } +} From f29712ead5400d1e3b57800f2309eac0ed118bce Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 16:43:29 -0500 Subject: [PATCH 23/35] feat(cli): embed plugin registry Adds cli/internal/registry/ with the plugin registry embedded into the binary via //go:embed. The registry is read at runtime with no network call required. Initial registry contains placeholder 0.1.0 entries for the five known platforms (dbt, gooddata, polaris, salesforce, snowflake). Checksums are marked sha256:placeholder pending real plugin packaging. Entries will be updated with real tags and checksums when converters are packaged as distributable OSSIE plugins. Wiring into cmd/plugin/list.go and cmd/plugin/install.go is deferred to P1 and P2 respectively. --- cli/internal/registry/plugins-registry.yaml | 45 ++++++++ cli/internal/registry/registry.go | 74 +++++++++++++ cli/internal/registry/registry_test.go | 110 ++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 cli/internal/registry/plugins-registry.yaml create mode 100644 cli/internal/registry/registry.go create mode 100644 cli/internal/registry/registry_test.go diff --git a/cli/internal/registry/plugins-registry.yaml b/cli/internal/registry/plugins-registry.yaml new file mode 100644 index 0000000..9a03d86 --- /dev/null +++ b/cli/internal/registry/plugins-registry.yaml @@ -0,0 +1,45 @@ +# OSSIE Plugin Registry +# +# Format: each top-level key is a platform name. The value is a list of +# versioned entries ordered oldest → newest; the last entry is the latest. +# +# Entry types: +# git_release — download from a GitHub release tarball +# raw_url — download from an arbitrary URL +# +# NOTE: All entries below are placeholders pending plugin packaging. + +dbt: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-dbt-v0.1.0 + checksum: sha256:placeholder + +gooddata: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-gooddata-v0.1.0 + checksum: sha256:placeholder + +polaris: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-polaris-v0.1.0 + checksum: sha256:placeholder + +salesforce: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-salesforce-v0.1.0 + checksum: sha256:placeholder + +snowflake: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-snowflake-v0.1.0 + checksum: sha256:placeholder diff --git a/cli/internal/registry/registry.go b/cli/internal/registry/registry.go new file mode 100644 index 0000000..cc425df --- /dev/null +++ b/cli/internal/registry/registry.go @@ -0,0 +1,74 @@ +package registry + +import ( + _ "embed" // blank import required to activate //go:embed + "fmt" + "sort" + + "gopkg.in/yaml.v3" +) + +// Entry types supported by the registry. +const ( + EntryTypeGitRelease = "git_release" + EntryTypeRawURL = "raw_url" +) + +// Entry is a single versioned release of a plugin for one platform. +type Entry struct { + Version string `yaml:"version"` + Type string `yaml:"type"` // EntryTypeGitRelease or EntryTypeRawURL + URL string `yaml:"url"` + Tag string `yaml:"tag,omitempty"` // git_release only + Checksum string `yaml:"checksum"` +} + +// Registry maps a platform name to its list of versioned entries, ordered +// oldest to newest. The last entry in each slice is the latest version. +type Registry map[string][]Entry + +//go:embed plugins-registry.yaml +var registryData []byte + +// Load parses the embedded plugins-registry.yaml and returns a Registry. +// An error here indicates a build-time defect; registry_test.go validates +// the embedded data on every test run. +func Load() (Registry, error) { + var r Registry + if err := yaml.Unmarshal(registryData, &r); err != nil { + return nil, fmt.Errorf("failed to parse embedded plugin registry: %w", err) + } + return r, nil +} + +// Platforms returns all platform names in the registry in alphabetical order. +func (r Registry) Platforms() []string { + names := make([]string, 0, len(r)) + for name := range r { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// LatestEntry returns the last (newest) entry for the given platform. +// It returns false if the platform is not in the registry or has no entries. +// Registry maintainers are responsible for keeping entries ordered oldest → newest. +func (r Registry) LatestEntry(platform string) (Entry, bool) { + entries, ok := r[platform] + if !ok || len(entries) == 0 { + return Entry{}, false + } + return entries[len(entries)-1], true +} + +// FindEntry returns the entry matching the given platform and version. +// It returns false if the platform or version is not found. +func (r Registry) FindEntry(platform, version string) (Entry, bool) { + for _, entry := range r[platform] { + if entry.Version == version { + return entry, true + } + } + return Entry{}, false +} diff --git a/cli/internal/registry/registry_test.go b/cli/internal/registry/registry_test.go new file mode 100644 index 0000000..9e110ce --- /dev/null +++ b/cli/internal/registry/registry_test.go @@ -0,0 +1,110 @@ +package registry_test + +import ( + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/registry" +) + +// knownPlatforms is the authoritative list of platforms that must be present +// in the embedded registry for the CLI to have useful output. +var knownPlatforms = []string{"dbt", "gooddata", "polaris", "salesforce", "snowflake"} + +func TestLoad_succeeds(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if reg == nil { + t.Fatal("Load() returned nil registry") + } +} + +func TestLoad_containsAllPlatforms(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + for _, platform := range knownPlatforms { + entries, ok := reg[platform] + if !ok { + t.Errorf("platform %q not found in registry", platform) + continue + } + if len(entries) == 0 { + t.Errorf("platform %q has no entries", platform) + } + } +} + +func TestRegistry_Platforms_sorted(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + got := reg.Platforms() + for i := 1; i < len(got); i++ { + if got[i] < got[i-1] { + t.Errorf("Platforms() not sorted: %q appears after %q", got[i-1], got[i]) + } + } +} + +func TestRegistry_LatestEntry_found(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + entry, ok := reg.LatestEntry("dbt") + if !ok { + t.Fatal("LatestEntry(\"dbt\") returned false, want true") + } + if entry.Version == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty Version") + } + if entry.Type == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty Type") + } + if entry.URL == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty URL") + } + if entry.Checksum == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty Checksum") + } +} + +func TestRegistry_LatestEntry_notFound(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + _, ok := reg.LatestEntry("does-not-exist") + if ok { + t.Error("LatestEntry(\"does-not-exist\") returned true, want false") + } +} + +func TestRegistry_FindEntry_found(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + entry, ok := reg.FindEntry("dbt", "0.1.0") + if !ok { + t.Fatal("FindEntry(\"dbt\", \"0.1.0\") returned false, want true") + } + if entry.Version != "0.1.0" { + t.Errorf("FindEntry version: got %q, want %q", entry.Version, "0.1.0") + } +} + +func TestRegistry_FindEntry_notFound(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + _, ok := reg.FindEntry("dbt", "99.99.99") + if ok { + t.Error("FindEntry(\"dbt\", \"99.99.99\") returned true, want false") + } +} From 4c35d2e05c1e47f774b5a7b83a70f00f33013915 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 16:57:28 -0500 Subject: [PATCH 24/35] docs(cli): address minor review comments in registry package - Add comment on EntryType constants noting they are consumed by P2 - Clarify why knownPlatforms is package-level (slices cannot be const) - Guard TestRegistry_Platforms_sorted against vacuous pass on empty slice --- cli/internal/registry/registry.go | 2 ++ cli/internal/registry/registry_test.go | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/cli/internal/registry/registry.go b/cli/internal/registry/registry.go index cc425df..d2817bf 100644 --- a/cli/internal/registry/registry.go +++ b/cli/internal/registry/registry.go @@ -9,6 +9,8 @@ import ( ) // Entry types supported by the registry. +// These constants are consumed by P2 (plugin install) when dispatching +// on how to download and extract a plugin archive. const ( EntryTypeGitRelease = "git_release" EntryTypeRawURL = "raw_url" diff --git a/cli/internal/registry/registry_test.go b/cli/internal/registry/registry_test.go index 9e110ce..92288d8 100644 --- a/cli/internal/registry/registry_test.go +++ b/cli/internal/registry/registry_test.go @@ -8,6 +8,8 @@ import ( // knownPlatforms is the authoritative list of platforms that must be present // in the embedded registry for the CLI to have useful output. +// Declared at package level (not inside the test) because slices cannot be +// constants in Go, and multiple tests reference this list. var knownPlatforms = []string{"dbt", "gooddata", "polaris", "salesforce", "snowflake"} func TestLoad_succeeds(t *testing.T) { @@ -43,6 +45,9 @@ func TestRegistry_Platforms_sorted(t *testing.T) { t.Fatalf("Load() returned error: %v", err) } got := reg.Platforms() + if len(got) == 0 { + t.Fatal("Platforms() returned empty slice") + } for i := 1; i < len(got); i++ { if got[i] < got[i-1] { t.Errorf("Platforms() not sorted: %q appears after %q", got[i-1], got[i]) From 0f5d03072d35348cb612c337debc96c65a2c8fac Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Wed, 24 Jun 2026 13:54:16 -0500 Subject: [PATCH 25/35] feat(plugin): wire registry into ossie plugin list (P1) Replaces the basic installed-only listing with a full P1 implementation that cross-references installed plugins against the embedded registry. Output now shows NAME/STATUS/INSTALLED/LATEST for all registry-known platforms, with status values of installed, update available, or not installed. Community plugins (installed but absent from the registry) are printed in a separate section below. The previous no-plugins-installed short-circuit is replaced by a no-plugins-available message that fires only when the registry is empty and nothing is installed. Matching installed plugins to registry entries is done by Platform.Name, not by directory name, since the registry is keyed by platform. --- cli/cmd/plugin/list.go | 83 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/cli/cmd/plugin/list.go b/cli/cmd/plugin/list.go index 01f52d0..ceb0b37 100644 --- a/cli/cmd/plugin/list.go +++ b/cli/cmd/plugin/list.go @@ -3,45 +3,98 @@ package plugin import ( "fmt" "os" - "path/filepath" "text/tabwriter" "github.com/spf13/cobra" "github.com/open-semantic-interchange/ossie/cli/internal/ossiedir" "github.com/open-semantic-interchange/ossie/cli/internal/plugin" + "github.com/open-semantic-interchange/ossie/cli/internal/registry" ) var listCmd = &cobra.Command{ Use: "list", - Short: "List installed plugins", - // TODO(P1): cross-reference against the embedded plugin registry to show - // latest available versions and update indicators once registry embedding - // (F4) is implemented. - RunE: runPluginList, + Short: "List available and installed plugins", + RunE: runPluginList, } func runPluginList(cmd *cobra.Command, args []string) error { + reg, err := registry.Load() + if err != nil { + return err + } + pluginsDir, err := ossiedir.PluginDir() if err != nil { return err } - plugins, err := plugin.Discover(pluginsDir, os.Stderr) + installed, err := plugin.Discover(pluginsDir, os.Stderr) if err != nil { return err } - if len(plugins) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "no plugins installed") + // Index installed plugins by platform name for O(1) lookup. + installedByPlatform := make(map[string]*plugin.Plugin, len(installed)) + for _, p := range installed { + installedByPlatform[p.Platform.Name] = p + } + + // Identify community plugins: installed but absent from the registry. + var community []*plugin.Plugin + for _, p := range installed { + if _, ok := reg.LatestEntry(p.Platform.Name); !ok { + community = append(community, p) + } + } + + platforms := reg.Platforms() // sorted alphabetically + out := cmd.OutOrStdout() + + if len(platforms) == 0 && len(installed) == 0 { + fmt.Fprintln(out, "no plugins available") return nil } - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "NAME\tPLATFORM\tSPEC") - for _, p := range plugins { - name := filepath.Base(p.Path) - fmt.Fprintf(w, "%s\t%s\t%s\n", name, p.Platform.Name, p.OSSIEPluginSpec) + if len(platforms) > 0 { + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tSTATUS\tINSTALLED\tLATEST") + for _, platform := range platforms { + latest, _ := reg.LatestEntry(platform) + p, isInstalled := installedByPlatform[platform] + + var status, installedVer string + if isInstalled { + installedVer = p.OSSIEPluginSpec + if installedVer == latest.Version { + status = "installed" + } else { + status = "update available" + } + } else { + installedVer = "—" + status = "not installed" + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", platform, status, installedVer, latest.Version) + } + if err := w.Flush(); err != nil { + return err + } } - return w.Flush() + + if len(community) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, "Community plugins:") + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tINSTALLED") + for _, p := range community { + fmt.Fprintf(w, "%s\t%s\n", p.Platform.Name, p.OSSIEPluginSpec) + } + if err := w.Flush(); err != nil { + return err + } + } + + return nil } From feba7cb96667f1fbd906d43dab51d3f98fccbb71 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 16 Jun 2026 14:55:21 -0500 Subject: [PATCH 26/35] feat(cli): add file collection and LoadPlugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CollectFiles walks an input file or directory, filters by accepted extensions, enforces a max-size limit, and returns a map of relative-path keys to file content (forward slashes on all platforms). Single-file inputs bypass the extension filter — the user explicitly chose the file, so the plugin's Accepts list is not applied. Empty accepts means all files are included (used by from_osi which has no Accepts field). LoadPlugin exports the existing unexported loadPlugin, used when --plugin bypasses name-based discovery. --- cli/internal/plugin/collect.go | 87 ++++++++++++++++++++ cli/internal/plugin/collect_test.go | 122 ++++++++++++++++++++++++++++ cli/internal/plugin/discover.go | 7 ++ 3 files changed, 216 insertions(+) create mode 100644 cli/internal/plugin/collect.go create mode 100644 cli/internal/plugin/collect_test.go diff --git a/cli/internal/plugin/collect.go b/cli/internal/plugin/collect.go new file mode 100644 index 0000000..a582503 --- /dev/null +++ b/cli/internal/plugin/collect.go @@ -0,0 +1,87 @@ +package plugin + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// CollectFiles builds the files map for a plugin Request. +// +// If input is a regular file, it is read unconditionally (no extension filter) +// and keyed by its bare filename. The caller explicitly chose the file, so the +// plugin's Accepts list is not applied. +// +// If input is a directory, it is walked recursively. Only files whose extension +// (case-insensitive) appears in accepts are included. If accepts is empty, all +// files are included. Keys are forward-slash relative paths from the directory root. +// +// maxBytes enforces a cumulative size limit. Pass maxBytes <= 0 to disable it. +func CollectFiles(input string, accepts []string, maxBytes int64) (map[string]string, error) { + info, err := os.Stat(input) + if err != nil { + return nil, fmt.Errorf("cannot access input path %q: %w", input, err) + } + + if !info.IsDir() { + data, err := os.ReadFile(input) + if err != nil { + return nil, fmt.Errorf("cannot read input file %q: %w", input, err) + } + if maxBytes > 0 && int64(len(data)) > maxBytes { + return nil, fmt.Errorf("total input size exceeds limit (%dMB). Use --max-input-size to override", + maxBytes/(1024*1024)) + } + return map[string]string{filepath.Base(input): string(data)}, nil + } + + // Build a lowercase set for O(1) extension lookup. + acceptSet := make(map[string]struct{}, len(accepts)) + for _, ext := range accepts { + acceptSet[strings.ToLower(ext)] = struct{}{} + } + + files := make(map[string]string) + var total int64 + + err = filepath.WalkDir(input, func(path string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + if d.IsDir() { + return nil + } + if len(acceptSet) > 0 { + ext := strings.ToLower(filepath.Ext(path)) + if _, ok := acceptSet[ext]; !ok { + return nil + } + } + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("cannot read %q: %w", path, err) + } + + if maxBytes > 0 { + total += int64(len(data)) + if total > maxBytes { + return fmt.Errorf("total input size exceeds limit (%dMB). Use --max-input-size to override", + maxBytes/(1024*1024)) + } + } + + rel, err := filepath.Rel(input, path) + if err != nil { + return err + } + files[filepath.ToSlash(rel)] = string(data) + return nil + }) + if err != nil { + return nil, err + } + return files, nil +} diff --git a/cli/internal/plugin/collect_test.go b/cli/internal/plugin/collect_test.go new file mode 100644 index 0000000..46f71be --- /dev/null +++ b/cli/internal/plugin/collect_test.go @@ -0,0 +1,122 @@ +package plugin_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" +) + +// writeFile is a test helper that creates a file with the given content. +// Distinct from writePlugin in discover_test.go which creates a full plugin dir. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatalf("could not write %s: %v", name, err) + } +} + +func TestCollectFiles_singleFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "model.yaml") + if err := os.WriteFile(path, []byte("content"), 0644); err != nil { + t.Fatal(err) + } + + // Single file: extension filter (.json) does NOT apply. + files, err := plugin.CollectFiles(path, []string{".json"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + if v, ok := files["model.yaml"]; !ok || v != "content" { + t.Errorf("unexpected files map: %v", files) + } +} + +func TestCollectFiles_directory(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.yaml", "a") + writeFile(t, dir, "b.yaml", "b") + + files, err := plugin.CollectFiles(dir, []string{".yaml"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 2 { + t.Errorf("expected 2 files, got %d: %v", len(files), files) + } +} + +func TestCollectFiles_directoryFiltered(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.yaml", "yaml-content") + writeFile(t, dir, "readme.md", "md-content") + + files, err := plugin.CollectFiles(dir, []string{".yaml"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file after filter, got %d: %v", len(files), files) + } + if _, ok := files["model.yaml"]; !ok { + t.Errorf("expected model.yaml in files, got: %v", files) + } +} + +func TestCollectFiles_forwardSlashKeys(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "subdir") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + writeFile(t, sub, "model.yaml", "content") + + files, err := plugin.CollectFiles(dir, []string{".yaml"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for k := range files { + for _, ch := range k { + if ch == '\\' { + t.Errorf("key contains backslash: %q", k) + } + } + if k != "subdir/model.yaml" { + t.Errorf("expected key %q, got %q", "subdir/model.yaml", k) + } + } +} + +func TestCollectFiles_exceedsMaxSize(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "big.yaml", "12345") // 5 bytes + + _, err := plugin.CollectFiles(dir, []string{".yaml"}, 4) // limit 4 bytes + if err == nil { + t.Fatal("expected error for oversized input, got nil") + } +} + +func TestCollectFiles_emptyDirectory(t *testing.T) { + dir := t.TempDir() + + files, err := plugin.CollectFiles(dir, []string{".yaml"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 0 { + t.Errorf("expected empty map, got: %v", files) + } +} + +func TestCollectFiles_nonExistentInput(t *testing.T) { + _, err := plugin.CollectFiles(filepath.Join(t.TempDir(), "nonexistent.yaml"), nil, 0) + if err == nil { + t.Fatal("expected error for non-existent path, got nil") + } +} diff --git a/cli/internal/plugin/discover.go b/cli/internal/plugin/discover.go index 7263e32..2d22352 100644 --- a/cli/internal/plugin/discover.go +++ b/cli/internal/plugin/discover.go @@ -74,3 +74,10 @@ func loadPlugin(dir string) (*Plugin, error) { return raw.toPlugin(dir), nil } + +// LoadPlugin reads, parses, and validates the plugin.yaml inside dir. +// It is the exported equivalent of loadPlugin, used when --plugin bypasses +// name-based discovery. +func LoadPlugin(dir string) (*Plugin, error) { + return loadPlugin(dir) +} From 9d12f19c9c393bd8bd5460354bf20ab3a7fb8098 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 16 Jun 2026 14:56:37 -0500 Subject: [PATCH 27/35] feat(cli): embed osi schema for output validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds cli/internal/schema/ with the OSI JSON schema embedded via //go:embed. Validate() accepts YAML or JSON, round-trips through encoding/json to normalise yaml.v3 types to what jsonschema/v6 expects, then validates against the compiled schema. The schema is compiled once in init() — a panic here indicates a build defect (corrupted embed), not a runtime error. New dependency: github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 (draft 2020-12 support, matching the schema's $schema declaration). --- cli/go.mod | 2 + cli/go.sum | 6 + cli/internal/schema/osi-schema.json | 330 ++++++++++++++++++++++++++++ cli/internal/schema/schema.go | 54 +++++ cli/internal/schema/schema_test.go | 55 +++++ 5 files changed, 447 insertions(+) create mode 100644 cli/internal/schema/osi-schema.json create mode 100644 cli/internal/schema/schema.go create mode 100644 cli/internal/schema/schema_test.go diff --git a/cli/go.mod b/cli/go.mod index d17729d..afb2f28 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,6 +3,7 @@ module github.com/open-semantic-interchange/ossie/cli go 1.22 require ( + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -10,4 +11,5 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/text v0.14.0 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index 47edb24..f11d3e5 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,12 +1,18 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/cli/internal/schema/osi-schema.json b/cli/internal/schema/osi-schema.json new file mode 100644 index 0000000..72cb164 --- /dev/null +++ b/cli/internal/schema/osi-schema.json @@ -0,0 +1,330 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/open-semantic-interchange/OSI/core-spec/osi-schema.json", + "title": "OSI Core Metadata Specification", + "description": "JSON Schema for validating OSI (Open Semantic Interoperability) semantic model definitions", + "type": "object", + "properties": { + "version": { + "type": "string", + "const": "0.2.0.dev0", + "description": "OSI specification version" + }, + "semantic_model": { + "type": "array", + "description": "Collection of semantic model definitions", + "items": { + "$ref": "#/$defs/SemanticModel" + } + } + }, + "required": ["version", "semantic_model"], + "additionalProperties": false, + "$defs": { + "Dialect": { + "type": "string", + "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL"], + "description": "Supported SQL and expression language dialects" + }, + "Vendor": { + "type": "string", + "examples": ["COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA"], + "description": "Vendor name for custom extensions. Any string value is accepted." + }, + "AIContext": { + "description": "Additional context for AI tools", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "instructions": { + "type": "string", + "description": "Instructions for AI on how to use this entity" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Alternative names and terms" + }, + "examples": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sample questions or use cases" + } + }, + "additionalProperties": true + } + ] + }, + "CustomExtension": { + "type": "object", + "description": "Vendor-specific attributes for extensibility", + "properties": { + "vendor_name": { + "$ref": "#/$defs/Vendor" + }, + "data": { + "type": "string", + "description": "JSON string containing vendor-specific data" + } + }, + "required": ["vendor_name", "data"], + "additionalProperties": false + }, + "DialectExpression": { + "type": "object", + "description": "Expression in a specific dialect", + "properties": { + "dialect": { + "$ref": "#/$defs/Dialect" + }, + "expression": { + "type": "string", + "description": "SQL or dialect-specific expression" + } + }, + "required": ["dialect", "expression"], + "additionalProperties": false + }, + "Expression": { + "type": "object", + "description": "Expression definition with multi-dialect support", + "properties": { + "dialects": { + "type": "array", + "items": { + "$ref": "#/$defs/DialectExpression" + }, + "minItems": 1 + } + }, + "required": ["dialects"], + "additionalProperties": false + }, + "Dimension": { + "type": "object", + "description": "Dimension metadata", + "properties": { + "is_time": { + "type": "boolean", + "description": "Indicates if this is a time-based dimension for temporal filtering" + } + }, + "additionalProperties": false + }, + "Field": { + "type": "object", + "description": "Row-level attribute for grouping, filtering, and metric expressions", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the field within the dataset" + }, + "expression": { + "$ref": "#/$defs/Expression" + }, + "dimension": { + "$ref": "#/$defs/Dimension" + }, + "label": { + "type": "string", + "description": "Label for categorization" + }, + "description": { + "type": "string", + "description": "Human-readable description" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "expression"], + "additionalProperties": false + }, + "Dataset": { + "type": "object", + "description": "Logical dataset representing a business entity (fact or dimension table)", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the dataset" + }, + "source": { + "type": "string", + "description": "Reference to underlying physical table/view (database.schema.table) or query" + }, + "primary_key": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Primary key columns (single or composite)" + }, + "unique_keys": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Array of unique key definitions (each can be single or composite)" + }, + "description": { + "type": "string", + "description": "Human-readable description" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/Field" + } + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "source"], + "additionalProperties": false + }, + "Relationship": { + "type": "object", + "description": "Foreign key relationship between datasets", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the relationship" + }, + "from": { + "type": "string", + "description": "Dataset on the many side of the relationship" + }, + "to": { + "type": "string", + "description": "Dataset on the one side of the relationship" + }, + "from_columns": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "Foreign key columns in the 'from' dataset" + }, + "to_columns": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "Primary/unique key columns in the 'to' dataset" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "from", "to", "from_columns", "to_columns"], + "additionalProperties": false + }, + "Metric": { + "type": "object", + "description": "Quantitative measure defined on business data", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the metric" + }, + "expression": { + "$ref": "#/$defs/Expression" + }, + "description": { + "type": "string", + "description": "Human-readable description of what the metric measures" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "expression"], + "additionalProperties": false + }, + "SemanticModel": { + "type": "object", + "description": "Top-level container representing a complete semantic model", + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the semantic model" + }, + "description": { + "type": "string", + "description": "Human-readable description" + }, + "ai_context": { + "$ref": "#/$defs/AIContext" + }, + "datasets": { + "type": "array", + "items": { + "$ref": "#/$defs/Dataset" + }, + "minItems": 1, + "description": "Collection of logical datasets" + }, + "relationships": { + "type": "array", + "items": { + "$ref": "#/$defs/Relationship" + }, + "description": "Defines how datasets are connected" + }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/$defs/Metric" + }, + "description": "Quantifiable measures spanning datasets" + }, + "custom_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomExtension" + } + } + }, + "required": ["name", "datasets"], + "additionalProperties": false + } + } +} diff --git a/cli/internal/schema/schema.go b/cli/internal/schema/schema.go new file mode 100644 index 0000000..c3c0efd --- /dev/null +++ b/cli/internal/schema/schema.go @@ -0,0 +1,54 @@ +package schema + +import ( + "bytes" + _ "embed" // blank import required to activate //go:embed + "encoding/json" + "fmt" + + "github.com/santhosh-tekuri/jsonschema/v6" + "gopkg.in/yaml.v3" +) + +//go:embed osi-schema.json +var schemaJSON []byte + +// schemaID must exactly match the $id in osi-schema.json. +// The compiler uses this string as both the resource URL and the compile target. +const schemaID = "https://github.com/open-semantic-interchange/OSI/core-spec/osi-schema.json" + +var compiledSchema *jsonschema.Schema + +func init() { + schemaSrc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON)) + if err != nil { + panic(fmt.Sprintf("schema: failed to parse embedded OSI schema: %v", err)) + } + c := jsonschema.NewCompiler() + if err := c.AddResource(schemaID, schemaSrc); err != nil { + panic(fmt.Sprintf("schema: failed to register embedded OSI schema: %v", err)) + } + compiledSchema = c.MustCompile(schemaID) +} + +// Validate validates data (YAML or JSON bytes) against the embedded OSI schema. +// YAML is converted to JSON via a round-trip through yaml.v3 and encoding/json +// to normalise Go types to the representation jsonschema/v6 expects. +// Returns nil if valid, an error describing the violation otherwise. +func Validate(data []byte) error { + var doc any + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("failed to parse document: %w", err) + } + // Round-trip through JSON to normalise yaml.v3's native int/float64 + // to json.Number, which jsonschema/v6 expects. + jsonBytes, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("failed to serialise document to JSON: %w", err) + } + inst, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonBytes)) + if err != nil { + return fmt.Errorf("failed to prepare document for validation: %w", err) + } + return compiledSchema.Validate(inst) +} diff --git a/cli/internal/schema/schema_test.go b/cli/internal/schema/schema_test.go new file mode 100644 index 0000000..2a4ac85 --- /dev/null +++ b/cli/internal/schema/schema_test.go @@ -0,0 +1,55 @@ +package schema_test + +import ( + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/schema" +) + +// minimalValidYAML satisfies all schema-required fields: +// root: version (const) + semantic_model (array) +// SemanticModel: name + datasets (minItems 1) +// Dataset: name + source +const minimalValidYAML = ` +version: "0.2.0.dev0" +semantic_model: + - name: test_model + datasets: + - name: test_dataset + source: db.schema.table +` + +const minimalValidJSON = `{ + "version": "0.2.0.dev0", + "semantic_model": [ + { + "name": "test_model", + "datasets": [ + { + "name": "test_dataset", + "source": "db.schema.table" + } + ] + } + ] +}` + +func TestValidate_validYAML(t *testing.T) { + if err := schema.Validate([]byte(minimalValidYAML)); err != nil { + t.Errorf("expected valid YAML to pass, got: %v", err) + } +} + +func TestValidate_validJSON(t *testing.T) { + if err := schema.Validate([]byte(minimalValidJSON)); err != nil { + t.Errorf("expected valid JSON to pass, got: %v", err) + } +} + +func TestValidate_invalidDocument(t *testing.T) { + // Missing required fields (version, semantic_model) and additionalProperties:false + // means unknown top-level keys are also rejected. + if err := schema.Validate([]byte(`name: foo`)); err == nil { + t.Error("expected invalid document to fail, got nil") + } +} From cfd2be1221ff2bd8a3737c897259ff0a2429fa1b Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 16 Jun 2026 14:57:55 -0500 Subject: [PATCH 28/35] feat(cli): implement osi convert base command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires together file collection (F2), plugin invocation (F3), schema validation, and output writing into a working end-to-end convert command. Flow: resolve plugin by name or --plugin path → collect input files → invoke plugin with timeout context → validate to_osi output against embedded OSI schema → write passing files to ossie-output/ → render issues to stderr → exit non-zero on any error-severity issue. Schema validation failures remove the offending file from output (rather than writing it with a warning) — the spec says validate before writing. Info-severity issues are suppressed unless --verbose. parseMaxInputSize supports MB/GB suffixes (case-insensitive) and raw byte counts. renderIssues formats each issue as: severity: message severity: path: message (when path is present) --- cli/cmd/convert.go | 186 +++++++++++++++++++++++++++++++++++++++- cli/cmd/convert_test.go | 124 +++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 cli/cmd/convert_test.go diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go index 92722ee..bb285ae 100644 --- a/cli/cmd/convert.go +++ b/cli/cmd/convert.go @@ -1,8 +1,18 @@ package cmd import ( + "context" "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "github.com/open-semantic-interchange/ossie/cli/internal/ossiedir" + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" + "github.com/open-semantic-interchange/ossie/cli/internal/schema" "github.com/spf13/cobra" ) @@ -26,14 +36,184 @@ func init() { } func runConvert(cmd *cobra.Command, args []string) error { + // --- 1. Parse flags --- from, _ := cmd.Flags().GetString("from") to, _ := cmd.Flags().GetString("to") - - // MarkFlagsMutuallyExclusive handles the both-set case; handle neither here. if from == "" && to == "" { return fmt.Errorf("exactly one of --from or --to must be specified") } - fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + input, _ := cmd.Flags().GetString("input") + outputFlag, _ := cmd.Flags().GetString("output") + pluginFlag, _ := cmd.Flags().GetString("plugin") + timeoutSecs, _ := cmd.Flags().GetInt("timeout") + maxInputSizeStr, _ := cmd.Flags().GetString("max-input-size") + // verbose is a PersistentFlag on root; Cobra injects it into child flag sets. + verbose, _ := cmd.Flags().GetBool("verbose") + + // --- 2. Resolve plugin --- + var p *plugin.Plugin + if pluginFlag != "" { + loaded, err := plugin.LoadPlugin(pluginFlag) + if err != nil { + return fmt.Errorf("could not load plugin from %q: %w", pluginFlag, err) + } + p = loaded + } else { + platformName := from + if to != "" { + platformName = to + } + pluginsDir, err := ossiedir.PluginDir() + if err != nil { + return err + } + plugins, err := plugin.Discover(pluginsDir, cmd.ErrOrStderr()) + if err != nil { + return err + } + var matches []*plugin.Plugin + for _, candidate := range plugins { + if candidate.Platform.Name == platformName { + matches = append(matches, candidate) + } + } + switch len(matches) { + case 0: + return fmt.Errorf("no plugin found for %q; run 'ossie plugin install %s' to install it", platformName, platformName) + case 1: + p = matches[0] + default: + return fmt.Errorf("multiple plugins found for %q; use --plugin to specify a path", platformName) + } + } + + // --- 3. Select direction --- + var direction string + var dir plugin.Direction + if from != "" { + direction = "to_osi" + dir = p.Convert.ToOSI + } else { + direction = "from_osi" + dir = p.Convert.FromOSI + } + + // --- 4. Parse max-input-size --- + maxBytes, err := parseMaxInputSize(maxInputSizeStr) + if err != nil { + return fmt.Errorf("invalid --max-input-size %q: %w", maxInputSizeStr, err) + } + + // --- 5. Collect input files --- + files, err := plugin.CollectFiles(input, dir.Accepts, maxBytes) + if err != nil { + return fmt.Errorf("input collection failed: %w", err) + } + + // --- 6. Build context with timeout --- + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs)*time.Second) + defer cancel() + + // --- 7. Route plugin stderr --- + var pluginStderr io.Writer = io.Discard + if verbose { + pluginStderr = cmd.ErrOrStderr() + } + + // --- 8. Invoke plugin --- + resp, err := plugin.Invoke(ctx, p.Path, dir.Invoke, plugin.Request{Files: files}, pluginStderr) + if err != nil { + return fmt.Errorf("plugin invocation failed: %w", err) + } + + // --- 9. Validate output (to_osi direction only) --- + // Schema validation runs before any files are written to disk. + // Files that fail validation are removed from resp.Files and reported + // as error-severity issues. + if direction == "to_osi" { + for name, content := range resp.Files { + if verr := schema.Validate([]byte(content)); verr != nil { + resp.Issues = append(resp.Issues, plugin.Issue{ + Severity: "error", + Path: name, + Message: fmt.Sprintf("output validation failed: %v", verr), + }) + delete(resp.Files, name) + } + } + } + + // --- 10. Write output files --- + outDir := outputFlag + if outDir == "" { + outDir = filepath.Join("ossie-output", p.Platform.Name, direction) + } + if err := os.MkdirAll(outDir, 0755); err != nil { + return fmt.Errorf("could not create output directory %q: %w", outDir, err) + } + for name, content := range resp.Files { + dest := filepath.Join(outDir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return fmt.Errorf("could not create output subdirectory for %q: %w", name, err) + } + if err := os.WriteFile(dest, []byte(content), 0644); err != nil { + return fmt.Errorf("could not write output file %q: %w", dest, err) + } + } + + // --- 11. Render issues and set exit code --- + if hasErrors := renderIssues(resp.Issues, cmd.ErrOrStderr(), verbose); hasErrors { + return fmt.Errorf("conversion completed with errors") + } return nil } + +// parseMaxInputSize parses a size string like "100MB", "2GB", or a raw integer +// (interpreted as bytes). The suffix is case-insensitive. +func parseMaxInputSize(s string) (int64, error) { + upper := strings.ToUpper(strings.TrimSpace(s)) + if strings.HasSuffix(upper, "GB") { + n, err := strconv.ParseInt(strings.TrimSuffix(upper, "GB"), 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid GB value: %w", err) + } + return n * 1024 * 1024 * 1024, nil + } + if strings.HasSuffix(upper, "MB") { + n, err := strconv.ParseInt(strings.TrimSuffix(upper, "MB"), 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid MB value: %w", err) + } + return n * 1024 * 1024, nil + } + n, err := strconv.ParseInt(upper, 10, 64) + if err != nil { + return 0, fmt.Errorf("unsupported size format %q (use e.g. 100MB, 2GB, or a raw byte count)", s) + } + return n, nil +} + +// renderIssues writes issues to w and returns true if any error-severity issue +// is present. info-severity issues are suppressed unless verbose is true. +func renderIssues(issues []plugin.Issue, w io.Writer, verbose bool) bool { + hasErrors := false + for _, iss := range issues { + if iss.Severity == "info" && !verbose { + continue + } + if iss.Path != "" { + fmt.Fprintf(w, "%s: %s: %s\n", iss.Severity, iss.Path, iss.Message) + } else { + fmt.Fprintf(w, "%s: %s\n", iss.Severity, iss.Message) + } + if iss.Severity == "error" { + hasErrors = true + } + } + return hasErrors +} diff --git a/cli/cmd/convert_test.go b/cli/cmd/convert_test.go new file mode 100644 index 0000000..adc7190 --- /dev/null +++ b/cli/cmd/convert_test.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" +) + +// --- parseMaxInputSize tests --- + +func TestParseMaxInputSize_MB(t *testing.T) { + got, err := parseMaxInputSize("100MB") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := int64(100 * 1024 * 1024) + if got != want { + t.Errorf("got %d, want %d", got, want) + } +} + +func TestParseMaxInputSize_MB_lowercase(t *testing.T) { + got, err := parseMaxInputSize("50mb") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := int64(50 * 1024 * 1024) + if got != want { + t.Errorf("got %d, want %d", got, want) + } +} + +func TestParseMaxInputSize_GB(t *testing.T) { + got, err := parseMaxInputSize("2GB") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := int64(2 * 1024 * 1024 * 1024) + if got != want { + t.Errorf("got %d, want %d", got, want) + } +} + +func TestParseMaxInputSize_rawBytes(t *testing.T) { + got, err := parseMaxInputSize("1048576") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 1048576 { + t.Errorf("got %d, want 1048576", got) + } +} + +func TestParseMaxInputSize_invalid(t *testing.T) { + _, err := parseMaxInputSize("fooMB") + if err == nil { + t.Fatal("expected error for invalid input, got nil") + } +} + +func TestParseMaxInputSize_invalidNoSuffix(t *testing.T) { + _, err := parseMaxInputSize("not-a-number") + if err == nil { + t.Fatal("expected error for non-numeric input without suffix, got nil") + } +} + +// --- renderIssues tests --- + +func TestRenderIssues_errorSetsReturnTrue(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "error", Message: "something failed"}} + if !renderIssues(issues, &buf, false) { + t.Error("expected hasErrors=true for error-severity issue") + } + if !strings.Contains(buf.String(), "error: something failed") { + t.Errorf("unexpected output: %q", buf.String()) + } +} + +func TestRenderIssues_warningSetsReturnFalse(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "warning", Message: "a warning"}} + if renderIssues(issues, &buf, false) { + t.Error("expected hasErrors=false for warning-only issue") + } +} + +func TestRenderIssues_infoSuppressedWhenNotVerbose(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "info", Message: "some info"}} + renderIssues(issues, &buf, false) + if buf.Len() != 0 { + t.Errorf("expected no output for info issue when not verbose, got: %q", buf.String()) + } +} + +func TestRenderIssues_infoPrintedWhenVerbose(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "info", Message: "some info"}} + renderIssues(issues, &buf, true) + if !strings.Contains(buf.String(), "info: some info") { + t.Errorf("expected info output when verbose, got: %q", buf.String()) + } +} + +func TestRenderIssues_pathIncludedInOutput(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "warning", Path: "models/orders.yaml", Message: "deprecated field"}} + renderIssues(issues, &buf, false) + if !strings.Contains(buf.String(), "warning: models/orders.yaml: deprecated field") { + t.Errorf("expected path in output, got: %q", buf.String()) + } +} + +func TestRenderIssues_emptyPathNoDoubleColon(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "error", Message: "fatal"}} + renderIssues(issues, &buf, false) + if strings.Contains(buf.String(), ": : ") { + t.Errorf("empty path must not produce double colon, got: %q", buf.String()) + } +} From 270b762f1eb1b35e8d51d10335e9f5d6395dcda4 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 16 Jun 2026 15:18:47 -0500 Subject: [PATCH 29/35] chore(cli): remove duplicate schema copy, generate from core-spec cli/internal/schema/osi-schema.json was a committed copy of core-spec/osi-schema.json that could drift silently. The file is now gitignored and generated by `make prepare`, which copies from the canonical source before any build or test step. CI switches from bare go commands to make targets so the copy step runs automatically in all environments. --- .github/workflows/cli-ci.yml | 12 +- .gitignore | 3 + cli/Makefile | 14 +- cli/internal/schema/osi-schema.json | 330 ---------------------------- 4 files changed, 18 insertions(+), 341 deletions(-) delete mode 100644 cli/internal/schema/osi-schema.json diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index e729089..e182e77 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -28,11 +28,11 @@ jobs: go-version-file: cli/go.mod cache-dependency-path: cli/go.sum - - name: go build - run: go build ./... + - name: make build + run: make build - - name: go vet - run: go vet ./... + - name: make lint + run: make lint - - name: go test - run: go test ./... + - name: make test + run: make test diff --git a/.gitignore b/.gitignore index 5f39ab3..46098ed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ # Go CLI cli/dist/ cli/ossie + +# Generated from core-spec/osi-schema.json by `make prepare` +cli/internal/schema/osi-schema.json diff --git a/cli/Makefile b/cli/Makefile index d1f7d66..7575875 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -1,18 +1,22 @@ BINARY_NAME := ossie BUILD_DIR := dist -.PHONY: build install test lint release-dry-run clean +.PHONY: prepare build install test lint release-dry-run clean -build: +# Copy the canonical schema from the repo root before any Go step that needs it. +prepare: + cp ../core-spec/osi-schema.json internal/schema/osi-schema.json + +build: prepare go build -o $(BUILD_DIR)/$(BINARY_NAME) . -install: +install: prepare go build -o $(shell go env GOPATH)/bin/$(BINARY_NAME) . -test: +test: prepare go test ./... -lint: +lint: prepare go vet ./... release-dry-run: diff --git a/cli/internal/schema/osi-schema.json b/cli/internal/schema/osi-schema.json deleted file mode 100644 index 72cb164..0000000 --- a/cli/internal/schema/osi-schema.json +++ /dev/null @@ -1,330 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/open-semantic-interchange/OSI/core-spec/osi-schema.json", - "title": "OSI Core Metadata Specification", - "description": "JSON Schema for validating OSI (Open Semantic Interoperability) semantic model definitions", - "type": "object", - "properties": { - "version": { - "type": "string", - "const": "0.2.0.dev0", - "description": "OSI specification version" - }, - "semantic_model": { - "type": "array", - "description": "Collection of semantic model definitions", - "items": { - "$ref": "#/$defs/SemanticModel" - } - } - }, - "required": ["version", "semantic_model"], - "additionalProperties": false, - "$defs": { - "Dialect": { - "type": "string", - "enum": ["ANSI_SQL", "SNOWFLAKE", "MDX", "TABLEAU", "DATABRICKS", "MAQL"], - "description": "Supported SQL and expression language dialects" - }, - "Vendor": { - "type": "string", - "examples": ["COMMON", "SNOWFLAKE", "SALESFORCE", "DBT", "DATABRICKS", "GOODDATA"], - "description": "Vendor name for custom extensions. Any string value is accepted." - }, - "AIContext": { - "description": "Additional context for AI tools", - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "instructions": { - "type": "string", - "description": "Instructions for AI on how to use this entity" - }, - "synonyms": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Alternative names and terms" - }, - "examples": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sample questions or use cases" - } - }, - "additionalProperties": true - } - ] - }, - "CustomExtension": { - "type": "object", - "description": "Vendor-specific attributes for extensibility", - "properties": { - "vendor_name": { - "$ref": "#/$defs/Vendor" - }, - "data": { - "type": "string", - "description": "JSON string containing vendor-specific data" - } - }, - "required": ["vendor_name", "data"], - "additionalProperties": false - }, - "DialectExpression": { - "type": "object", - "description": "Expression in a specific dialect", - "properties": { - "dialect": { - "$ref": "#/$defs/Dialect" - }, - "expression": { - "type": "string", - "description": "SQL or dialect-specific expression" - } - }, - "required": ["dialect", "expression"], - "additionalProperties": false - }, - "Expression": { - "type": "object", - "description": "Expression definition with multi-dialect support", - "properties": { - "dialects": { - "type": "array", - "items": { - "$ref": "#/$defs/DialectExpression" - }, - "minItems": 1 - } - }, - "required": ["dialects"], - "additionalProperties": false - }, - "Dimension": { - "type": "object", - "description": "Dimension metadata", - "properties": { - "is_time": { - "type": "boolean", - "description": "Indicates if this is a time-based dimension for temporal filtering" - } - }, - "additionalProperties": false - }, - "Field": { - "type": "object", - "description": "Row-level attribute for grouping, filtering, and metric expressions", - "properties": { - "name": { - "type": "string", - "description": "Unique identifier for the field within the dataset" - }, - "expression": { - "$ref": "#/$defs/Expression" - }, - "dimension": { - "$ref": "#/$defs/Dimension" - }, - "label": { - "type": "string", - "description": "Label for categorization" - }, - "description": { - "type": "string", - "description": "Human-readable description" - }, - "ai_context": { - "$ref": "#/$defs/AIContext" - }, - "custom_extensions": { - "type": "array", - "items": { - "$ref": "#/$defs/CustomExtension" - } - } - }, - "required": ["name", "expression"], - "additionalProperties": false - }, - "Dataset": { - "type": "object", - "description": "Logical dataset representing a business entity (fact or dimension table)", - "properties": { - "name": { - "type": "string", - "description": "Unique identifier for the dataset" - }, - "source": { - "type": "string", - "description": "Reference to underlying physical table/view (database.schema.table) or query" - }, - "primary_key": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Primary key columns (single or composite)" - }, - "unique_keys": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - }, - "description": "Array of unique key definitions (each can be single or composite)" - }, - "description": { - "type": "string", - "description": "Human-readable description" - }, - "ai_context": { - "$ref": "#/$defs/AIContext" - }, - "fields": { - "type": "array", - "items": { - "$ref": "#/$defs/Field" - } - }, - "custom_extensions": { - "type": "array", - "items": { - "$ref": "#/$defs/CustomExtension" - } - } - }, - "required": ["name", "source"], - "additionalProperties": false - }, - "Relationship": { - "type": "object", - "description": "Foreign key relationship between datasets", - "properties": { - "name": { - "type": "string", - "description": "Unique identifier for the relationship" - }, - "from": { - "type": "string", - "description": "Dataset on the many side of the relationship" - }, - "to": { - "type": "string", - "description": "Dataset on the one side of the relationship" - }, - "from_columns": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "description": "Foreign key columns in the 'from' dataset" - }, - "to_columns": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "description": "Primary/unique key columns in the 'to' dataset" - }, - "ai_context": { - "$ref": "#/$defs/AIContext" - }, - "custom_extensions": { - "type": "array", - "items": { - "$ref": "#/$defs/CustomExtension" - } - } - }, - "required": ["name", "from", "to", "from_columns", "to_columns"], - "additionalProperties": false - }, - "Metric": { - "type": "object", - "description": "Quantitative measure defined on business data", - "properties": { - "name": { - "type": "string", - "description": "Unique identifier for the metric" - }, - "expression": { - "$ref": "#/$defs/Expression" - }, - "description": { - "type": "string", - "description": "Human-readable description of what the metric measures" - }, - "ai_context": { - "$ref": "#/$defs/AIContext" - }, - "custom_extensions": { - "type": "array", - "items": { - "$ref": "#/$defs/CustomExtension" - } - } - }, - "required": ["name", "expression"], - "additionalProperties": false - }, - "SemanticModel": { - "type": "object", - "description": "Top-level container representing a complete semantic model", - "properties": { - "name": { - "type": "string", - "description": "Unique identifier for the semantic model" - }, - "description": { - "type": "string", - "description": "Human-readable description" - }, - "ai_context": { - "$ref": "#/$defs/AIContext" - }, - "datasets": { - "type": "array", - "items": { - "$ref": "#/$defs/Dataset" - }, - "minItems": 1, - "description": "Collection of logical datasets" - }, - "relationships": { - "type": "array", - "items": { - "$ref": "#/$defs/Relationship" - }, - "description": "Defines how datasets are connected" - }, - "metrics": { - "type": "array", - "items": { - "$ref": "#/$defs/Metric" - }, - "description": "Quantifiable measures spanning datasets" - }, - "custom_extensions": { - "type": "array", - "items": { - "$ref": "#/$defs/CustomExtension" - } - } - }, - "required": ["name", "datasets"], - "additionalProperties": false - } - } -} From c8d5c723a7b32fbadcefc8d544d07e4e30308861 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 16 Jun 2026 15:21:32 -0500 Subject: [PATCH 30/35] test(cli): add test for CollectFiles with empty accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty accepts means no extension filter — all files in the directory are included. The godoc stated this but it was untested. --- cli/internal/plugin/collect_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cli/internal/plugin/collect_test.go b/cli/internal/plugin/collect_test.go index 46f71be..bb0f550 100644 --- a/cli/internal/plugin/collect_test.go +++ b/cli/internal/plugin/collect_test.go @@ -120,3 +120,19 @@ func TestCollectFiles_nonExistentInput(t *testing.T) { t.Fatal("expected error for non-existent path, got nil") } } + +func TestCollectFiles_emptyAcceptsIncludesAllFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.yaml", "yaml-content") + writeFile(t, dir, "readme.md", "md-content") + writeFile(t, dir, "data.json", "json-content") + + // Empty accepts means no filter — all files should be included. + files, err := plugin.CollectFiles(dir, []string{}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 3 { + t.Errorf("expected 3 files with empty accepts, got %d: %v", len(files), files) + } +} From 395bb9309eaf036e78e4453a04418c59fb1e4f7b Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 16 Jun 2026 15:23:05 -0500 Subject: [PATCH 31/35] fix(cli): skip output dir creation when no files to write If all to_osi output files fail schema validation, resp.Files is empty. Creating the output directory in that case leaves an empty ossie-output/ on disk with no explanation. Guard os.MkdirAll behind len(resp.Files) > 0 so the directory only appears when there is something to write. --- cli/cmd/convert.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go index bb285ae..8005f86 100644 --- a/cli/cmd/convert.go +++ b/cli/cmd/convert.go @@ -153,8 +153,10 @@ func runConvert(cmd *cobra.Command, args []string) error { if outDir == "" { outDir = filepath.Join("ossie-output", p.Platform.Name, direction) } - if err := os.MkdirAll(outDir, 0755); err != nil { - return fmt.Errorf("could not create output directory %q: %w", outDir, err) + if len(resp.Files) > 0 { + if err := os.MkdirAll(outDir, 0755); err != nil { + return fmt.Errorf("could not create output directory %q: %w", outDir, err) + } } for name, content := range resp.Files { dest := filepath.Join(outDir, filepath.FromSlash(name)) From ee810936870983b99eba70a35a6250fee6a8a28b Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 16 Jun 2026 15:24:37 -0500 Subject: [PATCH 32/35] fix(cli): use backticks for inline command in error message Consistent with Go error message conventions and the rest of the codebase. Single quotes were a shell quoting style. --- cli/cmd/convert.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go index 8005f86..27c2331 100644 --- a/cli/cmd/convert.go +++ b/cli/cmd/convert.go @@ -80,7 +80,7 @@ func runConvert(cmd *cobra.Command, args []string) error { } switch len(matches) { case 0: - return fmt.Errorf("no plugin found for %q; run 'ossie plugin install %s' to install it", platformName, platformName) + return fmt.Errorf("no plugin found for %q; run `ossie plugin install %s` to install it", platformName, platformName) case 1: p = matches[0] default: From 4c0cb0b7308d8baa4201e5c42683b91f335c0b48 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 16 Jun 2026 15:29:12 -0500 Subject: [PATCH 33/35] refactor(cli): break runConvert into named helper functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runConvert was a single function with 11 numbered sections. Extract sections 2, 3, 9, and 10 into named functions so the orchestrator reads as a clean sequence of calls: resolvePlugin — discover or load plugin by path/name selectDirection — map --from/--to to direction + Direction struct validateOutput — schema-validate to_osi files, remove invalid ones writeOutput — create outDir and write response files to disk Sections 4 (parseMaxInputSize), 5 (CollectFiles), 8 (Invoke), and 11 (renderIssues) were already named functions. Sections 1, 6, and 7 are too small to warrant extraction. --- cli/cmd/convert.go | 181 ++++++++++++++++++++++++++------------------- 1 file changed, 104 insertions(+), 77 deletions(-) diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go index 27c2331..c61e284 100644 --- a/cli/cmd/convert.go +++ b/cli/cmd/convert.go @@ -36,13 +36,12 @@ func init() { } func runConvert(cmd *cobra.Command, args []string) error { - // --- 1. Parse flags --- + // 1. Parse flags from, _ := cmd.Flags().GetString("from") to, _ := cmd.Flags().GetString("to") if from == "" && to == "" { return fmt.Errorf("exactly one of --from or --to must be specified") } - input, _ := cmd.Flags().GetString("input") outputFlag, _ := cmd.Flags().GetString("output") pluginFlag, _ := cmd.Flags().GetString("plugin") @@ -51,67 +50,32 @@ func runConvert(cmd *cobra.Command, args []string) error { // verbose is a PersistentFlag on root; Cobra injects it into child flag sets. verbose, _ := cmd.Flags().GetBool("verbose") - // --- 2. Resolve plugin --- - var p *plugin.Plugin - if pluginFlag != "" { - loaded, err := plugin.LoadPlugin(pluginFlag) - if err != nil { - return fmt.Errorf("could not load plugin from %q: %w", pluginFlag, err) - } - p = loaded - } else { - platformName := from - if to != "" { - platformName = to - } - pluginsDir, err := ossiedir.PluginDir() - if err != nil { - return err - } - plugins, err := plugin.Discover(pluginsDir, cmd.ErrOrStderr()) - if err != nil { - return err - } - var matches []*plugin.Plugin - for _, candidate := range plugins { - if candidate.Platform.Name == platformName { - matches = append(matches, candidate) - } - } - switch len(matches) { - case 0: - return fmt.Errorf("no plugin found for %q; run `ossie plugin install %s` to install it", platformName, platformName) - case 1: - p = matches[0] - default: - return fmt.Errorf("multiple plugins found for %q; use --plugin to specify a path", platformName) - } + // 2. Resolve plugin + platformName := from + if to != "" { + platformName = to } - - // --- 3. Select direction --- - var direction string - var dir plugin.Direction - if from != "" { - direction = "to_osi" - dir = p.Convert.ToOSI - } else { - direction = "from_osi" - dir = p.Convert.FromOSI + p, err := resolvePlugin(platformName, pluginFlag, cmd.ErrOrStderr()) + if err != nil { + return err } - // --- 4. Parse max-input-size --- + // 3. Select direction + direction, dir := selectDirection(p, from) + + // 4. Parse max-input-size maxBytes, err := parseMaxInputSize(maxInputSizeStr) if err != nil { return fmt.Errorf("invalid --max-input-size %q: %w", maxInputSizeStr, err) } - // --- 5. Collect input files --- + // 5. Collect input files files, err := plugin.CollectFiles(input, dir.Accepts, maxBytes) if err != nil { return fmt.Errorf("input collection failed: %w", err) } - // --- 6. Build context with timeout --- + // 6. Build context with timeout ctx := cmd.Context() if ctx == nil { ctx = context.Background() @@ -119,46 +83,114 @@ func runConvert(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs)*time.Second) defer cancel() - // --- 7. Route plugin stderr --- + // 7. Route plugin stderr var pluginStderr io.Writer = io.Discard if verbose { pluginStderr = cmd.ErrOrStderr() } - // --- 8. Invoke plugin --- + // 8. Invoke plugin resp, err := plugin.Invoke(ctx, p.Path, dir.Invoke, plugin.Request{Files: files}, pluginStderr) if err != nil { return fmt.Errorf("plugin invocation failed: %w", err) } - // --- 9. Validate output (to_osi direction only) --- - // Schema validation runs before any files are written to disk. - // Files that fail validation are removed from resp.Files and reported - // as error-severity issues. - if direction == "to_osi" { - for name, content := range resp.Files { - if verr := schema.Validate([]byte(content)); verr != nil { - resp.Issues = append(resp.Issues, plugin.Issue{ - Severity: "error", - Path: name, - Message: fmt.Sprintf("output validation failed: %v", verr), - }) - delete(resp.Files, name) - } - } - } + // 9. Validate output + resp.Issues = append(resp.Issues, validateOutput(resp, direction)...) - // --- 10. Write output files --- + // 10. Write output files outDir := outputFlag if outDir == "" { outDir = filepath.Join("ossie-output", p.Platform.Name, direction) } - if len(resp.Files) > 0 { - if err := os.MkdirAll(outDir, 0755); err != nil { - return fmt.Errorf("could not create output directory %q: %w", outDir, err) + if err := writeOutput(resp.Files, outDir); err != nil { + return err + } + + // 11. Render issues and set exit code + if hasErrors := renderIssues(resp.Issues, cmd.ErrOrStderr(), verbose); hasErrors { + return fmt.Errorf("conversion completed with errors") + } + return nil +} + +// resolvePlugin finds the plugin to use for the conversion. +// If pluginFlag is set, it loads from that path directly. Otherwise it +// discovers all installed plugins and filters by platformName. +func resolvePlugin(platformName, pluginFlag string, stderr io.Writer) (*plugin.Plugin, error) { + if pluginFlag != "" { + loaded, err := plugin.LoadPlugin(pluginFlag) + if err != nil { + return nil, fmt.Errorf("could not load plugin from %q: %w", pluginFlag, err) + } + return loaded, nil + } + + pluginsDir, err := ossiedir.PluginDir() + if err != nil { + return nil, err + } + plugins, err := plugin.Discover(pluginsDir, stderr) + if err != nil { + return nil, err + } + + var matches []*plugin.Plugin + for _, candidate := range plugins { + if candidate.Platform.Name == platformName { + matches = append(matches, candidate) } } + switch len(matches) { + case 0: + return nil, fmt.Errorf("no plugin found for %q; run `ossie plugin install %s` to install it", platformName, platformName) + case 1: + return matches[0], nil + default: + return nil, fmt.Errorf("multiple plugins found for %q; use --plugin to specify a path", platformName) + } +} + +// selectDirection returns the direction string ("to_osi" or "from_osi") and +// the corresponding Direction based on whether --from or --to was specified. +func selectDirection(p *plugin.Plugin, from string) (string, plugin.Direction) { + if from != "" { + return "to_osi", p.Convert.ToOSI + } + return "from_osi", p.Convert.FromOSI +} + +// validateOutput validates to_osi response files against the embedded OSI schema. +// Invalid files are removed from resp.Files in place and returned as +// error-severity issues for the caller to append to resp.Issues. +func validateOutput(resp *plugin.Response, direction string) []plugin.Issue { + if direction != "to_osi" { + return nil + } + var issues []plugin.Issue for name, content := range resp.Files { + if verr := schema.Validate([]byte(content)); verr != nil { + issues = append(issues, plugin.Issue{ + Severity: "error", + Path: name, + Message: fmt.Sprintf("output validation failed: %v", verr), + }) + delete(resp.Files, name) + } + } + return issues +} + +// writeOutput writes files to outDir, creating the directory only when there +// are files to write. Subdirectories within outDir are created as needed. +func writeOutput(files map[string]string, outDir string) error { + if len(files) == 0 { + return nil + } + if err := os.MkdirAll(outDir, 0755); err != nil { + return fmt.Errorf("could not create output directory %q: %w", outDir, err) + } + for name, content := range files { dest := filepath.Join(outDir, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return fmt.Errorf("could not create output subdirectory for %q: %w", name, err) @@ -167,11 +199,6 @@ func runConvert(cmd *cobra.Command, args []string) error { return fmt.Errorf("could not write output file %q: %w", dest, err) } } - - // --- 11. Render issues and set exit code --- - if hasErrors := renderIssues(resp.Issues, cmd.ErrOrStderr(), verbose); hasErrors { - return fmt.Errorf("conversion completed with errors") - } return nil } From 88fb91f9b44d7b8c3886dcd3b277655a79dba142 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Wed, 24 Jun 2026 16:12:02 -0500 Subject: [PATCH 34/35] fix(convert): update stale to_osi/from_osi references in convert.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectDirection and validateOutput were written against the pre-rename field names (ToOSI/FromOSI, to_osi/from_osi). The direction string mismatch in validateOutput was a silent bug — output validation was never reached because "to_ossie" != "to_osi". --- cli/cmd/convert.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go index c61e284..d1dc157 100644 --- a/cli/cmd/convert.go +++ b/cli/cmd/convert.go @@ -151,20 +151,20 @@ func resolvePlugin(platformName, pluginFlag string, stderr io.Writer) (*plugin.P } } -// selectDirection returns the direction string ("to_osi" or "from_osi") and +// selectDirection returns the direction string ("to_ossie" or "from_ossie") and // the corresponding Direction based on whether --from or --to was specified. func selectDirection(p *plugin.Plugin, from string) (string, plugin.Direction) { if from != "" { - return "to_osi", p.Convert.ToOSI + return "to_ossie", p.Convert.ToOssie } - return "from_osi", p.Convert.FromOSI + return "from_ossie", p.Convert.FromOssie } -// validateOutput validates to_osi response files against the embedded OSI schema. +// validateOutput validates to_ossie response files against the embedded OSI schema. // Invalid files are removed from resp.Files in place and returned as // error-severity issues for the caller to append to resp.Issues. func validateOutput(resp *plugin.Response, direction string) []plugin.Issue { - if direction != "to_osi" { + if direction != "to_ossie" { return nil } var issues []plugin.Issue From 0eeeb3a4c3e16e14047ae62cef74d7e33fd8b932 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Wed, 24 Jun 2026 16:46:48 -0500 Subject: [PATCH 35/35] refactor(collect): extract directory walk into collectDir helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CollectFiles was doing two distinct things — single-file read and directory walk — in one flat function body. Extracting the walk into collectDir makes each path easier to follow and test independently. --- cli/internal/plugin/collect.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cli/internal/plugin/collect.go b/cli/internal/plugin/collect.go index a582503..2098481 100644 --- a/cli/internal/plugin/collect.go +++ b/cli/internal/plugin/collect.go @@ -43,10 +43,14 @@ func CollectFiles(input string, accepts []string, maxBytes int64) (map[string]st acceptSet[strings.ToLower(ext)] = struct{}{} } + return collectDir(input, acceptSet, maxBytes) +} + +func collectDir(root string, acceptSet map[string]struct{}, maxBytes int64) (map[string]string, error) { files := make(map[string]string) var total int64 - err = filepath.WalkDir(input, func(path string, d fs.DirEntry, werr error) error { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error { if werr != nil { return werr } @@ -73,7 +77,7 @@ func CollectFiles(input string, accepts []string, maxBytes int64) (map[string]st } } - rel, err := filepath.Rel(input, path) + rel, err := filepath.Rel(root, path) if err != nil { return err }