From 78b10f7ae24ff71605ae17e6793980c9d0718cce Mon Sep 17 00:00:00 2001 From: xt <1014797089@qq.com> Date: Tue, 16 Jun 2026 00:53:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(gen):=20=E6=B7=BB=E5=8A=A0=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 ExportDocs 函数生成 OpenAPI 文档的 Markdown 格式 - 实现 ExportTypeScript 函数生成 TypeScript 类型声明 - 实现 ExportClientBundle 和 ExportClient 函数生成多语言客户端 SDK - 实现 Generate 和 GenerateWithOptions 函数用于生成合约相关代码文件 - 添加 Result 和 Options 类型定义控制生成过程和输出 - 实现多种编程语言客户端生成包括 TypeScript、Dart、Java 和 Kotlin - 添加单元测试验证代码生成功能的正确性 - 实现错误码、端点、路由参数等元数据的代码生成逻辑 - 添加文件写入、格式化和新鲜度标记等辅助功能 --- README.md | 6 + cmd/nucleus/internal/describe/output_test.go | 51 +++ cmd/nucleus/internal/gen/command.go | 66 +++ cmd/nucleus/internal/gen/command_test.go | 251 ++++++++++++ cmd/nucleus/internal/gen/constants.go | 37 ++ cmd/nucleus/internal/gen/doc.go | 6 + cmd/nucleus/internal/gen/output.go | 66 +++ cmd/nucleus/internal/gen/output_test.go | 51 +++ cmd/nucleus/internal/gen/paths.go | 108 +++++ cmd/nucleus/internal/gen/run.go | 162 ++++++++ cmd/nucleus/internal/gen/summary.go | 27 ++ cmd/nucleus/internal/root/gen_example_test.go | 79 ++++ cmd/nucleus/internal/root/root.go | 12 +- .../internal/root/verify_example_test.go | 52 +++ cmd/nucleus/internal/verify/command.go | 52 +++ cmd/nucleus/internal/verify/command_test.go | 382 ++++++++++++++++++ cmd/nucleus/internal/verify/constants.go | 41 ++ cmd/nucleus/internal/verify/doc.go | 2 + cmd/nucleus/internal/verify/freshness.go | 40 ++ cmd/nucleus/internal/verify/output.go | 89 ++++ cmd/nucleus/internal/verify/run.go | 149 +++++++ cmd/nucleus/internal/verify/tidy.go | 79 ++++ contract | 2 +- .../contract/gen/.nucleus-source.sha256 | 1 + .../contract/gen/contract_source.go | 16 + example/hello-http/contract/gen/endpoints.go | 13 + example/hello-http/contract/gen/errors.go | 21 + example/hello-http/go.mod | 4 + .../adapter/http/gen/.nucleus-source.sha256 | 1 + .../internal/adapter/http/gen/handler.gen.go | 9 + .../internal/adapter/http/gen/routes.gen.go | 19 + .../internal/adapter/http/gen/types.gen.go | 25 ++ example/hello-http/nucleus.yaml | 3 + go.mod | 2 + go.sum | 2 - 35 files changed, 1921 insertions(+), 5 deletions(-) create mode 100644 cmd/nucleus/internal/gen/command.go create mode 100644 cmd/nucleus/internal/gen/command_test.go create mode 100644 cmd/nucleus/internal/gen/constants.go create mode 100644 cmd/nucleus/internal/gen/doc.go create mode 100644 cmd/nucleus/internal/gen/output.go create mode 100644 cmd/nucleus/internal/gen/output_test.go create mode 100644 cmd/nucleus/internal/gen/paths.go create mode 100644 cmd/nucleus/internal/gen/run.go create mode 100644 cmd/nucleus/internal/gen/summary.go create mode 100644 cmd/nucleus/internal/root/gen_example_test.go create mode 100644 cmd/nucleus/internal/root/verify_example_test.go create mode 100644 cmd/nucleus/internal/verify/command.go create mode 100644 cmd/nucleus/internal/verify/command_test.go create mode 100644 cmd/nucleus/internal/verify/constants.go create mode 100644 cmd/nucleus/internal/verify/doc.go create mode 100644 cmd/nucleus/internal/verify/freshness.go create mode 100644 cmd/nucleus/internal/verify/output.go create mode 100644 cmd/nucleus/internal/verify/run.go create mode 100644 cmd/nucleus/internal/verify/tidy.go create mode 100644 example/hello-http/contract/gen/.nucleus-source.sha256 create mode 100644 example/hello-http/contract/gen/contract_source.go create mode 100644 example/hello-http/contract/gen/endpoints.go create mode 100644 example/hello-http/contract/gen/errors.go create mode 100644 example/hello-http/internal/adapter/http/gen/.nucleus-source.sha256 create mode 100644 example/hello-http/internal/adapter/http/gen/handler.gen.go create mode 100644 example/hello-http/internal/adapter/http/gen/routes.gen.go create mode 100644 example/hello-http/internal/adapter/http/gen/types.gen.go diff --git a/README.md b/README.md index 2cdf20e..4e61b1b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ checks manifest and contract source legality before later workflow steps. `lint` checks project conventions and risk rules, while `verify` executes the validation, lint, build, and test evidence pipeline. +`gen` writes reproducible artifacts under generated targets such as +`contract/gen` and `internal/adapter/http/gen`. Use `nucleus gen --json` when +automation needs auditable evidence; the result includes +`result_kind: "nucleus.gen_result"`, `ok`, `source_hash`, generated `files`, +`summary`, and validation `diagnostics`. + For the bundled example, run `nucleus validate --dir example/hello-http`. Successful human output includes a short validation summary; `--json` emits the same result with stable `ok`, `summary`, and `diagnostics` fields. diff --git a/cmd/nucleus/internal/describe/output_test.go b/cmd/nucleus/internal/describe/output_test.go index c3d816e..6f453ed 100644 --- a/cmd/nucleus/internal/describe/output_test.go +++ b/cmd/nucleus/internal/describe/output_test.go @@ -29,6 +29,18 @@ func TestBuildOutputAddsDescribeMetadata(t *testing.T) { if got := verification["result_kind"]; got != "nucleus.verify_result" { t.Fatalf("verification.result_kind = %v, want nucleus.verify_result", got) } + if got := verification["evidence_schema"]; got != verificationEvidenceSchema { + t.Fatalf("verification.evidence_schema = %v, want %s", got, verificationEvidenceSchema) + } + schemaPath := filepath.Join("..", "..", "..", "..", filepath.FromSlash(verificationEvidenceSchema)) + if _, err := os.Stat(schemaPath); err != nil { + t.Fatalf("verification evidence schema %s is not readable: %v", schemaPath, err) + } + pipeline, ok := verification["pipeline"].([]map[string]any) + if !ok { + t.Fatalf("verification.pipeline has type %T, want []map[string]any", verification["pipeline"]) + } + assertVerificationPipeline(t, pipeline) } func TestBuildOutputUsesDefaultSchemaVersion(t *testing.T) { @@ -60,3 +72,42 @@ nucleus: {} } return dir } + +func assertVerificationPipeline(t *testing.T, pipeline []map[string]any) { + t.Helper() + want := []struct { + phase string + command string + }{ + {phaseValidate, commandValidate}, + {phaseLint, commandLintStrict}, + {phaseGeneratedFreshness, commandDescribeJSON}, + {phaseTidy, commandGoModTidy}, + {phaseImport, commandGoListAll}, + {phaseBuild, commandGoTestCompileOnly}, + {phaseTest, commandGoTestAll}, + } + if len(pipeline) != len(want) { + t.Fatalf("pipeline length = %d, want %d", len(pipeline), len(want)) + } + for index, item := range pipeline { + if got := item["id"]; got != want[index].phase { + t.Fatalf("pipeline[%d].id = %v, want %s", index, got, want[index].phase) + } + if got := item["sequence"]; got != index+1 { + t.Fatalf("pipeline[%d].sequence = %v, want %d", index, got, index+1) + } + if got := item["phase"]; got != want[index].phase { + t.Fatalf("pipeline[%d].phase = %v, want %s", index, got, want[index].phase) + } + if got := item["command"]; got != want[index].command { + t.Fatalf("pipeline[%d].command = %v, want %s", index, got, want[index].command) + } + if got := item["schema_ref"]; got != verificationEvidenceSchema { + t.Fatalf("pipeline[%d].schema_ref = %v, want %s", index, got, verificationEvidenceSchema) + } + if got := item["produces"]; got != verificationResultKind { + t.Fatalf("pipeline[%d].produces = %v, want %s", index, got, verificationResultKind) + } + } +} diff --git a/cmd/nucleus/internal/gen/command.go b/cmd/nucleus/internal/gen/command.go new file mode 100644 index 0000000..b8a3b0f --- /dev/null +++ b/cmd/nucleus/internal/gen/command.go @@ -0,0 +1,66 @@ +package gen + +import ( + "errors" + + "github.com/spf13/cobra" +) + +// Config carries root-level flag values used by the gen command. +type Config struct { + Dir *string +} + +type options struct { + json bool + pretty bool + http bool + grpc bool + errors bool + clients bool + clientLanguages []string + docs bool + typeScript bool +} + +// ErrGenFailed is returned when generation cannot produce valid artifacts. +var ErrGenFailed = errors.New("generation failed") + +// NewCommand creates the gen subcommand. +func NewCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseGen, + Short: commandShortGen, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + result, err := run(config, opts) + if opts.json { + if renderErr := renderJSON(cmd.OutOrStdout(), result, opts.pretty); renderErr != nil { + return renderErr + } + } else { + renderHuman(cmd.OutOrStdout(), cmd.ErrOrStderr(), result) + } + return err + }, + } + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + cmd.Flags().BoolVar(&opts.http, flagHTTP, false, flagHelpHTTP) + cmd.Flags().BoolVar(&opts.grpc, flagGRPC, false, flagHelpGRPC) + cmd.Flags().BoolVar(&opts.errors, flagErrors, false, flagHelpErrors) + cmd.Flags().BoolVar(&opts.clients, flagClients, false, flagHelpClients) + cmd.Flags().StringSliceVar(&opts.clientLanguages, flagClientLanguage, nil, flagHelpClientLanguage) + cmd.Flags().BoolVar(&opts.docs, flagDocs, false, flagHelpDocs) + cmd.Flags().BoolVar(&opts.typeScript, flagTypeScript, false, flagHelpTypeScript) + return cmd +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/cmd/nucleus/internal/gen/command_test.go b/cmd/nucleus/internal/gen/command_test.go new file mode 100644 index 0000000..48fe56b --- /dev/null +++ b/cmd/nucleus/internal/gen/command_test.go @@ -0,0 +1,251 @@ +package gen + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCommandJSONSuccess(t *testing.T) { + dir := t.TempDir() + writeService(t, dir) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--json", "--http", "--errors", "--docs", "--typescript", "--clients", "--client-language", "typescript"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute gen: %v", err) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + var output struct { + ResultKind string `json:"result_kind"` + OK bool `json:"ok"` + SourceHash string `json:"source_hash"` + Summary genSummary `json:"summary"` + Files []string `json:"files"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output.ResultKind != resultKindGen { + t.Fatalf("result_kind = %q, want %q", output.ResultKind, resultKindGen) + } + if !output.OK { + t.Fatal("ok = false, want true") + } + if output.SourceHash == "" { + t.Fatal("source_hash is empty") + } + if output.Summary.Files != len(output.Files) { + t.Fatalf("summary.files = %d, want %d", output.Summary.Files, len(output.Files)) + } + for _, want := range []string{ + "contract/gen/endpoints.go", + "contract/gen/errors.go", + "contract/gen/contract_source.go", + "contract/gen/contract.md", + "contract/gen/types.ts", + "internal/adapter/http/gen/routes.gen.go", + "sdk/typescript/client.ts", + "sdk/typescript/.nucleus-source.sha256", + } { + if !containsString(output.Files, want) { + t.Fatalf("files = %#v, want %s", output.Files, want) + } + } +} + +func TestCommandJSONValidationFailure(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + version: "0.1.0" +ai: + intent: test +`) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--json"}) + + err := cmd.Execute() + if !errors.Is(err, ErrGenFailed) { + t.Fatalf("execute gen error = %v, want ErrGenFailed", err) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty for JSON output", stderr.String()) + } + + var output struct { + OK bool `json:"ok"` + Diagnostics []struct { + Code string `json:"code"` + } `json:"diagnostics"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output.OK { + t.Fatal("ok = true, want false") + } + found := false + for _, item := range output.Diagnostics { + if item.Code == "manifest.service_name_required" { + found = true + break + } + } + if !found { + t.Fatalf("diagnostics = %#v, want manifest.service_name_required", output.Diagnostics) + } +} + +func TestCommandHumanSuccessOutput(t *testing.T) { + dir := t.TempDir() + writeService(t, dir) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--http", "--errors"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute gen: %v", err) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + output := stdout.String() + for _, want := range []string{ + "OK", + "generated:", + "source_hash:", + "contract/gen/endpoints.go", + "internal/adapter/http/gen/routes.gen.go", + } { + if !strings.Contains(output, want) { + t.Fatalf("stdout = %q, want %q", output, want) + } + } +} + +func TestCommandJSONUnsupportedClientLanguage(t *testing.T) { + dir := t.TempDir() + writeService(t, dir) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--json", "--clients", "--client-language", "ruby"}) + + err := cmd.Execute() + if !errors.Is(err, ErrGenFailed) { + t.Fatalf("execute gen error = %v, want ErrGenFailed", err) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty for JSON output", stderr.String()) + } + + var output struct { + OK bool `json:"ok"` + Diagnostics []struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"diagnostics"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output.OK { + t.Fatal("ok = true, want false") + } + if len(output.Diagnostics) == 0 || output.Diagnostics[0].Code != "gen.failed" { + t.Fatalf("diagnostics = %#v, want gen.failed", output.Diagnostics) + } + if !strings.Contains(output.Diagnostics[0].Message, "unsupported client language") { + t.Fatalf("message = %q, want unsupported language", output.Diagnostics[0].Message) + } +} + +func writeService(t *testing.T, dir string) { + t.Helper() + writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +ai: + intent: test + generated: + - contract/gen + - internal/adapter/http/gen + - sdk/typescript +capabilities: + - http +`) + writeFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 +paths: + /hello/{name}: + get: + operationId: getHello + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: ok +components: + schemas: + Greeting: + type: object + required: [message] + properties: + message: + type: string +`) + writeFile(t, dir, "api/errors.yaml", `errors: + - code: 1001 + message: greeting failed + http_status: 500 +`) +} + +func writeFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/cmd/nucleus/internal/gen/constants.go b/cmd/nucleus/internal/gen/constants.go new file mode 100644 index 0000000..bd1a725 --- /dev/null +++ b/cmd/nucleus/internal/gen/constants.go @@ -0,0 +1,37 @@ +package gen + +const ( + commandUseGen = "gen" + commandShortGen = "generate Nucleus contract artifacts" + defaultDir = "." +) + +const ( + flagJSON = "json" + flagPretty = "pretty" + flagHTTP = "http" + flagGRPC = "grpc" + flagErrors = "errors" + flagClients = "clients" + flagClientLanguage = "client-language" + flagDocs = "docs" + flagTypeScript = "typescript" +) + +const ( + flagHelpJSON = "emit machine-readable generation result" + flagHelpPretty = "pretty-print JSON output" + flagHelpHTTP = "generate HTTP endpoint and adapter metadata" + flagHelpGRPC = "generate gRPC service metadata" + flagHelpErrors = "generate error catalog metadata" + flagHelpClients = "generate client artifacts where supported" + flagHelpClientLanguage = "client language for --clients; repeat for typescript, dart, java, kotlin" + flagHelpDocs = "generate contract markdown documentation" + flagHelpTypeScript = "generate TypeScript schema declarations" +) + +const ( + resultKindGen = "nucleus.gen_result" + jsonIndentPrefix = "" + jsonIndentValue = " " +) diff --git a/cmd/nucleus/internal/gen/doc.go b/cmd/nucleus/internal/gen/doc.go new file mode 100644 index 0000000..be7b732 --- /dev/null +++ b/cmd/nucleus/internal/gen/doc.go @@ -0,0 +1,6 @@ +// Package gen implements the nucleus gen CLI subcommand. +// +// The package orchestrates validation, artifact generation, output rendering, +// and freshness evidence. Reusable contract rendering lives in +// github.com/nucleuskit/contract/gen. +package gen diff --git a/cmd/nucleus/internal/gen/output.go b/cmd/nucleus/internal/gen/output.go new file mode 100644 index 0000000..0b573f8 --- /dev/null +++ b/cmd/nucleus/internal/gen/output.go @@ -0,0 +1,66 @@ +package gen + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/nucleuskit/contract/diagnostic" +) + +type genResult struct { + ResultKind string `json:"result_kind"` + OK bool `json:"ok"` + SourceHash string `json:"source_hash,omitempty"` + Summary genSummary `json:"summary"` + Files []string `json:"files"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +func renderHuman(stdout io.Writer, stderr io.Writer, result genResult) { + for _, item := range result.Diagnostics { + _, _ = fmt.Fprintf(stderr, "%s %s %s: %s\n", item.Severity, item.Path, item.Code, item.Message) + } + if !result.OK { + _, _ = fmt.Fprintf(stderr, "FAILED\n") + _, _ = fmt.Fprintf(stderr, "diagnostics: %d errors, %d warnings\n", result.Summary.Errors, result.Summary.Warnings) + return + } + _, _ = fmt.Fprintln(stdout, "OK") + _, _ = fmt.Fprintf(stdout, "generated: %d file(s)\n", result.Summary.Files) + if result.SourceHash != "" { + _, _ = fmt.Fprintf(stdout, "source_hash: %s\n", result.SourceHash) + } + if len(result.Summary.Targets) > 0 { + _, _ = fmt.Fprintf(stdout, "targets: %s\n", strings.Join(result.Summary.Targets, ", ")) + } + if len(result.Summary.ClientLanguages) > 0 { + _, _ = fmt.Fprintf(stdout, "client_languages: %s\n", strings.Join(result.Summary.ClientLanguages, ", ")) + } + for _, file := range result.Files { + _, _ = fmt.Fprintln(stdout, file) + } + _, _ = fmt.Fprintf(stdout, "diagnostics: %d errors, %d warnings\n", result.Summary.Errors, result.Summary.Warnings) +} + +func renderJSON(writer io.Writer, result genResult, pretty bool) error { + result.ResultKind = resultKindGen + if result.Files == nil { + result.Files = []string{} + } + if result.Diagnostics == nil { + result.Diagnostics = diagnostic.Diagnostics{} + } + if result.Summary.Targets == nil { + result.Summary.Targets = []string{} + } + if result.Summary.ClientLanguages == nil { + result.Summary.ClientLanguages = []string{} + } + encoder := json.NewEncoder(writer) + if pretty { + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + } + return encoder.Encode(result) +} diff --git a/cmd/nucleus/internal/gen/output_test.go b/cmd/nucleus/internal/gen/output_test.go new file mode 100644 index 0000000..02cd74e --- /dev/null +++ b/cmd/nucleus/internal/gen/output_test.go @@ -0,0 +1,51 @@ +package gen + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestRenderJSONUsesStableEmptyArrays(t *testing.T) { + var stdout bytes.Buffer + + if err := renderJSON(&stdout, genResult{ + OK: true, + SourceHash: "abc123", + Summary: genSummary{Files: 0}, + }, false); err != nil { + t.Fatalf("renderJSON: %v", err) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if got := output["result_kind"]; got != resultKindGen { + t.Fatalf("result_kind = %v, want %s", got, resultKindGen) + } + if _, ok := output["files"].([]any); !ok { + t.Fatalf("files has type %T, want []any", output["files"]) + } + if _, ok := output["diagnostics"].([]any); !ok { + t.Fatalf("diagnostics has type %T, want []any", output["diagnostics"]) + } +} + +func TestRenderHumanValidationFailureUsesStderr(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + renderHuman(&stdout, &stderr, genResult{ + OK: false, + Summary: genSummary{Errors: 1}, + }) + + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "FAILED") { + t.Fatalf("stderr = %q, want FAILED", stderr.String()) + } +} diff --git a/cmd/nucleus/internal/gen/paths.go b/cmd/nucleus/internal/gen/paths.go new file mode 100644 index 0000000..20f03f0 --- /dev/null +++ b/cmd/nucleus/internal/gen/paths.go @@ -0,0 +1,108 @@ +package gen + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/nucleuskit/contract/inspect" +) + +const ( + targetContractGen = "contract/gen" + targetHTTPGen = "internal/adapter/http/gen" +) + +func writeBytesFile(dir string, rel string, data []byte) (string, error) { + clean, err := cleanServiceRelPath(rel) + if err != nil { + return "", err + } + path := filepath.Join(dir, filepath.FromSlash(clean)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return "", err + } + return clean, nil +} + +func cleanServiceRelPath(rel string) (string, error) { + if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") { + return "", fmt.Errorf("generated path must be relative: %s", rel) + } + clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("generated path escapes service directory: %s", rel) + } + return clean, nil +} + +func writeFreshnessMarker(dir string, target string, hash string) (string, error) { + if hash == "" { + return "", nil + } + markerRel := filepath.ToSlash(filepath.Join(filepath.FromSlash(target), inspect.FreshnessMarker)) + return writeBytesFile(dir, markerRel, []byte(hash+"\n")) +} + +func relativeFiles(dir string, files []string) []string { + relative := make([]string, 0, len(files)) + for _, file := range files { + rel, err := filepath.Rel(dir, file) + if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { + relative = append(relative, filepath.ToSlash(rel)) + continue + } + relative = append(relative, filepath.ToSlash(file)) + } + sort.Strings(relative) + return relative +} + +func appendUnique(values []string, next ...string) []string { + seen := make(map[string]struct{}, len(values)+len(next)) + result := make([]string, 0, len(values)+len(next)) + for _, value := range append(values, next...) { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func contractDocsPath() string { + return filepath.ToSlash(filepath.Join(targetContractGen, "contract.md")) +} + +func typeScriptSchemaPath() string { + return filepath.ToSlash(filepath.Join(targetContractGen, "types.ts")) +} + +func clientOutputPath(language string) string { + switch strings.ToLower(strings.TrimSpace(language)) { + case "typescript": + return filepath.ToSlash(filepath.Join("sdk", "typescript", "client.ts")) + case "dart": + return filepath.ToSlash(filepath.Join("sdk", "dart", "client.dart")) + case "java": + return filepath.ToSlash(filepath.Join("sdk", "java", "NucleusClient.java")) + case "kotlin": + return filepath.ToSlash(filepath.Join("sdk", "kotlin", "NucleusClient.kt")) + default: + return filepath.ToSlash(filepath.Join("sdk", strings.ToLower(strings.TrimSpace(language)), "client.txt")) + } +} + +func clientTarget(language string) string { + return filepath.ToSlash(filepath.Join("sdk", strings.ToLower(strings.TrimSpace(language)))) +} diff --git a/cmd/nucleus/internal/gen/run.go b/cmd/nucleus/internal/gen/run.go new file mode 100644 index 0000000..21a7166 --- /dev/null +++ b/cmd/nucleus/internal/gen/run.go @@ -0,0 +1,162 @@ +package gen + +import ( + "fmt" + "sort" + + "github.com/nucleuskit/contract/diagnostic" + contractgen "github.com/nucleuskit/contract/gen" + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/contract/validation" +) + +func run(config Config, opts *options) (genResult, error) { + dir := stringValue(config.Dir, defaultDir) + diagnostics := validation.ValidateService(dir) + if diagnostics.Failed() { + var files []string + result := genResult{ + ResultKind: resultKindGen, + OK: false, + Summary: buildSummary(files, nil, nil, diagnostics), + Files: files, + Diagnostics: diagnostics, + } + return result, ErrGenFailed + } + + files := []string{} + targets := []string{} + clientLanguages := []string{} + sourceHash := "" + + if shouldGenerateCore(opts) { + generated, err := contractgen.GenerateWithOptions(dir, coreOptions(opts)) + if err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } + sourceHash = generated.Hash + files = append(files, relativeFiles(dir, generated.Files)...) + targets = appendUnique(targets, coreTargets(opts)...) + } + if sourceHash == "" { + hash, err := inspect.ContractSourceHash(dir) + if err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } + sourceHash = hash + } + + if opts.docs { + docs, err := contractgen.ExportDocs(dir) + if err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } + path, err := writeBytesFile(dir, contractDocsPath(), docs) + if err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } + files = append(files, path) + targets = appendUnique(targets, targetContractGen) + } + if opts.typeScript { + types, err := contractgen.ExportTypeScript(dir) + if err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } + path, err := writeBytesFile(dir, typeScriptSchemaPath(), types) + if err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } + files = append(files, path) + targets = appendUnique(targets, targetContractGen) + } + if opts.docs || opts.typeScript { + if marker, err := writeFreshnessMarker(dir, targetContractGen, sourceHash); err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } else if marker != "" { + files = append(files, marker) + } + } + if opts.clients { + clients, err := contractgen.ExportClientBundle(dir, opts.clientLanguages) + if err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } + languages := make([]string, 0, len(clients)) + for language := range clients { + languages = append(languages, language) + } + sort.Strings(languages) + for _, language := range languages { + path, err := writeBytesFile(dir, clientOutputPath(language), clients[language]) + if err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } + files = append(files, path) + if marker, err := writeFreshnessMarker(dir, clientTarget(language), sourceHash); err != nil { + return failedResult(files, targets, clientLanguages, diagnostics, err), fmt.Errorf("%w: %v", ErrGenFailed, err) + } else if marker != "" { + files = append(files, marker) + } + clientLanguages = appendUnique(clientLanguages, language) + targets = appendUnique(targets, clientTarget(language)) + } + } + + files = appendUnique(nil, files...) + sort.Strings(files) + result := genResult{ + ResultKind: resultKindGen, + OK: true, + SourceHash: sourceHash, + Summary: buildSummary(files, targets, clientLanguages, diagnostics), + Files: files, + Diagnostics: diagnostics, + } + return result, nil +} + +func shouldGenerateCore(opts *options) bool { + return opts.http || opts.grpc || opts.errors || (!opts.docs && !opts.typeScript && !opts.clients) +} + +func coreOptions(opts *options) contractgen.Options { + if !opts.http && !opts.grpc && !opts.errors { + return contractgen.Options{} + } + return contractgen.Options{ + HTTP: opts.http, + GRPC: opts.grpc, + Errors: opts.errors, + } +} + +func coreTargets(opts *options) []string { + if !opts.http && !opts.grpc && !opts.errors { + return []string{targetContractGen, targetHTTPGen} + } + targets := []string{targetContractGen} + if opts.http { + targets = append(targets, targetHTTPGen) + } + return targets +} + +func failedResult(files []string, targets []string, clientLanguages []string, diagnostics diagnostic.Diagnostics, err error) genResult { + diagnostics = append(diagnostics, diagnostic.Diagnostic{ + Severity: diagnostic.SeverityError, + Code: "gen.failed", + Message: err.Error(), + }) + diagnostics.Sort() + files = appendUnique(nil, files...) + sort.Strings(files) + return genResult{ + ResultKind: resultKindGen, + OK: false, + Summary: buildSummary(files, targets, clientLanguages, diagnostics), + Files: files, + Diagnostics: diagnostics, + } +} diff --git a/cmd/nucleus/internal/gen/summary.go b/cmd/nucleus/internal/gen/summary.go new file mode 100644 index 0000000..10f51a7 --- /dev/null +++ b/cmd/nucleus/internal/gen/summary.go @@ -0,0 +1,27 @@ +package gen + +import ( + "sort" + + "github.com/nucleuskit/contract/diagnostic" +) + +type genSummary struct { + Files int `json:"files"` + Targets []string `json:"targets"` + ClientLanguages []string `json:"client_languages"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` +} + +func buildSummary(files []string, targets []string, clientLanguages []string, diagnostics diagnostic.Diagnostics) genSummary { + sort.Strings(targets) + sort.Strings(clientLanguages) + return genSummary{ + Files: len(files), + Targets: targets, + ClientLanguages: clientLanguages, + Errors: diagnostics.Count(diagnostic.SeverityError), + Warnings: diagnostics.Count(diagnostic.SeverityWarning), + } +} diff --git a/cmd/nucleus/internal/root/gen_example_test.go b/cmd/nucleus/internal/root/gen_example_test.go new file mode 100644 index 0000000..2908aed --- /dev/null +++ b/cmd/nucleus/internal/root/gen_example_test.go @@ -0,0 +1,79 @@ +package root + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestGenCommandJSONRootWiring(t *testing.T) { + dir := t.TempDir() + writeRootGenService(t, dir) + + cmd := New() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--dir", dir, "gen", "--json", "--http", "--errors"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute gen: %v\nstderr=%s", err, stderr.String()) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode gen output: %v\n%s", err, stdout.String()) + } + assertString(t, output, "result_kind", "nucleus.gen_result") + assertBool(t, output, "ok", true) + files := assertSlice(t, output, "files") + assertContainsString(t, files, "contract/gen/endpoints.go") + assertContainsString(t, files, "internal/adapter/http/gen/routes.gen.go") + if _, err := filepath.Abs(dir); err != nil { + t.Fatalf("abs dir: %v", err) + } +} + +func writeRootGenService(t *testing.T, dir string) { + t.Helper() + writeRootGenFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +ai: + intent: test + generated: + - contract/gen + - internal/adapter/http/gen +capabilities: + - http +`) + writeRootGenFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 +paths: + /healthz: + get: + operationId: getHealthz + responses: + "200": + description: ok +`) + writeRootGenFile(t, dir, "api/errors.yaml", `errors: + - code: 1001 + message: health check failed + http_status: 500 +`) +} + +func writeRootGenFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/nucleus/internal/root/root.go b/cmd/nucleus/internal/root/root.go index c18984d..a30400d 100644 --- a/cmd/nucleus/internal/root/root.go +++ b/cmd/nucleus/internal/root/root.go @@ -2,9 +2,11 @@ package root import ( "github.com/nucleuskit/nucleus/cmd/nucleus/internal/describe" - nucleuslint "github.com/nucleuskit/nucleus/cmd/nucleus/internal/lint" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/gen" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/lint" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/plan" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/validate" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/verify" "github.com/spf13/cobra" ) @@ -35,7 +37,13 @@ func New() *cobra.Command { cmd.AddCommand(validate.NewCommand(validate.Config{ Dir: &opts.dir, })) - cmd.AddCommand(nucleuslint.NewCommand(nucleuslint.Config{ + cmd.AddCommand(lint.NewCommand(lint.Config{ + Dir: &opts.dir, + })) + cmd.AddCommand(gen.NewCommand(gen.Config{ + Dir: &opts.dir, + })) + cmd.AddCommand(verify.NewCommand(verify.Config{ Dir: &opts.dir, })) cmd.AddCommand(plan.NewCommand(plan.Config{ diff --git a/cmd/nucleus/internal/root/verify_example_test.go b/cmd/nucleus/internal/root/verify_example_test.go new file mode 100644 index 0000000..ad1ae34 --- /dev/null +++ b/cmd/nucleus/internal/root/verify_example_test.go @@ -0,0 +1,52 @@ +package root + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestVerifyCommandJSONRootWiring(t *testing.T) { + dir := t.TempDir() + writeRootVerifyFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") + writeRootVerifyFile(t, dir, "demo.go", "package demo\n") + writeRootVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +ai: + intent: test +capabilities: [] +`) + + cmd := New() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--dir", dir, "verify", "--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute verify: %v\nstderr=%s\nstdout=%s", err, stderr.String(), stdout.String()) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode verify output: %v\n%s", err, stdout.String()) + } + assertString(t, output, "result_kind", "nucleus.verify_result") + assertBool(t, output, "ok", true) +} + +func writeRootVerifyFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/nucleus/internal/verify/command.go b/cmd/nucleus/internal/verify/command.go new file mode 100644 index 0000000..065c4d0 --- /dev/null +++ b/cmd/nucleus/internal/verify/command.go @@ -0,0 +1,52 @@ +package verify + +import ( + "errors" + + "github.com/spf13/cobra" +) + +// Config carries root-level flag values used by the verify command. +type Config struct { + Dir *string +} + +type options struct { + json bool + pretty bool +} + +// ErrVerifyFailed is returned when one or more verification checks fail. +var ErrVerifyFailed = errors.New("verification failed") + +// NewCommand creates to verify subcommand. +func NewCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseVerify, + Short: commandShortVerify, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + result, err := run(config) + if opts.json { + if renderErr := renderJSON(cmd.OutOrStdout(), result, opts.pretty); renderErr != nil { + return renderErr + } + } else { + renderHuman(cmd.OutOrStdout(), cmd.ErrOrStderr(), result) + } + return err + }, + } + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + return cmd +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/cmd/nucleus/internal/verify/command_test.go b/cmd/nucleus/internal/verify/command_test.go new file mode 100644 index 0000000..769a2ce --- /dev/null +++ b/cmd/nucleus/internal/verify/command_test.go @@ -0,0 +1,382 @@ +package verify + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/nucleuskit/contract/inspect" +) + +func TestCommandJSONSuccess(t *testing.T) { + dir := t.TempDir() + writeVerifyModule(t, dir) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute verify: %v\nstderr=%s\nstdout=%s", err, stderr.String(), stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + var output struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Summary verifySummary `json:"summary"` + Steps []verifyStep `json:"steps"` + Diagnostics json.RawMessage `json:"diagnostics"` + Findings json.RawMessage `json:"findings"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output.ResultKind != resultKindVerify { + t.Fatalf("result_kind = %q, want %q", output.ResultKind, resultKindVerify) + } + if output.SchemaVersion != schemaVersion { + t.Fatalf("schema_version = %q, want %q", output.SchemaVersion, schemaVersion) + } + if output.SchemaRef != schemaRef { + t.Fatalf("schema_ref = %q, want %q", output.SchemaRef, schemaRef) + } + if !output.OK { + t.Fatal("ok = false, want true") + } + if output.Summary.Failed != 0 { + t.Fatalf("summary.failed = %d, want 0", output.Summary.Failed) + } + assertSuccessfulStepOrder(t, output.Steps, []string{"validate", "lint", "generated_freshness", "tidy", "import", "build", "test"}) + if output.Summary.Steps != 7 || output.Summary.Passed != 7 { + t.Fatalf("summary = %#v, want 7 steps passed", output.Summary) + } +} + +func TestCommandJSONValidationFailure(t *testing.T) { + dir := t.TempDir() + writeVerifyFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") + writeVerifyFile(t, dir, "demo.go", "package demo\n") + writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + version: "0.1.0" +ai: + intent: test +capabilities: [] +`) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--json"}) + + err := cmd.Execute() + if !errors.Is(err, ErrVerifyFailed) { + t.Fatalf("execute verify error = %v, want ErrVerifyFailed", err) + } + + var output struct { + OK bool `json:"ok"` + Diagnostics []struct { + Code string `json:"code"` + } `json:"diagnostics"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output.OK { + t.Fatal("ok = true, want false") + } + found := false + for _, item := range output.Diagnostics { + if item.Code == "manifest.service_name_required" { + found = true + break + } + } + if !found { + t.Fatalf("diagnostics = %#v, want manifest.service_name_required", output.Diagnostics) + } +} + +func TestCommandJSONGeneratedFreshnessFailure(t *testing.T) { + dir := t.TempDir() + writeVerifyGeneratedModule(t, dir) + writeVerifyFile(t, dir, "contract/gen/client.go", "package gen\n") + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--json"}) + + err := cmd.Execute() + if !errors.Is(err, ErrVerifyFailed) { + t.Fatalf("execute verify error = %v, want ErrVerifyFailed", err) + } + + var output struct { + OK bool `json:"ok"` + Summary verifySummary `json:"summary"` + Steps []verifyStep `json:"steps"` + Findings []struct { + Rule string `json:"rule"` + } `json:"findings"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output.OK { + t.Fatal("ok = true, want false") + } + step, ok := findStep(output.Steps, "generated_freshness") + if !ok { + t.Fatalf("steps = %#v, want generated_freshness phase", output.Steps) + } + if step.OK { + t.Fatalf("generated_freshness step OK = true, want false") + } + if step.Status != statusFailed { + t.Fatalf("generated_freshness status = %q, want %q", step.Status, statusFailed) + } + if step.Output == "" { + t.Fatalf("generated_freshness output is empty, want failure evidence") + } + if len(step.GeneratedFreshness) != 1 { + t.Fatalf("generated_freshness = %#v, want one target", step.GeneratedFreshness) + } + if item := step.GeneratedFreshness[0]; item.Target != "contract/gen" || item.Fresh { + t.Fatalf("generated_freshness item = %#v, want stale contract/gen", item) + } + if _, ok := findStep(output.Steps, "tidy"); ok { + t.Fatalf("steps = %#v, tidy should not run after generated freshness failure", output.Steps) + } + foundL010 := false + for _, finding := range output.Findings { + if finding.Rule == "L010" { + foundL010 = true + break + } + } + if !foundL010 { + t.Fatalf("findings = %#v, want L010 freshness finding", output.Findings) + } + if output.Summary.Failed == 0 { + t.Fatalf("summary.failed = 0, want failed steps") + } +} + +func TestCommandJSONGeneratedFreshnessSuccess(t *testing.T) { + dir := t.TempDir() + writeVerifyGeneratedModule(t, dir) + writeVerifyFile(t, dir, "contract/gen/client.go", "package gen\n") + sourceHash, err := inspect.ContractSourceHash(dir) + if err != nil { + t.Fatalf("ContractSourceHash(): %v", err) + } + writeVerifyFile(t, dir, "contract/gen/.nucleus-source.sha256", sourceHash+"\n") + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute verify: %v\nstdout=%s", err, stdout.String()) + } + + var output struct { + OK bool `json:"ok"` + Steps []verifyStep `json:"steps"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if !output.OK { + t.Fatal("ok = false, want true") + } + step, ok := findStep(output.Steps, "generated_freshness") + if !ok { + t.Fatalf("steps = %#v, want generated_freshness phase", output.Steps) + } + if len(step.GeneratedFreshness) != 1 { + t.Fatalf("generated_freshness = %#v, want one target", step.GeneratedFreshness) + } + if item := step.GeneratedFreshness[0]; item.Target != "contract/gen" || !item.Fresh || item.SourceHash != sourceHash { + t.Fatalf("generated_freshness item = %#v, want fresh contract/gen", item) + } +} + +func TestCommandJSONTidyChangedModuleFilesFailure(t *testing.T) { + dir := t.TempDir() + writeVerifyFile(t, dir, "go.mod", `module example.com/demo + +go 1.26.3 + +require example.com/unused v0.0.0 + +replace example.com/unused => ./unused +`) + writeVerifyFile(t, dir, "demo.go", "package demo\n") + writeVerifyFile(t, dir, "unused/go.mod", "module example.com/unused\n\ngo 1.26.3\n") + writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +ai: + intent: test +capabilities: [] +`) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--json"}) + + err := cmd.Execute() + if !errors.Is(err, ErrVerifyFailed) { + t.Fatalf("execute verify error = %v, want ErrVerifyFailed", err) + } + + var output struct { + OK bool `json:"ok"` + Steps []verifyStep `json:"steps"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output.OK { + t.Fatal("ok = true, want false") + } + step, ok := findStep(output.Steps, "tidy") + if !ok { + t.Fatalf("steps = %#v, want tidy phase", output.Steps) + } + if step.OK { + t.Fatalf("tidy step OK = true, want false") + } + if step.Error != "go mod tidy changed module files" { + t.Fatalf("tidy error = %q, want changed module files", step.Error) + } + if !containsString(step.ChangedPaths, "go.mod") { + t.Fatalf("changed_paths = %#v, want go.mod", step.ChangedPaths) + } + if _, ok := findStep(output.Steps, "import"); ok { + t.Fatalf("steps = %#v, import should not run after tidy failure", output.Steps) + } +} + +func writeVerifyModule(t *testing.T, dir string) { + t.Helper() + writeVerifyFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") + writeVerifyFile(t, dir, "demo.go", "package demo\n") + writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +ai: + intent: test +capabilities: [] +`) +} + +func writeVerifyGeneratedModule(t *testing.T, dir string) { + t.Helper() + writeVerifyFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") + writeVerifyFile(t, dir, "demo.go", "package demo\n") + writeVerifyFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 +info: + title: demo + version: 0.1.0 +paths: + /healthz: + get: + operationId: getHealthz + responses: + "204": + description: ok +`) + writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +ai: + intent: test + generated: + - contract/gen +capabilities: [] +`) +} + +func writeVerifyFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func findStep(steps []verifyStep, phase string) (verifyStep, bool) { + for _, step := range steps { + if step.Phase == phase { + return step, true + } + } + return verifyStep{}, false +} + +func assertSuccessfulStepOrder(t *testing.T, steps []verifyStep, want []string) { + t.Helper() + if len(steps) != len(want) { + t.Fatalf("steps length = %d, want %d: %#v", len(steps), len(want), steps) + } + for index, phase := range want { + step := steps[index] + if step.Phase != phase { + t.Fatalf("steps[%d].phase = %q, want %q", index, step.Phase, phase) + } + if step.ID != phase { + t.Fatalf("steps[%d].id = %q, want %q", index, step.ID, phase) + } + if step.Sequence != index+1 { + t.Fatalf("steps[%d].sequence = %d, want %d", index, step.Sequence, index+1) + } + if step.WorkingDir != "." { + t.Fatalf("steps[%d].working_dir = %q, want .", index, step.WorkingDir) + } + if step.SchemaRef != schemaRef { + t.Fatalf("steps[%d].schema_ref = %q, want %q", index, step.SchemaRef, schemaRef) + } + if step.Produces != resultKindVerify { + t.Fatalf("steps[%d].produces = %q, want %q", index, step.Produces, resultKindVerify) + } + if step.Status != statusPassed || !step.OK || step.ExitCode != 0 { + t.Fatalf("steps[%d] = %#v, want passed step", index, step) + } + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/cmd/nucleus/internal/verify/constants.go b/cmd/nucleus/internal/verify/constants.go new file mode 100644 index 0000000..de0d565 --- /dev/null +++ b/cmd/nucleus/internal/verify/constants.go @@ -0,0 +1,41 @@ +package verify + +const ( + commandUseVerify = "verify" + commandShortVerify = "run Nucleus verification checks" + defaultDir = "." +) + +const ( + flagJSON = "json" + flagPretty = "pretty" + flagHelpJSON = "emit machine-readable verification result" + flagHelpPretty = "pretty-print JSON output" +) + +const ( + resultKindVerify = "nucleus.verify_result" + schemaVersion = "verify.v1" + schemaRef = "contract/schema/evidence.schema.json" + jsonIndentPrefix = "" + jsonIndentValue = " " +) + +const ( + phaseValidate = "validate" + phaseLint = "lint" + phaseGeneratedFreshness = "generated_freshness" + phaseTidy = "tidy" + phaseImport = "import" + phaseBuild = "build" + phaseTest = "test" +) + +const ( + commandValidate = "nucleus validate --dir ." + commandLintStrict = "nucleus lint --dir . --strict" + commandGeneratedFreshness = "nucleus describe --dir . --json" + commandWorkingDir = "." + statusPassed = "passed" + statusFailed = "failed" +) diff --git a/cmd/nucleus/internal/verify/doc.go b/cmd/nucleus/internal/verify/doc.go new file mode 100644 index 0000000..8194fe6 --- /dev/null +++ b/cmd/nucleus/internal/verify/doc.go @@ -0,0 +1,2 @@ +// Package verify implements the nucleus verify CLI subcommand. +package verify diff --git a/cmd/nucleus/internal/verify/freshness.go b/cmd/nucleus/internal/verify/freshness.go new file mode 100644 index 0000000..533cd78 --- /dev/null +++ b/cmd/nucleus/internal/verify/freshness.go @@ -0,0 +1,40 @@ +package verify + +import ( + "fmt" + "strings" + + "github.com/nucleuskit/contract/inspect" +) + +func runGeneratedFreshness(dir string) verifyStep { + step := verifyStep{ + Phase: phaseGeneratedFreshness, + Command: commandGeneratedFreshness, + OK: true, + } + + items, err := inspect.GeneratedFreshnessForDir(dir) + if err != nil { + step.OK = false + step.Error = "load nucleus.yaml failed" + return step + } + step.GeneratedFreshness = items + if len(items) == 0 { + step.Output = "no generated targets declared" + return step + } + + lines := make([]string, 0, len(items)) + for _, item := range items { + if !item.Fresh { + step.OK = false + lines = append(lines, fmt.Sprintf("%s: %s", item.Target, item.Reason)) + continue + } + lines = append(lines, fmt.Sprintf("%s: fresh", item.Target)) + } + step.Output = strings.Join(lines, "\n") + return step +} diff --git a/cmd/nucleus/internal/verify/output.go b/cmd/nucleus/internal/verify/output.go new file mode 100644 index 0000000..91754c7 --- /dev/null +++ b/cmd/nucleus/internal/verify/output.go @@ -0,0 +1,89 @@ +package verify + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" + contractlint "github.com/nucleuskit/contract/lint" +) + +type verifyResult struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Summary verifySummary `json:"summary"` + Steps []verifyStep `json:"steps"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` + Findings []contractlint.Finding `json:"findings"` +} + +type verifySummary struct { + Steps int `json:"steps"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + LintFindings int `json:"lint_findings"` +} + +type verifyStep struct { + ID string `json:"id"` + Sequence int `json:"sequence"` + Phase string `json:"phase"` + Command string `json:"command"` + WorkingDir string `json:"working_dir"` + SchemaRef string `json:"schema_ref"` + Produces string `json:"produces"` + Status string `json:"status"` + OK bool `json:"ok"` + ExitCode int `json:"exit_code"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + ChangedPaths []string `json:"changed_paths,omitempty"` + GeneratedFreshness []inspect.GeneratedFreshness `json:"generated_freshness,omitempty"` +} + +func renderHuman(stdout io.Writer, stderr io.Writer, result verifyResult) { + if !result.OK { + _, _ = fmt.Fprintln(stderr, "FAILED") + } else { + _, _ = fmt.Fprintln(stdout, "OK") + } + for _, step := range result.Steps { + status := "ok" + if !step.OK { + status = "failed" + } + _, _ = fmt.Fprintf(stdout, "%s: %s\n", step.Phase, status) + } + _, _ = fmt.Fprintf(stdout, "diagnostics: %d errors, %d warnings\n", result.Summary.Errors, result.Summary.Warnings) + _, _ = fmt.Fprintf(stdout, "lint_findings: %d\n", result.Summary.LintFindings) +} + +func renderJSON(writer io.Writer, result verifyResult, pretty bool) error { + result.ResultKind = resultKindVerify + if result.SchemaVersion == "" { + result.SchemaVersion = schemaVersion + } + if result.SchemaRef == "" { + result.SchemaRef = schemaRef + } + if result.Diagnostics == nil { + result.Diagnostics = diagnostic.Diagnostics{} + } + if result.Findings == nil { + result.Findings = []contractlint.Finding{} + } + if result.Steps == nil { + result.Steps = []verifyStep{} + } + encoder := json.NewEncoder(writer) + if pretty { + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + } + return encoder.Encode(result) +} diff --git a/cmd/nucleus/internal/verify/run.go b/cmd/nucleus/internal/verify/run.go new file mode 100644 index 0000000..cae3bef --- /dev/null +++ b/cmd/nucleus/internal/verify/run.go @@ -0,0 +1,149 @@ +package verify + +import ( + "bytes" + "os/exec" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + contractlint "github.com/nucleuskit/contract/lint" + "github.com/nucleuskit/contract/validation" +) + +func run(config Config) (verifyResult, error) { + dir := stringValue(config.Dir, defaultDir) + var steps []verifyStep + + diagnostics := validation.ValidateService(dir) + steps = append(steps, verifyStep{ + Phase: phaseValidate, + Command: commandValidate, + OK: !diagnostics.Failed(), + }) + if diagnostics.Failed() { + result := buildResult(steps, diagnostics, nil) + return result, ErrVerifyFailed + } + + findings := contractlint.Run(dir, true) + steps = append(steps, verifyStep{ + Phase: phaseLint, + Command: commandLintStrict, + OK: len(findings) == 0, + }) + + freshnessStep := runGeneratedFreshness(dir) + steps = append(steps, freshnessStep) + if len(findings) > 0 || !freshnessStep.OK { + result := buildResult(steps, diagnostics, findings) + return result, ErrVerifyFailed + } + + tidyStep := runTidyCommand(dir) + steps = append(steps, tidyStep) + if !tidyStep.OK { + result := buildResult(steps, diagnostics, findings) + return result, ErrVerifyFailed + } + + for _, command := range []struct { + phase string + args []string + }{ + {phaseImport, []string{"list", "./..."}}, + {phaseBuild, []string{"test", "./...", "-run", "^$"}}, + {phaseTest, []string{"test", "./..."}}, + } { + step := runGoCommand(dir, command.phase, command.args) + steps = append(steps, step) + if !step.OK { + result := buildResult(steps, diagnostics, findings) + return result, ErrVerifyFailed + } + } + + return buildResult(steps, diagnostics, findings), nil +} + +func runGoCommand(dir string, phase string, args []string) verifyStep { + cmd := exec.Command("go", args...) + cmd.Dir = dir + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + err := cmd.Run() + step := verifyStep{ + Phase: phase, + Command: "go " + strings.Join(args, " "), + OK: err == nil, + Output: strings.TrimSpace(output.String()), + } + if err != nil { + step.Error = err.Error() + if exitErr, ok := err.(*exec.ExitError); ok { + step.ExitCode = exitErr.ExitCode() + } else { + step.ExitCode = 1 + } + } + return step +} + +func buildResult(steps []verifyStep, diagnostics diagnostic.Diagnostics, findings []contractlint.Finding) verifyResult { + steps = completeStepMetadata(steps) + summary := verifySummary{ + Steps: len(steps), + Errors: diagnostics.Count(diagnostic.SeverityError), + Warnings: diagnostics.Count(diagnostic.SeverityWarning), + LintFindings: len(findings), + } + ok := !diagnostics.Failed() && len(findings) == 0 + for _, step := range steps { + if step.OK { + summary.Passed++ + continue + } + summary.Failed++ + ok = false + } + return verifyResult{ + ResultKind: resultKindVerify, + SchemaVersion: schemaVersion, + SchemaRef: schemaRef, + OK: ok, + Summary: summary, + Steps: steps, + Diagnostics: diagnostics, + Findings: findings, + } +} + +func completeStepMetadata(steps []verifyStep) []verifyStep { + for index := range steps { + step := &steps[index] + if step.ID == "" { + step.ID = step.Phase + } + if step.Sequence == 0 { + step.Sequence = index + 1 + } + if step.WorkingDir == "" { + step.WorkingDir = commandWorkingDir + } + if step.SchemaRef == "" { + step.SchemaRef = schemaRef + } + if step.Produces == "" { + step.Produces = resultKindVerify + } + if step.OK { + step.Status = statusPassed + continue + } + step.Status = statusFailed + if step.ExitCode == 0 { + step.ExitCode = 1 + } + } + return steps +} diff --git a/cmd/nucleus/internal/verify/tidy.go b/cmd/nucleus/internal/verify/tidy.go new file mode 100644 index 0000000..c55dbef --- /dev/null +++ b/cmd/nucleus/internal/verify/tidy.go @@ -0,0 +1,79 @@ +package verify + +import ( + "crypto/sha256" + "errors" + "os" + "path/filepath" + "sort" +) + +type fileSnapshot struct { + exists bool + hash [sha256.Size]byte +} + +func runTidyCommand(dir string) verifyStep { + before, err := snapshotModuleFiles(dir) + if err != nil { + return verifyStep{ + Phase: phaseTidy, + Command: "go mod tidy", + OK: false, + Error: "read module files failed", + } + } + step := runGoCommand(dir, phaseTidy, []string{"mod", "tidy"}) + after, err := snapshotModuleFiles(dir) + if err != nil { + step.OK = false + step.Error = "read module files failed" + return step + } + step.ChangedPaths = changedModuleFiles(before, after) + if step.OK && len(step.ChangedPaths) > 0 { + step.OK = false + step.Error = "go mod tidy changed module files" + } + return step +} + +func snapshotModuleFiles(dir string) (map[string]fileSnapshot, error) { + files := []string{"go.mod", "go.sum"} + snapshots := make(map[string]fileSnapshot, len(files)) + for _, name := range files { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + snapshots[name] = fileSnapshot{} + continue + } + return nil, err + } + snapshots[name] = fileSnapshot{ + exists: true, + hash: sha256.Sum256(data), + } + } + return snapshots, nil +} + +func changedModuleFiles(before map[string]fileSnapshot, after map[string]fileSnapshot) []string { + seen := map[string]struct{}{} + for name := range before { + seen[name] = struct{}{} + } + for name := range after { + seen[name] = struct{}{} + } + changed := make([]string, 0, len(seen)) + for name := range seen { + left := before[name] + right := after[name] + if left.exists != right.exists || left.hash != right.hash { + changed = append(changed, name) + } + } + sort.Strings(changed) + return changed +} diff --git a/contract b/contract index 924d1bb..c3c61c5 160000 --- a/contract +++ b/contract @@ -1 +1 @@ -Subproject commit 924d1bb5a36beb12190b038563a1b4bfe8ef5d12 +Subproject commit c3c61c5b39bdfde4a5044870a9113e3210b70811 diff --git a/example/hello-http/contract/gen/.nucleus-source.sha256 b/example/hello-http/contract/gen/.nucleus-source.sha256 new file mode 100644 index 0000000..c71a6ec --- /dev/null +++ b/example/hello-http/contract/gen/.nucleus-source.sha256 @@ -0,0 +1 @@ +6399c0636543078a250f6ee503877d3a75637fe0242950245796a2299874b3a1 diff --git a/example/hello-http/contract/gen/contract_source.go b/example/hello-http/contract/gen/contract_source.go new file mode 100644 index 0000000..c5d2337 --- /dev/null +++ b/example/hello-http/contract/gen/contract_source.go @@ -0,0 +1,16 @@ +package gen + +// ContractSourceHash is generated from contract source files. +const ContractSourceHash = "6399c0636543078a250f6ee503877d3a75637fe0242950245796a2299874b3a1" + +// EmbeddedContractSource is a generated contract source snapshot. +type EmbeddedContractSource struct { + Path string + Content string +} + +// EmbeddedContractSources contains generated contract source snapshots. +var EmbeddedContractSources = []EmbeddedContractSource{ + {Path: "api/errors.yaml", Content: "errors:\n - code: 4001\n message: invalid_name\n http_status: 400\n - code: 4041\n message: greeting_not_found\n http_status: 404\n"}, + {Path: "api/openapi.yaml", Content: "openapi: 3.1.0\ninfo:\n title: Hello HTTP Example\n version: 0.1.0\npaths:\n /hello/{name}:\n parameters:\n - name: name\n in: path\n required: true\n schema:\n type: string\n get:\n operationId: get_hello\n x-nucleus-priority: 10\n parameters:\n - name: trace_id\n in: header\n required: false\n schema:\n type: string\n responses:\n \"200\":\n description: Greeting response.\n \"404\":\n description: Greeting target was not found.\n"}, +} diff --git a/example/hello-http/contract/gen/endpoints.go b/example/hello-http/contract/gen/endpoints.go new file mode 100644 index 0000000..c332584 --- /dev/null +++ b/example/hello-http/contract/gen/endpoints.go @@ -0,0 +1,13 @@ +package gen + +// Endpoint is generated from OpenAPI. +type Endpoint struct { + Method string + Path string + OperationID string +} + +// Endpoints lists generated HTTP endpoint metadata. +var Endpoints = []Endpoint{ + {Method: "GET", Path: "/hello/{name}", OperationID: "get_hello"}, +} diff --git a/example/hello-http/contract/gen/errors.go b/example/hello-http/contract/gen/errors.go new file mode 100644 index 0000000..69f9d81 --- /dev/null +++ b/example/hello-http/contract/gen/errors.go @@ -0,0 +1,21 @@ +package gen + +// Code is a generated Nucleus error code. +type Code int + +const ( + CodeInvalidName Code = 4001 + CodeGreetingNotFound Code = 4041 +) + +// ErrorMessages maps generated error codes to stable messages. +var ErrorMessages = map[Code]string{ + CodeInvalidName: "invalid_name", + CodeGreetingNotFound: "greeting_not_found", +} + +// HTTPStatuses maps generated error codes to HTTP statuses. +var HTTPStatuses = map[Code]int{ + CodeInvalidName: 400, + CodeGreetingNotFound: 404, +} diff --git a/example/hello-http/go.mod b/example/hello-http/go.mod index 7fe05f1..c0668d9 100644 --- a/example/hello-http/go.mod +++ b/example/hello-http/go.mod @@ -1,3 +1,7 @@ module github.com/nucleuskit/nucleus/example/hello-http go 1.26.3 + +require github.com/nucleuskit/http v0.0.0 + +replace github.com/nucleuskit/http => ../../runtime/http diff --git a/example/hello-http/internal/adapter/http/gen/.nucleus-source.sha256 b/example/hello-http/internal/adapter/http/gen/.nucleus-source.sha256 new file mode 100644 index 0000000..c71a6ec --- /dev/null +++ b/example/hello-http/internal/adapter/http/gen/.nucleus-source.sha256 @@ -0,0 +1 @@ +6399c0636543078a250f6ee503877d3a75637fe0242950245796a2299874b3a1 diff --git a/example/hello-http/internal/adapter/http/gen/handler.gen.go b/example/hello-http/internal/adapter/http/gen/handler.gen.go new file mode 100644 index 0000000..070691a --- /dev/null +++ b/example/hello-http/internal/adapter/http/gen/handler.gen.go @@ -0,0 +1,9 @@ +package gen + +import "net/http" + +// Handler is implemented by the handwritten HTTP adapter. +type Handler interface { + // GetHello handles the get_hello operation. + GetHello(request *http.Request) (any, error) +} diff --git a/example/hello-http/internal/adapter/http/gen/routes.gen.go b/example/hello-http/internal/adapter/http/gen/routes.gen.go new file mode 100644 index 0000000..48a18cd --- /dev/null +++ b/example/hello-http/internal/adapter/http/gen/routes.gen.go @@ -0,0 +1,19 @@ +package gen + +import ( + "net/http" + + runtimehttp "github.com/nucleuskit/http" +) + +// RegisterRoutes binds generated OpenAPI routes to a handwritten handler. +func RegisterRoutes(server *runtimehttp.Server, handler Handler) { + if server == nil || handler == nil { + return + } + server.RegisterRoutes([]runtimehttp.Route{ + {Method: "GET", Path: "/hello/{name}", OperationID: "get_hello", Handler: func(request *http.Request) (any, error) { + return handler.GetHello(request) + }}, + }) +} diff --git a/example/hello-http/internal/adapter/http/gen/types.gen.go b/example/hello-http/internal/adapter/http/gen/types.gen.go new file mode 100644 index 0000000..a91d3c7 --- /dev/null +++ b/example/hello-http/internal/adapter/http/gen/types.gen.go @@ -0,0 +1,25 @@ +package gen + +// RouteParameter is generated OpenAPI route parameter metadata. +type RouteParameter struct { + Name string + In string + Required bool + SchemaType string +} + +// ServerRoute is generated HTTP route metadata for adapter registration. +type ServerRoute struct { + Method string + Path string + OperationID string + HandlerName string + Parameters []RouteParameter + RequestBodyRequired bool + Priority int +} + +// ServerRoutes lists generated HTTP server route metadata. +var ServerRoutes = []ServerRoute{ + {Method: "GET", Path: "/hello/{name}", OperationID: "get_hello", HandlerName: "GetHello", Parameters: []RouteParameter{{Name: "name", In: "path", Required: true, SchemaType: "string"}, {Name: "trace_id", In: "header", Required: false, SchemaType: "string"}}, RequestBodyRequired: false, Priority: 10}, +} diff --git a/example/hello-http/nucleus.yaml b/example/hello-http/nucleus.yaml index dd2b127..d66d577 100644 --- a/example/hello-http/nucleus.yaml +++ b/example/hello-http/nucleus.yaml @@ -22,6 +22,9 @@ ai: - internal/** readonly: - internal/adapter/http/gen/** + generated: + - contract/gen + - internal/adapter/http/gen forbidden: - configs/*.local.yaml nucleus: diff --git a/go.mod b/go.mod index 11414ec..c310646 100644 --- a/go.mod +++ b/go.mod @@ -12,3 +12,5 @@ require ( github.com/spf13/pflag v1.0.10 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect ) + +replace github.com/nucleuskit/contract => ./contract diff --git a/go.sum b/go.sum index 7c0be25..643e897 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ 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/nucleuskit/contract v0.1.0-alpha.2 h1:HXAo633rfPpItpBHe/CtUFJHH70nMcNcMreURHludRo= -github.com/nucleuskit/contract v0.1.0-alpha.2/go.mod h1:AVLCb1uBSCvd2CLoNvwUqsYN66sF6LWuKkLTeM4WjTk= 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= From 5e0c9f45a158dfb00993fc3f3b0c2515f7eefc95 Mon Sep 17 00:00:00 2001 From: xt <1014797089@qq.com> Date: Tue, 16 Jun 2026 01:03:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor(inspect):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=91=BD=E4=BB=A4=E5=B9=B6=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将默认验证命令从 go test ./... 替换为 nucleus verify --dir . --json - 添加 --strict 参数到 lint 命令中以启用严格模式 - 在 GeneratedFreshnessForDir 中添加对生成目标路径的规范化和验证 - 引入 normalizeGeneratedTarget 函数防止路径遍历攻击 - 添加 generatedFreshnessReasonInvalidTarget 常量用于无效路径情况 - 新增 sanitizeCommandOutput 函数用于清理命令输出中的敏感信息 - 实现模块文件变更后的自动恢复功能 - 添加测试用例验证路径遍历防护和命令输出清理功能 --- cmd/nucleus/internal/verify/command_test.go | 32 ++++++++- cmd/nucleus/internal/verify/constants.go | 3 + cmd/nucleus/internal/verify/run.go | 2 +- cmd/nucleus/internal/verify/sanitize.go | 73 +++++++++++++++++++++ cmd/nucleus/internal/verify/tidy.go | 35 ++++++++++ contract | 2 +- runtime/http | 2 +- 7 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 cmd/nucleus/internal/verify/sanitize.go diff --git a/cmd/nucleus/internal/verify/command_test.go b/cmd/nucleus/internal/verify/command_test.go index 769a2ce..644827f 100644 --- a/cmd/nucleus/internal/verify/command_test.go +++ b/cmd/nucleus/internal/verify/command_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/nucleuskit/contract/inspect" @@ -221,14 +222,15 @@ func TestCommandJSONGeneratedFreshnessSuccess(t *testing.T) { func TestCommandJSONTidyChangedModuleFilesFailure(t *testing.T) { dir := t.TempDir() - writeVerifyFile(t, dir, "go.mod", `module example.com/demo + originalGoMod := `module example.com/demo go 1.26.3 require example.com/unused v0.0.0 replace example.com/unused => ./unused -`) +` + writeVerifyFile(t, dir, "go.mod", originalGoMod) writeVerifyFile(t, dir, "demo.go", "package demo\n") writeVerifyFile(t, dir, "unused/go.mod", "module example.com/unused\n\ngo 1.26.3\n") writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" @@ -277,6 +279,32 @@ capabilities: [] if _, ok := findStep(output.Steps, "import"); ok { t.Fatalf("steps = %#v, import should not run after tidy failure", output.Steps) } + data, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + t.Fatalf("read go.mod after verify: %v", err) + } + if string(data) != originalGoMod { + t.Fatalf("go.mod changed after verify:\n%s", data) + } +} + +func TestSanitizeCommandOutputRedactsSecretsPathsAndLongOutput(t *testing.T) { + dir := t.TempDir() + writeVerifyFile(t, dir, "go.mod", "module private.example.com/demo\n\ngo 1.26.3\n") + raw := dir + "/demo.go: token=abc123 password: hunter2 Authorization: Bearer abc.def private.example.com/demo/internal\n" + + strings.Repeat("x", maxCommandOutputRunes+16) + + output := sanitizeCommandOutput(raw, dir) + for _, forbidden := range []string{dir, "abc123", "hunter2", "abc.def", "private.example.com/demo"} { + if strings.Contains(output, forbidden) { + t.Fatalf("sanitizeCommandOutput() leaked %q in %q", forbidden, output) + } + } + for _, want := range []string{"token=[REDACTED]", "password: [REDACTED]", "Authorization: Bearer [REDACTED]", "/internal", "[output truncated]"} { + if !strings.Contains(output, want) { + t.Fatalf("sanitizeCommandOutput() = %q, want %q", output, want) + } + } } func writeVerifyModule(t *testing.T, dir string) { diff --git a/cmd/nucleus/internal/verify/constants.go b/cmd/nucleus/internal/verify/constants.go index de0d565..0746a9c 100644 --- a/cmd/nucleus/internal/verify/constants.go +++ b/cmd/nucleus/internal/verify/constants.go @@ -38,4 +38,7 @@ const ( commandWorkingDir = "." statusPassed = "passed" statusFailed = "failed" + redactedValue = "[REDACTED]" + truncatedOutputNotice = "[output truncated]" + maxCommandOutputRunes = 32768 ) diff --git a/cmd/nucleus/internal/verify/run.go b/cmd/nucleus/internal/verify/run.go index cae3bef..fb66cb0 100644 --- a/cmd/nucleus/internal/verify/run.go +++ b/cmd/nucleus/internal/verify/run.go @@ -76,7 +76,7 @@ func runGoCommand(dir string, phase string, args []string) verifyStep { Phase: phase, Command: "go " + strings.Join(args, " "), OK: err == nil, - Output: strings.TrimSpace(output.String()), + Output: sanitizeCommandOutput(output.String(), dir), } if err != nil { step.Error = err.Error() diff --git a/cmd/nucleus/internal/verify/sanitize.go b/cmd/nucleus/internal/verify/sanitize.go new file mode 100644 index 0000000..13c5a64 --- /dev/null +++ b/cmd/nucleus/internal/verify/sanitize.go @@ -0,0 +1,73 @@ +package verify + +import ( + "os" + "path/filepath" + "regexp" + "strings" +) + +var ( + sensitiveKeyPattern = regexp.MustCompile(`(?i)\b(token|secret|password|cookie|private_key|dsn)(\s*[:=]\s*)[^\s]+`) + authorizationPattern = regexp.MustCompile(`(?i)\b(authorization\s*:\s*bearer\s+)[^\s]+`) + goModuleDirectivePrefix = "module " +) + +func sanitizeCommandOutput(raw string, dir string) string { + output := strings.TrimSpace(raw) + output = redactKnownPaths(output, dir) + output = redactLocalModulePath(output, dir) + output = authorizationPattern.ReplaceAllString(output, "${1}"+redactedValue) + output = sensitiveKeyPattern.ReplaceAllString(output, "${1}${2}"+redactedValue) + return truncateCommandOutput(output) +} + +func redactKnownPaths(output string, dir string) string { + if dir != "" { + if abs, err := filepath.Abs(dir); err == nil { + cleaned := filepath.Clean(abs) + if cleaned != string(filepath.Separator) { + output = strings.ReplaceAll(output, cleaned, ".") + output = strings.ReplaceAll(output, filepath.ToSlash(cleaned), ".") + } + } + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + cleaned := filepath.Clean(home) + if cleaned != string(filepath.Separator) { + output = strings.ReplaceAll(output, cleaned, "~") + output = strings.ReplaceAll(output, filepath.ToSlash(cleaned), "~") + } + } + return output +} + +func redactLocalModulePath(output string, dir string) string { + modulePath := readLocalModulePath(dir) + if modulePath == "" { + return output + } + return strings.ReplaceAll(output, modulePath, "") +} + +func readLocalModulePath(dir string) string { + data, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, goModuleDirectivePrefix) { + return strings.TrimSpace(strings.TrimPrefix(line, goModuleDirectivePrefix)) + } + } + return "" +} + +func truncateCommandOutput(output string) string { + runes := []rune(output) + if len(runes) <= maxCommandOutputRunes { + return output + } + return string(runes[:maxCommandOutputRunes]) + "\n" + truncatedOutputNotice +} diff --git a/cmd/nucleus/internal/verify/tidy.go b/cmd/nucleus/internal/verify/tidy.go index c55dbef..6aa78f1 100644 --- a/cmd/nucleus/internal/verify/tidy.go +++ b/cmd/nucleus/internal/verify/tidy.go @@ -11,6 +11,8 @@ import ( type fileSnapshot struct { exists bool hash [sha256.Size]byte + mode os.FileMode + data []byte } func runTidyCommand(dir string) verifyStep { @@ -31,6 +33,13 @@ func runTidyCommand(dir string) verifyStep { return step } step.ChangedPaths = changedModuleFiles(before, after) + if len(step.ChangedPaths) > 0 { + if err := restoreModuleFiles(dir, before); err != nil { + step.OK = false + step.Error = "restore module files failed" + return step + } + } if step.OK && len(step.ChangedPaths) > 0 { step.OK = false step.Error = "go mod tidy changed module files" @@ -50,14 +59,40 @@ func snapshotModuleFiles(dir string) (map[string]fileSnapshot, error) { } return nil, err } + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil { + return nil, err + } snapshots[name] = fileSnapshot{ exists: true, hash: sha256.Sum256(data), + mode: info.Mode().Perm(), + data: data, } } return snapshots, nil } +func restoreModuleFiles(dir string, snapshots map[string]fileSnapshot) error { + for name, snapshot := range snapshots { + path := filepath.Join(dir, name) + if !snapshot.exists { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + continue + } + mode := snapshot.mode + if mode == 0 { + mode = 0o644 + } + if err := os.WriteFile(path, snapshot.data, mode); err != nil { + return err + } + } + return nil +} + func changedModuleFiles(before map[string]fileSnapshot, after map[string]fileSnapshot) []string { seen := map[string]struct{}{} for name := range before { diff --git a/contract b/contract index c3c61c5..e00c329 160000 --- a/contract +++ b/contract @@ -1 +1 @@ -Subproject commit c3c61c5b39bdfde4a5044870a9113e3210b70811 +Subproject commit e00c3290a34ae31d5e9e92b6178c62ba92c779b3 diff --git a/runtime/http b/runtime/http index 52bf270..db4debf 160000 --- a/runtime/http +++ b/runtime/http @@ -1 +1 @@ -Subproject commit 52bf27005220fc1559b2d59f307cc5fbb047bc0c +Subproject commit db4debffef809524546c7f017264538f3cb52fd2