diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5088d4b..2e099f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: - name: Run Lint uses: golangci/golangci-lint-action@v9 with: - version: v2.1 + version: v2.1.6 test: name: Test diff --git a/.gitignore b/.gitignore index 66a384a..24d4dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store coverage.out bin/ +config/config.local.yml diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 0ae8f29..0000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -golang 1.24.0 diff --git a/CODEOWNERS b/CODEOWNERS index b617470..949d8f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* froppa +* @froppa diff --git a/Makefile b/Makefile index 236ff7d..3cc3fe4 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ help: ## Show this help message. # QUALITY & TESTING (CI Targets) # ==================================================================================== -all: tidy fmt vet lint test ## Run all quality checks and tests. +all: vet lint test ## Run all quality checks and tests. ci: all ## Alias for 'all', intended for CI environments. @@ -68,6 +68,6 @@ clean: ## Clean up build artifacts and coverage reports. tools: ## Install required development tools. @echo "==> Installing development tools..." - @go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 .PHONY: all lint update-deps test tidy fmt vet ci help cover-html clean tools diff --git a/README.md b/README.md index e203067..34e4341 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,16 @@ [![CI](https://github.com/froppa/stackkit/actions/workflows/test.yml/badge.svg)](https://github.com/froppa/stackkit/actions/workflows/test.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/froppa/stackkit.svg)](https://pkg.go.dev/github.com/froppa/stackkit) -[![Go Report Card](https://goreportcard.com/badge/github.com/froppa/stackkit)](https://goreportcard.com/report/github.com/froppa/stackkit) -Personal Go modules for wiring microservices with [Uber Fx](https://github.com/uber-go/fx). -Everything here is designed to be drop-in, explicit, and easy to reason about when building small services or tools. +Go packages I use to wire services with [Uber Fx](https://github.com/uber-go/fx): +configuration, logging, HTTP, health checks, telemetry, and shutdown. +The kits share a few conventions but can be used individually. + +Requires Go 1.24 or later. The API is still evolving. + +```sh +go get github.com/froppa/stackkit +``` ## Layout @@ -27,74 +33,51 @@ Everything here is designed to be drop-in, explicit, and easy to reason about wh ## Quick Start -```go -package main - -import ( - "context" - "net/http" - - "github.com/froppa/stackkit/kits/configkit" - "github.com/froppa/stackkit/kits/fxeventlog" - "github.com/froppa/stackkit/kits/healthkit" - "github.com/froppa/stackkit/kits/httpkit" - "github.com/froppa/stackkit/kits/logkit" - "github.com/froppa/stackkit/kits/shutdownkit" - "github.com/froppa/stackkit/kits/telemetry" - "go.uber.org/fx" - "go.uber.org/fx/fxevent" - "go.uber.org/zap" -) - -const embeddedConfig = ` -http: - addr: ":8080" -health: {} -telemetry: - disabled: true -` - -func main() { - app := fx.New( - fx.WithLogger(func(log *zap.Logger) fxevent.Logger { - return fxeventlog.NewMinimal(log) - }), - fx.Supply(context.Background()), - configkit.Module( - configkit.WithEmbeddedBytes([]byte(embeddedConfig)), - ), - logkit.Module(), - httpkit.Module(), - healthkit.MuxModule(), - telemetry.Module(), - shutdownkit.Module(), - fx.Provide(fx.Annotate( - func() httpkit.Handler { - return httpkit.Handler{ - Pattern: "/ping", - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("pong")) - }), - } - }, - fx.ResultTags(`group:"http.handlers"`), - )), - ) - - app.Run() -} +Run the [basic service](examples/service-basic/main.go) from a checkout: + +```sh +go run ./examples/service-basic ``` +It serves `/ping` and `/health` on port 8080, with telemetry export disabled. +In another terminal: + +```sh +curl http://localhost:8080/ping +curl http://localhost:8080/health +``` + +The example shows the complete Fx wiring, including grouped HTTP handlers. +Use Ctrl-C to stop the service. + Configuration lives under `./config` by default. 1. Embed defaults via `configkit.WithEmbeddedBytes`. 2. Override with `config/config.yml`, `config/config.local.yml`, and `config/.yml` (uses `runtimeinfo.Name`). 3. Expand `${VAR:default}` placeholders at load time. -CLI helpers: +The CLI uses a separate precedence: `config/config.yml`, then `$CONFIG`, then +`--config`. It registers HTTP and telemetry configuration types. + +CLI helpers (from a checkout): + +- `go run ./cmd/stackctl config check --all --config=examples/service-basic/config.yml` +- `go run ./cmd/stackctl config discovery --from-yaml=examples/service-basic/config.yml` +- `go run ./cmd/stackctl config list --key=http --config=examples/service-basic/config.yml` + +## Development + +With [mise](https://mise.jdx.dev/) installed: + +```sh +mise install +mise exec -- make all +``` + +`make all` runs vet, lint, and race-enabled tests. `make fmt` and `make tidy` +are explicit maintenance commands. `make tools` installs the pinned linter +if you manage Go yourself. -- `go run github.com/froppa/stackkit/cmd/stackctl config check --all` -- `go run github.com/froppa/stackkit/cmd/stackctl config discovery --from-yaml=./config/config.yml` -- `go run github.com/froppa/stackkit/cmd/stackctl config list --key=http --config=./config/config.yml` +## License -Bring your own Fx modules around these pieces; everything here is intentionally small and composable. +[MIT](LICENSE). diff --git a/STRUKTLY_README.md b/STRUKTLY_README.md deleted file mode 100644 index 750509f..0000000 --- a/STRUKTLY_README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Struktly - -**Struktly** is a governed, fail-first, deterministic code generation system. - -It operates on the principle that **no code is better than bad code**. - -## Core Principles - -1. **Fail-First**: If the generated code does not compile, pass tests, or satisfy policy, it is **discarded**. There are no silent retries. -2. **Determinism**: The same inputs (Spec + Policy) always produce the same scaffolding. AI is only used for bounded logic synthesis within strict templates. -3. **Auditability**: Every run produces an audit trail (`audit.json`, `policy.json`, `verification.json`). If verification fails, you get an audit log explaining why, but no code. -4. **No Hallucinated Intent**: If the system is unsure (simulated by "ambiguous" input), it returns `NEEDS_HUMAN_REVIEW` rather than guessing. - -## Architecture - -- **CLI**: The entry point. -- **Orchestrator**: Manages the pipeline (Input -> Policy -> Generate -> Synthesize -> Verify -> Output). -- **Generator**: Uses `text/template` to create deterministic `main.go` and handler skeletons. -- **Synthesizer**: Fills in handler logic and tests. In a real system, this calls an LLM. Here, it is mocked to demonstrate success/failure modes. -- **Gates**: Runs `go fmt`, `go vet`, and `go test` inside a sandbox (temp directory). -- **Policy Engine**: Enforces constraints (e.g., max handlers). - -## Usage - -Build the tool: -```bash -go build -o struktly ./cmd/struktly -``` - -Run a generation: -```bash -./struktly --spec examples/struktly/petstore.yaml --policy examples/struktly/policy.yaml --out output_dir -``` - -## Failure Scenarios (Demo) - -The included `demo_struktly.sh` script demonstrates three scenarios: - -1. **Success**: Valid inputs produce a working Go service with passing tests. -2. **Failure**: Inputs that trigger "bad code" generation (simulated) result in **no output code**, only audit logs. - - *Why?* Because `go vet` or `go test` failed. -3. **Ambiguity**: Inputs that trigger uncertainty result in `NEEDS_HUMAN_REVIEW`. - - *Why?* The system refuses to guess business logic. - -## What is NOT Supported - -- **Retries**: If it fails, fix the input or the prompt. Do not loop until it "looks right". -- **AI Architecture**: The AI cannot add new files, change the router, or pick dependencies. It only fills the function bodies it is assigned. -- **Framework Magic**: The generated code is standard library Go. diff --git a/cmd/struktly/main.go b/cmd/struktly/main.go deleted file mode 100644 index 941c488..0000000 --- a/cmd/struktly/main.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/froppa/stackkit/internal/struktly/orchestrator" - "github.com/spf13/cobra" -) - -func main() { - var specPath string - var policyPath string - var outDir string - - var rootCmd = &cobra.Command{ - Use: "struktly", - Short: "Struktly Code Generation", - Run: func(cmd *cobra.Command, args []string) { - if err := orchestrator.Run(specPath, policyPath, outDir); err != nil { - // We don't necessarily exit 1 if it's a "controlled failure" (e.g. policy violation), - // but generally if the tool fails to produce the expected output, exit !0 is good for scripts. - // However, the prompt says "OR a failure report". If we produce a failure report, maybe we exit 0? - // "FAIL-FIRST IS A FEATURE... DISCARD IT". - // "If verification fails, the run fails regardless of AI confidence" - // I will print the error and exit 1 so CI pipelines know it failed. - fmt.Fprintf(os.Stderr, "Struktly Run Failed: %v\n", err) - os.Exit(1) - } - fmt.Println("Struktly Run Successful.") - }, - } - - rootCmd.Flags().StringVar(&specPath, "spec", "", "Path to OpenAPI spec") - rootCmd.Flags().StringVar(&policyPath, "policy", "", "Path to Policy file") - rootCmd.Flags().StringVar(&outDir, "out", "output", "Output directory") - _ = rootCmd.MarkFlagRequired("spec") - _ = rootCmd.MarkFlagRequired("policy") - - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } -} diff --git a/demo_struktly.sh b/demo_struktly.sh deleted file mode 100644 index df20e8e..0000000 --- a/demo_struktly.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -set -e - -# Build -echo "Building struktly..." -go build -o struktly ./cmd/struktly - -echo ">>> RUN 1: SUCCESS CASE" -rm -rf out_success -./struktly --spec examples/struktly/petstore.yaml --policy examples/struktly/policy.yaml --out out_success -ls -l out_success -# Check for code -if [ ! -f out_success/main.go ]; then echo "FAIL: main.go missing"; exit 1; fi -if [ ! -f out_success/audit.json ]; then echo "FAIL: audit.json missing"; exit 1; fi - -echo ">>> RUN 2: FAILURE CASE (Bad Code)" -rm -rf out_fail -set +e -./struktly --spec examples/struktly/petstore_fail.yaml --policy examples/struktly/policy.yaml --out out_fail -EXIT_CODE=$? -set -e -if [ $EXIT_CODE -eq 0 ]; then echo "FAIL: Should have failed"; exit 1; fi -# Check NO code -if [ -f out_fail/main.go ]; then echo "FAIL: main.go should NOT exist"; exit 1; fi -# Check Audit exists -if [ ! -f out_fail/audit.json ]; then echo "FAIL: audit.json missing"; exit 1; fi -grep "FAIL" out_fail/audit.json > /dev/null -echo "Run 2 Verified Correctly" - -echo ">>> RUN 3: AMBIGUOUS CASE" -rm -rf out_ambiguous -set +e -./struktly --spec examples/struktly/petstore_ambiguous.yaml --policy examples/struktly/policy.yaml --out out_ambiguous -EXIT_CODE=$? -set -e -if [ $EXIT_CODE -eq 0 ]; then echo "FAIL: Should have failed"; exit 1; fi -grep "NEEDS_HUMAN_REVIEW" out_ambiguous/audit.json > /dev/null -echo "Run 3 Verified Correctly" - -echo "ALL DEMOS PASSED" diff --git a/examples/service-basic/config.yml b/examples/service-basic/config.yml new file mode 100644 index 0000000..db8e4fd --- /dev/null +++ b/examples/service-basic/config.yml @@ -0,0 +1,5 @@ +http: + addr: ":8080" +health: {} +telemetry: + disabled: true diff --git a/examples/service-basic/main.go b/examples/service-basic/main.go index 5656e17..14f6d7e 100644 --- a/examples/service-basic/main.go +++ b/examples/service-basic/main.go @@ -2,6 +2,7 @@ package main import ( "context" + _ "embed" "net/http" "github.com/froppa/stackkit/kits/configkit" @@ -16,22 +17,17 @@ import ( "go.uber.org/zap" ) -const embeddedConfig = ` -http: - addr: ":8080" -health: {} -telemetry: - disabled: true -` +//go:embed config.yml +var embeddedConfig []byte func main() { app := fx.New( fx.WithLogger(func(log *zap.Logger) fxevent.Logger { return fxeventlog.NewMinimal(log) }), - fx.Supply(context.Background()), + fx.Provide(context.Background), configkit.Module( - configkit.WithEmbeddedBytes([]byte(embeddedConfig)), + configkit.WithEmbeddedBytes(embeddedConfig), ), logkit.Module(), httpkit.Module(), diff --git a/examples/struktly/petstore.yaml b/examples/struktly/petstore.yaml deleted file mode 100644 index b890ab3..0000000 --- a/examples/struktly/petstore.yaml +++ /dev/null @@ -1,9 +0,0 @@ -openapi: 3.0.0 -info: - title: Petstore Service - version: 1.0.0 -paths: - /pets: - get: - operationId: ListPets - summary: List all pets diff --git a/examples/struktly/petstore_ambiguous.yaml b/examples/struktly/petstore_ambiguous.yaml deleted file mode 100644 index f1f4a9e..0000000 --- a/examples/struktly/petstore_ambiguous.yaml +++ /dev/null @@ -1,9 +0,0 @@ -openapi: 3.0.0 -info: - title: Petstore ambiguous Service - version: 1.0.0 -paths: - /pets: - get: - operationId: ListPets - summary: List all pets diff --git a/examples/struktly/petstore_fail.yaml b/examples/struktly/petstore_fail.yaml deleted file mode 100644 index 71efa41..0000000 --- a/examples/struktly/petstore_fail.yaml +++ /dev/null @@ -1,9 +0,0 @@ -openapi: 3.0.0 -info: - title: Petstore fail Service - version: 1.0.0 -paths: - /pets: - get: - operationId: ListPets - summary: List all pets diff --git a/examples/struktly/policy.yaml b/examples/struktly/policy.yaml deleted file mode 100644 index 28db4c5..0000000 --- a/examples/struktly/policy.yaml +++ /dev/null @@ -1,3 +0,0 @@ -max_handlers: 5 -required: - - tests diff --git a/internal/struktly/audit/types.go b/internal/struktly/audit/types.go deleted file mode 100644 index 4163cad..0000000 --- a/internal/struktly/audit/types.go +++ /dev/null @@ -1,34 +0,0 @@ -package audit - -import ( - "time" -) - -type AuditLog struct { - Timestamp time.Time `json:"timestamp"` - Inputs InputHashes `json:"inputs"` - Policy PolicyResult `json:"policy"` - Generation GenerationResult `json:"generation"` - Verification VerificationResult `json:"verification"` - Outcome string `json:"outcome"` // SUCCESS, FAIL, REVIEW -} - -type InputHashes struct { - SpecHash string `json:"spec_hash"` - PolicyHash string `json:"policy_hash"` -} - -type PolicyResult struct { - Passed bool `json:"passed"` - Reason string `json:"reason,omitempty"` -} - -type GenerationResult struct { - Prompts []string `json:"prompts"` - Model string `json:"model"` -} - -type VerificationResult struct { - Passed bool `json:"passed"` - Output string `json:"output,omitempty"` -} diff --git a/internal/struktly/gates/runner.go b/internal/struktly/gates/runner.go deleted file mode 100644 index 06e3112..0000000 --- a/internal/struktly/gates/runner.go +++ /dev/null @@ -1,63 +0,0 @@ -package gates - -import ( - "bytes" - "fmt" - "os/exec" -) - -type Runner struct{} - -func New() *Runner { - return &Runner{} -} - -// Verify runs go mod init, go fmt, go vet, and go test. -// Returns the combined output and an error if any step fails. -func (r *Runner) Verify(dir string) (string, error) { - var combinedOutput bytes.Buffer - - // Helper to run and log - run := func(name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Dir = dir - var out bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &stderr - - combinedOutput.WriteString(fmt.Sprintf("Running: %s %v\n", name, args)) - err := cmd.Run() - - combinedOutput.Write(out.Bytes()) - combinedOutput.Write(stderr.Bytes()) - combinedOutput.WriteString("\n") - - if err != nil { - return fmt.Errorf("%s failed: %w", name, err) - } - return nil - } - - // 1. Init Module - if err := run("go", "mod", "init", "generated"); err != nil { - return combinedOutput.String(), err - } - - // 2. Fmt - if err := run("go", "fmt", "./..."); err != nil { - return combinedOutput.String(), err - } - - // 3. Vet - if err := run("go", "vet", "./..."); err != nil { - return combinedOutput.String(), err - } - - // 4. Test - if err := run("go", "test", "./..."); err != nil { - return combinedOutput.String(), err - } - - return combinedOutput.String(), nil -} diff --git a/internal/struktly/generator/renderer.go b/internal/struktly/generator/renderer.go deleted file mode 100644 index 180a0b8..0000000 --- a/internal/struktly/generator/renderer.go +++ /dev/null @@ -1,48 +0,0 @@ -package generator - -import ( - "bytes" - "fmt" - "text/template" - - "github.com/froppa/stackkit/internal/struktly/ir" -) - -func Render(svc *ir.Service) (map[string]string, error) { - files := make(map[string]string) - - // Render Main - mainContent, err := renderTemplate("main", MainTemplate, svc) - if err != nil { - return nil, err - } - files["main.go"] = mainContent - - // Render Handlers - handlersContent, err := renderTemplate("handlers", HandlersTemplate, svc) - if err != nil { - return nil, err - } - files["handlers.go"] = handlersContent - - // Render Tests - testContent, err := renderTemplate("main_test", TestTemplate, svc) - if err != nil { - return nil, err - } - files["main_test.go"] = testContent - - return files, nil -} - -func renderTemplate(name, tmplStr string, data interface{}) (string, error) { - tmpl, err := template.New(name).Parse(tmplStr) - if err != nil { - return "", fmt.Errorf("failed to parse template %s: %w", name, err) - } - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return "", fmt.Errorf("failed to execute template %s: %w", name, err) - } - return buf.String(), nil -} diff --git a/internal/struktly/generator/templates.go b/internal/struktly/generator/templates.go deleted file mode 100644 index 0c2313e..0000000 --- a/internal/struktly/generator/templates.go +++ /dev/null @@ -1,51 +0,0 @@ -package generator - -const MainTemplate = `package main - -import ( - "log" - "net/http" -) - -func main() { - mux := http.NewServeMux() -{{- range .Handlers }} - mux.HandleFunc("{{ .Path }}", {{ .Name }}) -{{- end }} - - log.Println("Starting {{ .Name }} on :8080") - if err := http.ListenAndServe(":8080", mux); err != nil { - log.Fatal(err) - } -} -` - -const HandlersTemplate = `package main - -import ( - "net/http" -) -{{ range .Handlers }} -// {{ .Name }} handles {{ .Method }} {{ .Path }} -// {{ .Description }} -func {{ .Name }}(w http.ResponseWriter, r *http.Request) { - // AI_LOGIC_START: {{ .Name }} - // AI_LOGIC_END: {{ .Name }} -} -{{ end }} -` - -const TestTemplate = `package main - -import ( - "net/http" - "net/http/httptest" - "testing" -) -{{ range .Handlers }} -func Test{{ .Name }}(t *testing.T) { - // AI_TEST_START: {{ .Name }} - // AI_TEST_END: {{ .Name }} -} -{{ end }} -` diff --git a/internal/struktly/ir/parser.go b/internal/struktly/ir/parser.go deleted file mode 100644 index fc47875..0000000 --- a/internal/struktly/ir/parser.go +++ /dev/null @@ -1,77 +0,0 @@ -package ir - -import ( - "fmt" - "os" - "strings" - "unicode" - - "gopkg.in/yaml.v3" -) - -// Simplified structures for parsing YAML -type openApiSpec struct { - Info struct { - Title string `yaml:"title"` - } `yaml:"info"` - Paths map[string]map[string]struct { - Summary string `yaml:"summary"` - OperationID string `yaml:"operationId"` - } `yaml:"paths"` -} - -func Parse(filepath string) (*Service, error) { - data, err := os.ReadFile(filepath) - if err != nil { - return nil, fmt.Errorf("failed to read spec: %w", err) - } - - var spec openApiSpec - if err := yaml.Unmarshal(data, &spec); err != nil { - return nil, fmt.Errorf("failed to parse yaml: %w", err) - } - - svc := &Service{ - Name: spec.Info.Title, - } - - // Normalize name - svc.Name = strings.ReplaceAll(svc.Name, " ", "") - - for path, methods := range spec.Paths { - for method, op := range methods { - h := Handler{ - Name: op.OperationID, - Method: strings.ToUpper(method), - Path: path, - Description: op.Summary, - } - if h.Name == "" { - // Fallback name if operationId is missing - cleanPath := strings.ReplaceAll(path, "/", "_") - cleanPath = strings.Trim(cleanPath, "_") - h.Name = fmt.Sprintf("%s_%s", h.Method, cleanPath) - } - h.Name = sanitize(h.Name) - svc.Handlers = append(svc.Handlers, h) - } - } - - return svc, nil -} - -func sanitize(s string) string { - // Simple camel casing for identifiers - // e.g. list-pets -> ListPets - parts := strings.FieldsFunc(s, func(r rune) bool { - return !unicode.IsLetter(r) && !unicode.IsNumber(r) - }) - for i, p := range parts { - if len(p) > 0 { - r := []rune(p) - r[0] = unicode.ToUpper(r[0]) - parts[i] = string(r) - } - } - return strings.Join(parts, "") -} diff --git a/internal/struktly/ir/types.go b/internal/struktly/ir/types.go deleted file mode 100644 index c88d490..0000000 --- a/internal/struktly/ir/types.go +++ /dev/null @@ -1,13 +0,0 @@ -package ir - -type Service struct { - Name string - Handlers []Handler -} - -type Handler struct { - Name string - Method string - Path string - Description string -} diff --git a/internal/struktly/orchestrator/run.go b/internal/struktly/orchestrator/run.go deleted file mode 100644 index 2cc948b..0000000 --- a/internal/struktly/orchestrator/run.go +++ /dev/null @@ -1,141 +0,0 @@ -package orchestrator - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" - - "github.com/froppa/stackkit/internal/struktly/audit" - "github.com/froppa/stackkit/internal/struktly/gates" - "github.com/froppa/stackkit/internal/struktly/generator" - "github.com/froppa/stackkit/internal/struktly/ir" - "github.com/froppa/stackkit/internal/struktly/policy" - "github.com/froppa/stackkit/internal/struktly/synthesizer" -) - -func Run(specPath, policyPath, outDir string) error { - log := audit.AuditLog{ - Timestamp: time.Now(), - Outcome: "FAIL", - } - - // Ensure outDir exists - if err := os.MkdirAll(outDir, 0755); err != nil { - return fmt.Errorf("failed to create output dir: %w", err) - } - - defer func() { - // Write Audit Artifacts - writeJSON(outDir, "audit.json", log) - writeJSON(outDir, "policy.json", log.Policy) - writeJSON(outDir, "verification.json", log.Verification) - writeJSON(outDir, "generation.json", log.Generation) - }() - - // 1. Hash Inputs - specBytes, err := os.ReadFile(specPath) - if err != nil { - return fmt.Errorf("read spec failed: %w", err) - } - policyBytes, err := os.ReadFile(policyPath) - if err != nil { - return fmt.Errorf("read policy failed: %w", err) - } - log.Inputs.SpecHash = hash(specBytes) - log.Inputs.PolicyHash = hash(policyBytes) - - // 2. Parse & Policy - svc, err := ir.Parse(specPath) - if err != nil { - log.Policy.Reason = "Parse Error: " + err.Error() - return err - } - - pol, err := policy.Load(policyPath) - if err != nil { - log.Policy.Reason = "Policy Load Error: " + err.Error() - return err - } - - if err := policy.Check(pol, svc); err != nil { - log.Policy.Passed = false - log.Policy.Reason = err.Error() - return err - } - log.Policy.Passed = true - - // 3. Generate Skeleton - files, err := generator.Render(svc) - if err != nil { - return fmt.Errorf("generation failed: %w", err) - } - - // 4. Synthesize - synth := synthesizer.New() - for name, content := range files { - newContent, err := synth.FillBlocks(name, content, svc.Name) // Use svc.Name as intent - if err != nil { - if err == synthesizer.ErrNeedsHumanReview { - log.Outcome = "NEEDS_HUMAN_REVIEW" - return err - } - return fmt.Errorf("synthesis failed: %w", err) - } - files[name] = newContent - } - log.Generation.Model = "mock-v1" - - // 5. Write Temp - tmpDir, err := os.MkdirTemp("", "struktly-*") - if err != nil { - return fmt.Errorf("temp dir failed: %w", err) - } - defer func() { _ = os.RemoveAll(tmpDir) }() - - for name, content := range files { - if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0644); err != nil { - return fmt.Errorf("write temp file failed: %w", err) - } - } - - // 6. Verify - gateRunner := gates.New() - output, err := gateRunner.Verify(tmpDir) - log.Verification.Output = output - if err != nil { - log.Verification.Passed = false - log.Outcome = "FAIL" - return fmt.Errorf("verification failed: %w", err) - } - log.Verification.Passed = true - log.Outcome = "SUCCESS" - - // 7. Copy to Output - for name, content := range files { - if err := os.WriteFile(filepath.Join(outDir, name), []byte(content), 0644); err != nil { - return fmt.Errorf("write output file failed: %w", err) - } - } - - return nil -} - -func hash(b []byte) string { - h := sha256.Sum256(b) - return hex.EncodeToString(h[:]) -} - -func writeJSON(dir, filename string, v interface{}) { - f, err := os.Create(filepath.Join(dir, filename)) - if err != nil { - return - } - defer func() { _ = f.Close() }() - enc := json.NewEncoder(f) - enc.SetIndent("", " ") - _ = enc.Encode(v) -} diff --git a/internal/struktly/policy/checker.go b/internal/struktly/policy/checker.go deleted file mode 100644 index fb488e9..0000000 --- a/internal/struktly/policy/checker.go +++ /dev/null @@ -1,33 +0,0 @@ -package policy - -import ( - "fmt" - "os" - - "github.com/froppa/stackkit/internal/struktly/ir" - "gopkg.in/yaml.v3" -) - -type Policy struct { - MaxHandlers int `yaml:"max_handlers"` - Required []string `yaml:"required"` // e.g. "tests" -} - -func Load(path string) (*Policy, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var p Policy - if err := yaml.Unmarshal(data, &p); err != nil { - return nil, err - } - return &p, nil -} - -func Check(p *Policy, svc *ir.Service) error { - if p.MaxHandlers > 0 && len(svc.Handlers) > p.MaxHandlers { - return fmt.Errorf("policy violation: too many handlers (%d > %d)", len(svc.Handlers), p.MaxHandlers) - } - return nil -} diff --git a/internal/struktly/synthesizer/ai.go b/internal/struktly/synthesizer/ai.go deleted file mode 100644 index 95a3ffd..0000000 --- a/internal/struktly/synthesizer/ai.go +++ /dev/null @@ -1,69 +0,0 @@ -package synthesizer - -import ( - "errors" - "fmt" - "regexp" - "strings" -) - -var ( - ErrNeedsHumanReview = errors.New("NEEDS_HUMAN_REVIEW") -) - -type Synthesizer struct{} - -func New() *Synthesizer { - return &Synthesizer{} -} - -// FillBlocks replaces AI markers with code based on the intent (service name for mocking). -func (s *Synthesizer) FillBlocks(filename, content, intent string) (string, error) { - if strings.Contains(intent, "ambiguous") { - return "", ErrNeedsHumanReview - } - - shouldFail := strings.Contains(intent, "fail") - - // Regex for logic blocks - // // AI_LOGIC_START: Name - // ... - // // AI_LOGIC_END: Name - reLogic := regexp.MustCompile(`(?s)// AI_LOGIC_START: (\w+).*?// AI_LOGIC_END: \w+`) - - content = reLogic.ReplaceAllStringFunc(content, func(match string) string { - submatches := reLogic.FindStringSubmatch(match) - if len(submatches) < 2 { - return match - } - funcName := submatches[1] - - if shouldFail { - return fmt.Sprintf(`w.Write("Syntax Error Here %s)`, funcName) // Missing quote - } - return fmt.Sprintf(`w.WriteHeader(http.StatusOK) - w.Write([]byte("Hello from %s"))`, funcName) - }) - - // Regex for test blocks - reTest := regexp.MustCompile(`(?s)// AI_TEST_START: (\w+).*?// AI_TEST_END: \w+`) - content = reTest.ReplaceAllStringFunc(content, func(match string) string { - submatches := reTest.FindStringSubmatch(match) - if len(submatches) < 2 { - return match - } - funcName := submatches[1] - - if shouldFail { - return `if true { t.Fail() } syntax error` - } - return fmt.Sprintf(`req := httptest.NewRequest("GET", "http://example.com/foo", nil) - w := httptest.NewRecorder() - %s(w, req) - if w.Result().StatusCode != http.StatusOK { - t.Errorf("Expected 200") - }`, funcName) - }) - - return content, nil -} diff --git a/kits/configkit/README.md b/kits/configkit/README.md index f3e7df3..7cf5aa2 100644 --- a/kits/configkit/README.md +++ b/kits/configkit/README.md @@ -33,14 +33,6 @@ For tooling and one-off inspection, `configkit.NewYAML` provides a minimal loade The CLI loader always applies environment expansion and never logs secrets. Use `configkit.Redact(key, value)` to render a redacted view for display. -On Fx boot via `configfx.Module`, a single line is emitted: - -``` -msg="config.loaded" env="$ENV" service="$runtimeinfo.Name" version="$runtimeinfo.Version" -``` - -Only these fields are logged; configuration values are never included. - --- ## Usage @@ -161,5 +153,5 @@ go run github.com/froppa/stackkit/cmd/stackctl config check --all ``` Notes: -- The CLI registers modules you pass via `--with`. +- The CLI registers the HTTP and telemetry configuration types. There is no `--with` flag. - Field specs use `yaml` tags primarily and fall back to `json`. Required is inferred from `validate:"required"`. diff --git a/kits/healthkit/README.md b/kits/healthkit/README.md index abd1b1d..d0fe05b 100644 --- a/kits/healthkit/README.md +++ b/kits/healthkit/README.md @@ -33,6 +33,8 @@ Dedicated server: ```go app := fx.New( + configkit.Module(), + logkit.Module(), healthkit.ServerModule(), ) ``` @@ -41,6 +43,8 @@ Mux attachment: ```go app := fx.New( + configkit.Module(), + logkit.Module(), httpkit.Module(), healthkit.MuxModule(), ) diff --git a/kits/httpkit/README.md b/kits/httpkit/README.md index 139fcf3..f35539e 100644 --- a/kits/httpkit/README.md +++ b/kits/httpkit/README.md @@ -24,18 +24,20 @@ http: ## Usage +See the [runnable service example](../../examples/service-basic/main.go) for +configuration and logging setup. Register routes as grouped `httpkit.Handler` +values: + ```go -app := fx.New( - httpkit.Module(), - fx.Provide(func() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("pong")) - }) - }), - fx.Provide(func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.ServeHTTP(w, r) - }) - }), -) +fx.Provide(fx.Annotate( + func() httpkit.Handler { + return httpkit.Handler{ + Pattern: "/ping", + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("pong")) + }), + } + }, + fx.ResultTags(`group:"http.handlers"`), +)), ``` diff --git a/kits/logkit/README.md b/kits/logkit/README.md index 87fde38..e68db19 100644 --- a/kits/logkit/README.md +++ b/kits/logkit/README.md @@ -12,13 +12,15 @@ Zap logger integration for Fx applications. ## Config -```yaml -logger: - encoding: production # or development/json - level: info # debug|info|warn|error +`Module()` defaults to production JSON at info level. It does not load YAML. +Override its configuration with `fx.Replace`: + +```go +logkit.Module(), +fx.Replace(logkit.Config{Encoding: "development", Level: "debug"}), ``` -Validation enforces that `encoding` is one of `production|prod|json|development|dev|console` and that `level` resolves to a valid Zap level. Startup fails if the values are out of range. +`New` rejects unknown encodings and invalid Zap levels. ## Usage diff --git a/kits/shutdownkit/README.md b/kits/shutdownkit/README.md index b668cd3..1cbf42f 100644 --- a/kits/shutdownkit/README.md +++ b/kits/shutdownkit/README.md @@ -21,6 +21,7 @@ type deps struct { } app := fx.New( + logkit.Module(), shutdownkit.Module(shutdownkit.WithTimeout(5*time.Second)), fx.Invoke(func(d deps) { shutdownkit.Go(d.WG, func() { @@ -37,4 +38,6 @@ app := fx.New( app.Run() ``` -Use `shutdownkit.Module()` together with `signals.NewWithSignals` if you need both Fx lifecycle coordination and OS signal triggering in the same process. +`app.Run()` handles OS signals. Use `signals.NewWithSignals` for standalone +programs without Fx. The timeout cancels the force context; shutdown still +waits for workers to return. diff --git a/kits/signals/README.md b/kits/signals/README.md index d450bac..2770908 100644 --- a/kits/signals/README.md +++ b/kits/signals/README.md @@ -7,7 +7,7 @@ Graceful and forced shutdown orchestration. - Handles `SIGINT` and `SIGTERM` for CLI tools or services. - Provides two contexts: - **Graceful**: cancelled on first signal. - - **Force**: cancelled after timeout or second signal. + - **Force**: cancelled when the timeout in `Wait` elapses. - WaitGroup support for in-flight goroutines. ## Usage @@ -16,11 +16,14 @@ Graceful and forced shutdown orchestration. wg := &sync.WaitGroup{} s := signals.NewWithSignals(context.Background(), wg) +wg.Add(1) go func() { - wg.Add(1) defer wg.Done() <-s.Graceful().Done() // cleanup }() s.Wait(10 * time.Second) // blocks until done or force timeout ``` + +`Wait` still waits for workers to finish after cancelling the force context. +Workers must observe cancellation and return; the timeout does not terminate them. diff --git a/kits/telemetry/README.md b/kits/telemetry/README.md index 1b96f60..9210f19 100644 --- a/kits/telemetry/README.md +++ b/kits/telemetry/README.md @@ -10,8 +10,8 @@ Pragmatic OpenTelemetry wiring for Fx applications. - **Automatic Configuration**: Loads settings from a YAML file key (default `"telemetry"`). - **Standard Resource Identity**: Populates resources with service name, version, and environment using semantic conventions. -- **Safe Disabled Mode**: Honors `sdk.disabled` with true noop providers that preserve - resource data and sampling semantics. +- **Safe Disabled Mode**: Honors `telemetry.disabled` or `OTEL_SDK_DISABLED` by disabling + sampling and export while retaining SDK providers. - **Export Readiness**: Warns when tracing or metrics are enabled without an OTLP endpoint so misconfigurations are visible at startup. - **OTLP Exporters**: Automatically enables OTLP/gRPC trace and metric exporters @@ -28,6 +28,8 @@ updates the global OTEL providers. ```go fx.New( + fx.Provide(context.Background), + logkit.Module(), configkit.Module(), // Your application's config module telemetry.Module(), // This OpenTelemetry module (installs providers globally) fx.Invoke(func(tracer trace.Tracer, meter metric.Meter) { @@ -64,8 +66,9 @@ telemetry: resource_attributes: team: "backend" -# Opting out +``` + +## Opting out Applications that should not install the OTEL module can omit `telemetry.Module()` or provide an alternative module in their Fx wiring (for example in `app/fx.go`). -``` diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..b69e5a5 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +go = "1.24.0" +golangci-lint = "2.1.6" diff --git a/out_ambiguous/audit.json b/out_ambiguous/audit.json deleted file mode 100644 index c84dce8..0000000 --- a/out_ambiguous/audit.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "timestamp": "2026-01-31T01:10:56.342749306Z", - "inputs": { - "spec_hash": "87260417d96bfca455a0cc5f2394f0c8ec85f0ee05c3ba0b0f1b3511d9a2e832", - "policy_hash": "19a244d8372b039bf2863fc83ec5779c6618b44c39e3f1d14b66531d77ad6d8b" - }, - "policy": { - "passed": true - }, - "generation": { - "prompts": null, - "model": "" - }, - "verification": { - "passed": false - }, - "outcome": "NEEDS_HUMAN_REVIEW" -} diff --git a/out_ambiguous/generation.json b/out_ambiguous/generation.json deleted file mode 100644 index 665882a..0000000 --- a/out_ambiguous/generation.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "prompts": null, - "model": "" -} diff --git a/out_ambiguous/policy.json b/out_ambiguous/policy.json deleted file mode 100644 index fcaa704..0000000 --- a/out_ambiguous/policy.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "passed": true -} diff --git a/out_ambiguous/verification.json b/out_ambiguous/verification.json deleted file mode 100644 index e41511f..0000000 --- a/out_ambiguous/verification.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "passed": false -} diff --git a/out_fail/audit.json b/out_fail/audit.json deleted file mode 100644 index 9d6e77d..0000000 --- a/out_fail/audit.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "timestamp": "2026-01-31T01:10:56.212087789Z", - "inputs": { - "spec_hash": "ccc51c1f8c98ec702620412f62425c72081530a183565f72ca1f191f9b1f35d4", - "policy_hash": "19a244d8372b039bf2863fc83ec5779c6618b44c39e3f1d14b66531d77ad6d8b" - }, - "policy": { - "passed": true - }, - "generation": { - "prompts": null, - "model": "mock-v1" - }, - "verification": { - "passed": false, - "output": "Running: go [mod init generated]\ngo: creating new go.mod: module generated\ngo: to add module requirements and sums:\n\tgo mod tidy\n\nRunning: go [fmt ./...]\nhandlers.go:10:10: string literal not terminated\nhandlers.go:11:1: expected operand, found '}'\nhandlers.go:12:2: missing ',' in argument list\nmain_test.go:10:23: expected ';', found syntax\nmain_test.go:12:2: expected '}', found 'EOF'\nexit status 2\n\n" - }, - "outcome": "FAIL" -} diff --git a/out_fail/generation.json b/out_fail/generation.json deleted file mode 100644 index 4c54c16..0000000 --- a/out_fail/generation.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "prompts": null, - "model": "mock-v1" -} diff --git a/out_fail/policy.json b/out_fail/policy.json deleted file mode 100644 index fcaa704..0000000 --- a/out_fail/policy.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "passed": true -} diff --git a/out_fail/verification.json b/out_fail/verification.json deleted file mode 100644 index 70a0d0a..0000000 --- a/out_fail/verification.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "passed": false, - "output": "Running: go [mod init generated]\ngo: creating new go.mod: module generated\ngo: to add module requirements and sums:\n\tgo mod tidy\n\nRunning: go [fmt ./...]\nhandlers.go:10:10: string literal not terminated\nhandlers.go:11:1: expected operand, found '}'\nhandlers.go:12:2: missing ',' in argument list\nmain_test.go:10:23: expected ';', found syntax\nmain_test.go:12:2: expected '}', found 'EOF'\nexit status 2\n\n" -} diff --git a/out_success/audit.json b/out_success/audit.json deleted file mode 100644 index ae8091a..0000000 --- a/out_success/audit.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "timestamp": "2026-01-31T01:10:53.360675527Z", - "inputs": { - "spec_hash": "a7b6b4b36058b50ccf3484ca72ce54307244dda9c89e6bfde390aedabf256b1e", - "policy_hash": "19a244d8372b039bf2863fc83ec5779c6618b44c39e3f1d14b66531d77ad6d8b" - }, - "policy": { - "passed": true - }, - "generation": { - "prompts": null, - "model": "mock-v1" - }, - "verification": { - "passed": true, - "output": "Running: go [mod init generated]\ngo: creating new go.mod: module generated\ngo: to add module requirements and sums:\n\tgo mod tidy\n\nRunning: go [fmt ./...]\nhandlers.go\nmain_test.go\n\nRunning: go [vet ./...]\n\nRunning: go [test ./...]\nok \tgenerated\t0.029s\n\n" - }, - "outcome": "SUCCESS" -} diff --git a/out_success/generation.json b/out_success/generation.json deleted file mode 100644 index 4c54c16..0000000 --- a/out_success/generation.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "prompts": null, - "model": "mock-v1" -} diff --git a/out_success/handlers.go b/out_success/handlers.go deleted file mode 100644 index 17fdbe0..0000000 --- a/out_success/handlers.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "net/http" -) - -// ListPets handles GET /pets -// List all pets -func ListPets(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("Hello from ListPets")) -} diff --git a/out_success/main.go b/out_success/main.go deleted file mode 100644 index 979491c..0000000 --- a/out_success/main.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "log" - "net/http" -) - -func main() { - mux := http.NewServeMux() - mux.HandleFunc("/pets", ListPets) - - log.Println("Starting PetstoreService on :8080") - if err := http.ListenAndServe(":8080", mux); err != nil { - log.Fatal(err) - } -} diff --git a/out_success/main_test.go b/out_success/main_test.go deleted file mode 100644 index cea5102..0000000 --- a/out_success/main_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func TestListPets(t *testing.T) { - req := httptest.NewRequest("GET", "http://example.com/foo", nil) - w := httptest.NewRecorder() - ListPets(w, req) - if w.Result().StatusCode != http.StatusOK { - t.Errorf("Expected 200") - } -} diff --git a/out_success/policy.json b/out_success/policy.json deleted file mode 100644 index fcaa704..0000000 --- a/out_success/policy.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "passed": true -} diff --git a/out_success/verification.json b/out_success/verification.json deleted file mode 100644 index efc3d97..0000000 --- a/out_success/verification.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "passed": true, - "output": "Running: go [mod init generated]\ngo: creating new go.mod: module generated\ngo: to add module requirements and sums:\n\tgo mod tidy\n\nRunning: go [fmt ./...]\nhandlers.go\nmain_test.go\n\nRunning: go [vet ./...]\n\nRunning: go [test ./...]\nok \tgenerated\t0.029s\n\n" -} diff --git a/struktly b/struktly deleted file mode 100755 index adf5ab4..0000000 Binary files a/struktly and /dev/null differ