diff --git a/.gitignore b/.gitignore index eef0dc6..d735db3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ /dist/ *.test coverage.out -hado-coverage.json # Local toolchains /.tools/ diff --git a/Makefile b/Makefile index 75764c5..1d75fe9 100644 --- a/Makefile +++ b/Makefile @@ -7,13 +7,13 @@ GO_CMD = $(if $(wildcard $(TOOLS_GO)),$(TOOLS_GO),go) GOFMT_CMD = $(if $(wildcard $(TOOLS_GO)),$(dir $(TOOLS_GO))gofmt,gofmt) GO_BOOTSTRAP_VERSION ?= 1.22.12 -GO_FILES := $(shell git ls-files '*.go') +GO_FILES := $(shell git ls-files '*.go' | while read f; do test -f "$$f" && printf '%s\n' "$$f"; done) BINARY := bin/hado +HADO_GOBCE_BINARY := bin/hado-gobce GO_BIN = $(shell $(GO_CMD) env GOPATH 2>/dev/null)/bin GOBCE = $(GO_BIN)/gobce GOBCE_PACKAGE ?= github.com/keyskey/gobce/cmd/gobce@latest COVERPROFILE ?= coverage.out -READINESS_COVERAGE ?= hado-coverage.json READINESS_MANIFEST ?= hado.yaml READINESS_STANDARD ?= standards/cli-service.yaml MARKDOWNLINT_CLI2_IMAGE ?= davidanson/markdownlint-cli2:v0.22.1 @@ -23,7 +23,7 @@ help: @echo " make bootstrap-go # Install Go $(GO_BOOTSTRAP_VERSION) into .tools/go (network; not global)" @echo " make ensure-go # Use .tools/go if complete, else PATH go, else bootstrap" @echo " make setup # ensure-go + go install gobce" - @echo " make build # Build hado CLI binary" + @echo " make build # Build hado CLI and bundled module wrapper binaries" @echo " make lint # Run YAML, Markdown, and Go lint checks" @echo " make fmt # Format Go source files" @echo " make fmt-check # Used in make ci-lint (does not run go test)" @@ -84,6 +84,7 @@ setup-hooks: build: ensure-go @mkdir -p bin $(GO_CMD) build -o "$(BINARY)" ./cmd/hado + $(GO_CMD) build -o "$(HADO_GOBCE_BINARY)" ./modules/gobce/cmd/hado-gobce gen-manifest-doc: ensure-go @mkdir -p bin @@ -132,8 +133,15 @@ test: ensure-go readiness-check: ensure-go @command -v "$(GOBCE)" >/dev/null 2>&1 || { echo "gobce is required. Run: make setup"; exit 1; } $(GO_CMD) test ./... -coverprofile="$(COVERPROFILE)" - "$(GOBCE)" analyze --coverprofile "$(COVERPROFILE)" --format json --output "$(READINESS_COVERAGE)" - $(GO_CMD) run ./cmd/hado fire --standard "$(READINESS_STANDARD)" --manifest "$(READINESS_MANIFEST)" + @mkdir -p bin + $(GO_CMD) build -o "$(HADO_GOBCE_BINARY)" ./modules/gobce/cmd/hado-gobce + @tmpdir="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + cp "$(READINESS_MANIFEST)" "$$tmpdir/hado.yaml"; \ + standard="$(CURDIR)/$(READINESS_STANDARD)"; \ + PATH="$(CURDIR)/bin:$(GO_BIN):$$PATH" HADO_COVERPROFILE="$(CURDIR)/$(COVERPROFILE)" \ + $(GO_CMD) run ./cmd/hado charge --standard "$$standard" --manifest "$$tmpdir/hado.yaml"; \ + $(GO_CMD) run ./cmd/hado fire --standard "$$standard" --manifest "$$tmpdir/hado.yaml" pre-pr: ci-lint test @echo "pre-pr: OK." diff --git a/README.md b/README.md index d8f3c4b..292256c 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,11 @@ make build ./bin/hado ``` +`make build` は HADO CLI に加えて coverage module wrapper の `bin/hado-gobce` もビルドします。 + ## Target manifest(service / standard) -`hado target` は、HADO Manifest に **評価対象の service** と **適用する Readiness Standard** を書き込み、**解決した standard に含まれるゲート**に対応する `evidence` の **キーとひな形**(文字列は空、coverage は `hado-json` + `coverage-metrics.json` の既定)を **マージ**します。既に値があるフィールドは上書きしません(`--rewrite-placeholders=false` でこの evidence のマージをスキップ可能)。`standards/.yaml` は `--standards-dir` または manifest 隣の `standards/` に置きます。 +`hado target` は、HADO Manifest に **評価対象の service** と **適用する Readiness Standard** を書き込み、**解決した standard に含まれるゲート**に対応する `evidence` の **キーとひな形**(文字列は空、coverage は `module: gobce`)を **マージ**します。既に値があるフィールドは上書きしません(`--rewrite-placeholders=false` でこの evidence のマージをスキップ可能)。`standards/.yaml` は `--standards-dir` または manifest 隣の `standards/` に置きます。 ```bash ./bin/hado target --manifest hado.yaml \ @@ -52,7 +54,7 @@ TTY で実行すると、現在の manifest の値をデフォルトにしなが `hado` は `target` / `charge` / `fire` の 3 コマンドで運用します。 - `hado target`: service / standard と evidence のひな形を manifest に書く -- `hado charge`: evidence の参照(例: coverage artifact の adapter/path)を manifest にマージする。任意で **`--datadog-discover`** により Datadog Monitor を API から特定し **`evidence.observability.monitor.refs`** に `discovery_type: auto` を upsert する(`DD_API_KEY` / `DD_APP_KEY`、詳細は [docs/observability-readiness.md](docs/observability-readiness.md)) +- `hado charge`: coverage module を実行して `evidence.coverage.metrics` を manifest に書き戻す。任意で **`--datadog-discover`** により Datadog Monitor を API から特定し **`evidence.observability.monitor.refs`** に `discovery_type: auto` を upsert する(`DD_API_KEY` / `DD_APP_KEY`、詳細は [docs/observability-readiness.md](docs/observability-readiness.md)) - `hado fire`: manifest に揃った evidence を Readiness Standard の gate と照合して判定する `hado fire` の終了コードは `0`(ready)、`1`(blocked)、`2`(error)です。`BLOCKED` のときは CI で扱いやすいように 1 で終了します。 @@ -60,52 +62,44 @@ TTY で実行すると、現在の manifest の値をデフォルトにしなが HADO core は、特定の runtime、tool、SaaS、infrastructure provider の フォーマットに直接依存しません。Coverage、Operation、Observability、 Infrastructure、Application、Security などの readiness domain は、 -adapter や module が evidence を正規化し、standard の gate が判定します。 +module が evidence を正規化し、standard の gate が判定します。 現在の evaluator は、coverage・operations・observability・release(rollback と 自動リリース用 `workflow_refs` の宣言)・infra(deployment 参照)の各 evidence を Manifest から読み、対応する existence 系 gate を評価できます(詳細は [docs/implementation-status.md](docs/implementation-status.md))。 -Coverage tool 固有の出力は adapter が `c0Coverage` / `c1Coverage` に正規化します。 +Coverage tool 固有の出力は coverage module が `test.c0_coverage` / `test.c1_coverage` に正規化し、`hado charge` が manifest に保存します。 +`module: gobce` は HADO 同梱の `hado-gobce` wrapper を起動し、内部で `gobce analyze` を実行します。 ```bash -printf '{"c0Coverage": 82.1, "c1Coverage": 72.5}\n' > coverage-metrics.json - cat > hado.yaml <<'YAML' version: v1 evidence: coverage: - inputs: - - adapter: hado-json - path: coverage-metrics.json + module: gobce operations: owner: platform-team runbook: https://example.com/runbooks/order-api YAML ./bin/hado charge \ - --manifest hado.yaml \ - --coverage-input hado-json:coverage-metrics.json + --manifest hado.yaml ./bin/hado fire \ --standard standards/web-service.yaml \ --manifest hado.yaml ``` -Go coverprofile や `keyskey/gobce` の JSON output も adapter 経由で扱えます。 - -```bash -go test ./... -coverprofile=coverage.out -gobce analyze --coverprofile coverage.out --format json --output gobce.json - -./bin/hado charge \ - --manifest hado.yaml \ - --coverage-input go-coverprofile:coverage.out \ - --coverage-input gobce-json:gobce.json +`hado charge` 後の manifest には次のように coverage 結果が直接書き戻されます。 -./bin/hado fire \ - --standard standards/web-service.yaml \ - --manifest hado.yaml +```yaml +evidence: + coverage: + module: gobce + measured_at: 2026-05-26T14:30:00Z + metrics: + c0: 82.1 + c1: 68.4 ``` -`hado charge` の `--coverage-input` は manifest の `evidence.coverage.inputs` へ不足分をマージします。既存値は置換しません。`--datadog-discover` を付けたときは、`evidence.observability.monitor.discovery.datadog` が必須で、失敗時(0 件・複数件・認証エラーなど)は exit 2 としその時点の manifest ファイルは更新されません(同一コマンドで coverage マージも済んでいても保存されません)。 +必要なら `hado charge --coverage-module gobce` で manifest の `evidence.coverage.module` を補助的に指定できます。`--datadog-discover` を付けたときは、`evidence.observability.monitor.discovery.datadog` が必須で、失敗時(0 件・複数件・認証エラーなど)は exit 2 としその時点の manifest ファイルは更新されません。 diff --git a/cmd/hado/charge/run.go b/cmd/hado/charge/run.go index 49abb9a..60d390b 100644 --- a/cmd/hado/charge/run.go +++ b/cmd/hado/charge/run.go @@ -7,32 +7,20 @@ import ( "io" "net/http" "path/filepath" - "strings" + "time" - "github.com/keyskey/hado/internal/coverage" "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/modulerunner" "github.com/keyskey/hado/internal/standard" ) -type stringList []string - -func (values *stringList) String() string { - return strings.Join(*values, ",") -} - -func (values *stringList) Set(value string) error { - *values = append(*values, value) - return nil -} - func Run(args []string, stdout, stderr io.Writer) (int, error) { fs := flag.NewFlagSet("charge", flag.ContinueOnError) fs.SetOutput(stderr) manifestPath := fs.String("manifest", "", "path to HADO manifest YAML") standardRef := fs.String("standard", "", "Readiness Standard id or path (optional; defaults to manifest standard.id)") standardsDir := fs.String("standards-dir", "", "directory containing .yaml standards (default: /standards)") - var coverageInputs stringList - fs.Var(&coverageInputs, "coverage-input", "coverage input as :; merges into manifest coverage inputs; adapters: hado-json, go-coverprofile, gobce-json") + coverageModule := fs.String("coverage-module", "", "coverage module to write to evidence.coverage.module before running charge (example: gobce)") datadogDiscover := fs.Bool("datadog-discover", false, "resolve one Datadog monitor via API and write monitor.refs (discovery_type auto; requires DD_API_KEY, DD_APP_KEY, evidence.observability.monitor.discovery.datadog)") if err := fs.Parse(args); err != nil { return 2, err @@ -59,8 +47,11 @@ func Run(args []string, stdout, stderr io.Writer) (int, error) { return 2, err } - if err := mergeCoverageInputs(&m, coverageInputs); err != nil { - return 2, err + if *coverageModule != "" { + if m.Evidence.Coverage == nil { + m.Evidence.Coverage = &manifest.CoverageEvidence{} + } + m.Evidence.Coverage.Module = *coverageModule } if *datadogDiscover { @@ -69,52 +60,106 @@ func Run(args []string, stdout, stderr io.Writer) (int, error) { } } - if requiresCoverage(st) && len(m.CoverageAdapterInputs()) == 0 { - return 2, fmt.Errorf("charge requires --coverage-input or manifest evidence.coverage.inputs for coverage gates") - } - if len(m.CoverageAdapterInputs()) > 0 { - if _, err := coverage.ParseAdapterInputs(m.CoverageAdapterInputs()); err != nil { + measuredAt := "" + if requiresCoverage(st) { + module := m.CoverageModule() + if module == "" { + return 2, fmt.Errorf("charge requires evidence.coverage.module for coverage gates") + } + result, err := runCoverageModule(context.Background(), module, m) + if err != nil { + return 2, err + } + metrics, err := coverageMetricsFromModuleResult(result) + if err != nil { return 2, err } + measuredAt = time.Now().UTC().Format(time.RFC3339) + if m.Evidence.Coverage == nil { + m.Evidence.Coverage = &manifest.CoverageEvidence{} + } + m.Evidence.Coverage.Module = module + m.Evidence.Coverage.MeasuredAt = measuredAt + m.Evidence.Coverage.Metrics = metrics } if err := m.Save(*manifestPath); err != nil { return 2, err } - fmt.Fprintf(stdout, "Wrote manifest %s (coverage inputs: %d)\n", *manifestPath, len(m.CoverageAdapterInputs())) + if measuredAt != "" { + fmt.Fprintf(stdout, "Wrote manifest %s (coverage module: %s, measured_at: %s)\n", *manifestPath, m.CoverageModule(), measuredAt) + } else { + fmt.Fprintf(stdout, "Wrote manifest %s\n", *manifestPath) + } return 0, nil } -func mergeCoverageInputs(m *manifest.Manifest, specs []string) error { - if len(specs) == 0 { - return nil - } - if m.Evidence.Coverage == nil { - m.Evidence.Coverage = &manifest.CoverageEvidence{} +func requiresCoverage(st standard.Standard) bool { + return st.RequiresGate(standard.C0CoverageGateID) || st.RequiresGate(standard.C1CoverageGateID) +} + +func runCoverageModule(ctx context.Context, module string, m manifest.Manifest) (modulerunner.RunResult, error) { + spec, err := coverageModuleSpec(module) + if err != nil { + return modulerunner.RunResult{}, err } + return modulerunner.Run(ctx, spec, modulerunner.RunRequest{ + APIVersion: modulerunner.APIVersion, + Operation: "coverage.analyze", + Service: map[string]string{ + "id": m.Service.ID, + "name": m.Service.Name, + }, + Evidence: map[string]any{ + "coverage": map[string]string{"module": module}, + }, + }) +} - existing := make(map[string]struct{}, len(m.Evidence.Coverage.Inputs)) - for _, input := range m.Evidence.Coverage.Inputs { - key := input.Adapter + "\x00" + input.Path - existing[key] = struct{}{} +func coverageModuleSpec(module string) (modulerunner.Spec, error) { + switch module { + case "gobce": + return modulerunner.Spec{ + ID: "gobce", + Command: "hado-gobce", + Timeout: 2 * time.Minute, + MaxStdoutBytes: modulerunner.DefaultMaxStdoutBytes, + }, nil + default: + return modulerunner.Spec{}, fmt.Errorf("unsupported coverage module %q", module) } - for _, spec := range specs { - parsed, err := coverage.ParseCoverageSpec(spec) - if err != nil { - return err - } - key := parsed.Format + "\x00" + parsed.Path - if _, ok := existing[key]; ok { - continue +} + +func coverageMetricsFromModuleResult(result modulerunner.RunResult) (*manifest.CoverageMetrics, error) { + var c0 *float64 + var c1 *float64 + for _, metric := range result.Metrics { + value := metric.Value + switch metric.ID { + case standard.C0CoverageGateID: + c0 = &value + case standard.C1CoverageGateID: + c1 = &value } - m.Evidence.Coverage.Inputs = append(m.Evidence.Coverage.Inputs, manifest.CoverageInput{ - Adapter: parsed.Format, - Path: parsed.Path, - }) - existing[key] = struct{}{} } - return nil + metrics := &manifest.CoverageMetrics{C0: c0, C1: c1} + if err := validateCoverageModuleMetrics(metrics); err != nil { + return nil, err + } + return metrics, nil } -func requiresCoverage(st standard.Standard) bool { - return st.RequiresGate(standard.C0CoverageGateID) || st.RequiresGate(standard.C1CoverageGateID) +func validateCoverageModuleMetrics(metrics *manifest.CoverageMetrics) error { + if metrics.C0 == nil { + return fmt.Errorf("coverage module result missing %s", standard.C0CoverageGateID) + } + if metrics.C1 == nil { + return fmt.Errorf("coverage module result missing %s", standard.C1CoverageGateID) + } + if *metrics.C0 < 0 || *metrics.C0 > 100 { + return fmt.Errorf("coverage module result %s must be between 0 and 100", standard.C0CoverageGateID) + } + if *metrics.C1 < 0 || *metrics.C1 > 100 { + return fmt.Errorf("coverage module result %s must be between 0 and 100", standard.C1CoverageGateID) + } + return nil } diff --git a/cmd/hado/charge/run_test.go b/cmd/hado/charge/run_test.go index 5b5a892..5b638b3 100644 --- a/cmd/hado/charge/run_test.go +++ b/cmd/hado/charge/run_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/keyskey/hado/internal/manifest" ) @@ -19,7 +20,7 @@ func writeFile(t *testing.T, dir, name, content string) string { return path } -func TestChargeMergesCoverageInputsWithoutReplacingExisting(t *testing.T) { +func TestChargeRunsCoverageModuleAndWritesMetrics(t *testing.T) { dir := t.TempDir() stdDir := filepath.Join(dir, "standards") if err := os.MkdirAll(stdDir, 0o755); err != nil { @@ -33,23 +34,19 @@ gates: threshold: min: 70 `) - writeFile(t, dir, "existing.json", `{"c0Coverage":71}`) - writeFile(t, dir, "new.json", `{"c0Coverage":72}`) + installFakeGobce(t, dir, `{"apiVersion":"hado.dev/v1","module":{"id":"hado.gobce"},"metrics":[{"id":"test.c0_coverage","value":82.1,"unit":"percent"},{"id":"test.c1_coverage","value":68.4,"unit":"percent"}]}`) manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 standard: id: standard.yaml evidence: coverage: - inputs: - - adapter: hado-json - path: existing.json + module: gobce `) var stdout bytes.Buffer var stderr bytes.Buffer exitCode, err := Run([]string{ "--manifest", manifestPath, - "--coverage-input", "hado-json:new.json", }, &stdout, &stderr) if err != nil { t.Fatalf("run charge: %v", err) @@ -62,8 +59,20 @@ evidence: if err != nil { t.Fatalf("load manifest: %v", err) } - if m.Evidence.Coverage == nil || len(m.Evidence.Coverage.Inputs) != 2 { - t.Fatalf("coverage inputs = %#v, want 2 inputs", m.Evidence.Coverage) + if m.Evidence.Coverage == nil || m.Evidence.Coverage.Module != "gobce" { + t.Fatalf("coverage = %#v, want gobce module", m.Evidence.Coverage) + } + if m.Evidence.Coverage.Metrics == nil || m.Evidence.Coverage.Metrics.C0 == nil || *m.Evidence.Coverage.Metrics.C0 != 82.1 { + t.Fatalf("coverage metrics = %#v, want c0 82.1", m.Evidence.Coverage.Metrics) + } + if m.Evidence.Coverage.Metrics.C1 == nil || *m.Evidence.Coverage.Metrics.C1 != 68.4 { + t.Fatalf("coverage metrics = %#v, want c1 68.4", m.Evidence.Coverage.Metrics) + } + if _, err := time.Parse(time.RFC3339, m.Evidence.Coverage.MeasuredAt); err != nil { + t.Fatalf("measured_at = %q, want RFC3339: %v", m.Evidence.Coverage.MeasuredAt, err) + } + if !strings.Contains(stdout.String(), "coverage module: gobce") { + t.Fatalf("stdout = %q, want coverage module summary", stdout.String()) } } @@ -113,7 +122,7 @@ evidence: } } -func TestChargeFailsWhenMergedCoverageInputIsInvalid(t *testing.T) { +func TestChargeCoverageModuleOverrideWritesManifest(t *testing.T) { dir := t.TempDir() stdDir := filepath.Join(dir, "standards") if err := os.MkdirAll(stdDir, 0o755); err != nil { @@ -127,6 +136,7 @@ gates: threshold: min: 70 `) + installFakeGobce(t, dir, `{"apiVersion":"hado.dev/v1","metrics":[{"id":"test.c0_coverage","value":82.1},{"id":"test.c1_coverage","value":68.4}]}`) manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 standard: id: standard.yaml @@ -137,12 +147,169 @@ evidence: {} var stderr bytes.Buffer exitCode, err := Run([]string{ "--manifest", manifestPath, - "--coverage-input", "gobce-json:not-found.json", + "--coverage-module", "gobce", }, &stdout, &stderr) + if err != nil { + t.Fatalf("run charge: %v", err) + } + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0", exitCode) + } + m, err := manifest.Load(manifestPath) + if err != nil { + t.Fatal(err) + } + if m.Evidence.Coverage == nil || m.Evidence.Coverage.Module != "gobce" { + t.Fatalf("coverage = %#v, want gobce", m.Evidence.Coverage) + } +} + +func TestChargeRequiresCoverageModuleForCoverageGates(t *testing.T) { + dir := t.TempDir() + stdDir := filepath.Join(dir, "standards") + if err := os.MkdirAll(stdDir, 0o755); err != nil { + t.Fatalf("mkdir standards: %v", err) + } + writeFile(t, stdDir, "standard.yaml", `id: test +gates: + - id: test.c0_coverage + severity: critical + required: true + threshold: + min: 70 +`) + manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 +standard: + id: standard.yaml +evidence: {} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode, err := Run([]string{"--manifest", manifestPath}, &stdout, &stderr) + if err == nil { + t.Fatal("run charge error = nil, want module error") + } + if exitCode != 2 { + t.Fatalf("exit code = %d, want 2", exitCode) + } +} + +func TestChargeDoesNotSaveManifestWhenModuleFails(t *testing.T) { + dir := t.TempDir() + stdDir := filepath.Join(dir, "standards") + if err := os.MkdirAll(stdDir, 0o755); err != nil { + t.Fatalf("mkdir standards: %v", err) + } + writeFile(t, stdDir, "standard.yaml", `id: test +gates: + - id: test.c0_coverage + severity: critical + required: true + threshold: + min: 70 +`) + installFailingGobce(t, dir) + manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 +standard: + id: standard.yaml +evidence: + coverage: + module: gobce +`) + before, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode, err := Run([]string{"--manifest", manifestPath}, &stdout, &stderr) if err == nil { - t.Fatal("run charge error = nil, want parse/read error") + t.Fatal("run charge error = nil, want module error") } if exitCode != 2 { t.Fatalf("exit code = %d, want 2", exitCode) } + after, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("manifest changed on failure:\n%s", after) + } +} + +func TestChargeFailsForInvalidModuleResult(t *testing.T) { + dir := t.TempDir() + stdDir := filepath.Join(dir, "standards") + if err := os.MkdirAll(stdDir, 0o755); err != nil { + t.Fatalf("mkdir standards: %v", err) + } + writeFile(t, stdDir, "standard.yaml", `id: test +gates: + - id: test.c0_coverage + severity: critical + required: true + threshold: + min: 70 +`) + installFakeGobce(t, dir, `{"apiVersion":"hado.dev/v1","metrics":[{"id":"test.c1_coverage","value":68.4}]}`) + manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 +standard: + id: standard.yaml +evidence: + coverage: + module: gobce +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode, err := Run([]string{"--manifest", manifestPath}, &stdout, &stderr) + if err == nil { + t.Fatal("run charge error = nil, want missing metric error") + } + if exitCode != 2 { + t.Fatalf("exit code = %d, want 2", exitCode) + } +} + +func TestChargeCoverageInputFlagIsRemoved(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode, err := Run([]string{"--manifest", "hado.yaml", "--coverage-input", "hado-json:coverage.json"}, &stdout, &stderr) + if err == nil { + t.Fatal("run charge error = nil, want flag parse error") + } + if exitCode != 2 { + t.Fatalf("exit code = %d, want 2", exitCode) + } +} + +func installFakeGobce(t *testing.T, dir, stdout string) { + t.Helper() + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + path := filepath.Join(binDir, "hado-gobce") + content := "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '" + stdout + "'\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write fake gobce: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func installFailingGobce(t *testing.T, dir string) { + t.Helper() + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + path := filepath.Join(binDir, "hado-gobce") + content := "#!/bin/sh\necho module failed >&2\nexit 3\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write fake gobce: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) } diff --git a/cmd/hado/fire/coverage_test.go b/cmd/hado/fire/coverage_test.go index 36af9a8..7f5e85c 100644 --- a/cmd/hado/fire/coverage_test.go +++ b/cmd/hado/fire/coverage_test.go @@ -20,16 +20,14 @@ gates: threshold: min: 65 `) - writeFile(t, dir, "coverage-metrics.json", `{ - "c0Coverage": 70, - "c1Coverage": 68.4 -}`) manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 evidence: coverage: - inputs: - - adapter: hado-json - path: coverage-metrics.json + module: gobce + measured_at: 2026-05-26T14:30:00Z + metrics: + c0: 70 + c1: 68.4 `) var stdout bytes.Buffer @@ -49,7 +47,7 @@ evidence: } } -func TestFireBlockedWithGobceAdapter(t *testing.T) { +func TestFireBlockedWithInlineC1Coverage(t *testing.T) { dir := t.TempDir() standardPath := writeFile(t, dir, "standard.yaml", `id: test gates: @@ -64,18 +62,14 @@ gates: threshold: min: 70 `) - writeFile(t, dir, "gobce.json", `{ - "language": "go", - "statementCoverage": 70, - "estimatedBranchCoverage": 68.4, - "uncoveredBranches": [] -}`) manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 evidence: coverage: - inputs: - - adapter: gobce-json - path: gobce.json + module: gobce + measured_at: 2026-05-26T14:30:00Z + metrics: + c0: 70 + c1: 68.4 `) var stdout bytes.Buffer @@ -95,7 +89,7 @@ evidence: } } -func TestFireReadyWithGoCoverprofileAdapter(t *testing.T) { +func TestFireReadyWithInlineC0Coverage(t *testing.T) { dir := t.TempDir() standardPath := writeFile(t, dir, "standard.yaml", `id: test gates: @@ -104,16 +98,13 @@ gates: required: true threshold: min: 70 -`) - writeFile(t, dir, "coverage.out", `mode: set -example.go:1.1,2.1 7 1 `) manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 evidence: coverage: - inputs: - - adapter: go-coverprofile - path: coverage.out + module: gobce + metrics: + c0: 70 `) var stdout bytes.Buffer @@ -130,7 +121,7 @@ evidence: } } -func TestFireBlocked(t *testing.T) { +func TestFireBlockedWithInlineC0Coverage(t *testing.T) { dir := t.TempDir() standardPath := writeFile(t, dir, "standard.yaml", `id: test gates: @@ -139,17 +130,13 @@ gates: required: true threshold: min: 80 -`) - coverprofilePath := writeFile(t, dir, "coverage.out", `mode: set -example.go:1.1,2.1 7 1 -example.go:3.1,4.1 3 0 `) manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 evidence: coverage: - inputs: - - adapter: go-coverprofile - path: coverage.out + module: gobce + metrics: + c0: 70 `) var stdout bytes.Buffer @@ -167,7 +154,34 @@ evidence: if !strings.Contains(stdout.String(), "HADO: BLOCKED") { t.Fatalf("stdout = %q, want blocked status", stdout.String()) } - if coverprofilePath == "" { - t.Fatal("coverprofilePath should not be empty") +} + +func TestFireRequiresInlineCoverageMetrics(t *testing.T) { + dir := t.TempDir() + standardPath := writeFile(t, dir, "standard.yaml", `id: test +gates: + - id: test.c0_coverage + severity: critical + required: true + threshold: + min: 80 +`) + manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 +evidence: + coverage: + module: gobce +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode, err := Run([]string{ + "--standard", standardPath, + "--manifest", manifestPath, + }, &stdout, &stderr) + if err == nil { + t.Fatal("run fire error = nil, want missing metrics error") + } + if exitCode != 2 { + t.Fatalf("exit code = %d, want 2", exitCode) } } diff --git a/cmd/hado/fire/run.go b/cmd/hado/fire/run.go index 0eb6a42..d800d67 100644 --- a/cmd/hado/fire/run.go +++ b/cmd/hado/fire/run.go @@ -7,7 +7,6 @@ import ( "io" "path/filepath" - "github.com/keyskey/hado/internal/coverage" "github.com/keyskey/hado/internal/gate" "github.com/keyskey/hado/internal/manifest" "github.com/keyskey/hado/internal/standard" @@ -45,18 +44,15 @@ func Run(args []string, stdout, stderr io.Writer) (int, error) { return 2, err } - adapterInputs := hadoManifest.CoverageAdapterInputs() - if requiresCoverage(readinessStandard) && len(adapterInputs) == 0 { - return 2, fmt.Errorf("fire requires evidence.coverage.inputs in manifest") - } metrics := gate.Metrics{} - if len(adapterInputs) > 0 { - coverageMetrics, err := coverage.ParseAdapterInputs(adapterInputs) - if err != nil { - return 2, err + if hadoManifest.Evidence.Coverage != nil { + metrics.C0CoveragePercent = hadoManifest.CoverageC0() + metrics.C1CoveragePercent = hadoManifest.CoverageC1() + } + if requiresCoverage(readinessStandard) { + if hadoManifest.Evidence.Coverage == nil || hadoManifest.Evidence.Coverage.Metrics == nil { + return 2, fmt.Errorf("fire requires evidence.coverage.metrics in manifest; run hado charge") } - metrics.C0CoveragePercent = coverageMetrics.C0Coverage - metrics.C1CoveragePercent = coverageMetrics.C1Coverage } applyManifestEvidence(&metrics, hadoManifest) diff --git a/cmd/hado/target/run_test.go b/cmd/hado/target/run_test.go index 02beac2..0854fac 100644 --- a/cmd/hado/target/run_test.go +++ b/cmd/hado/target/run_test.go @@ -137,8 +137,8 @@ gates: if err != nil { t.Fatal(err) } - if m.Evidence.Coverage == nil || len(m.Evidence.Coverage.Inputs) == 0 { - t.Fatal("expected coverage scaffold input") + if m.Evidence.Coverage == nil || m.Evidence.Coverage.Module != "gobce" { + t.Fatal("expected coverage scaffold module") } if m.Evidence.Operations == nil || m.Evidence.Operations.Owner != "" { t.Fatalf("owner = %+v", m.Evidence.Operations) @@ -176,7 +176,7 @@ gates: if err != nil { t.Fatal(err) } - if m.Evidence.Coverage != nil && len(m.Evidence.Coverage.Inputs) != 0 { - t.Fatalf("expected no coverage inputs, got %+v", m.Evidence.Coverage.Inputs) + if m.Evidence.Coverage != nil && m.Evidence.Coverage.Module != "" { + t.Fatalf("expected no coverage module, got %+v", m.Evidence.Coverage) } } diff --git a/docs/architecture.md b/docs/architecture.md index 3017371..fc3f51f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,12 +23,9 @@ hado │ └── deployment and release decision layer │ ├── hado-modules -│ ├── github +│ ├── gobce │ ├── datadog │ ├── notion -│ ├── slack -│ ├── sbom -│ ├── openapi │ ├── terraform │ └── custom modules │ @@ -98,18 +95,16 @@ HADO 本体リポジトリは、root に `go.mod` を置く単一 Go module と ├── internal │ ├── manifest # HADO Manifest の読み込み・standard パス解決・evidence スキャフォールド │ ├── standard # Readiness Standard の読み込みと検証 -│ ├── coverage # coverage artifact の adapter と正規化 metric +│ ├── coverage # HADO 内部の正規化 coverage metric +│ ├── modulerunner # 外部 module の JSON-over-stdio 実行 │ ├── gate # gate 評価と最終 decision │ ├── integration # 外部 SaaS API の薄いラッパ。manifest や gate ID を import しない │ │ └── datadog # Datadog Monitor 一覧など。公式 datadog-api-client-go を利用 │ ├── modules -│ ├── github +│ ├── gobce # HADO 同梱の gobce wrapper module(modules/gobce/cmd/hado-gobce) │ ├── datadog -│ ├── notion -│ ├── slack -│ ├── sbom -│ └── openapi +│ └── notion │ ├── standards │ ├── web-service.yaml @@ -125,7 +120,7 @@ HADO 本体リポジトリは、root に `go.mod` を置く単一 Go module と このツリーは、このリポジトリで **いま実在する** `internal` パッケージを反映している。将来、スコア計算やレポート生成、評価オーケストレーションを独立パッケージに切り出す場合は、その時点の責務に合わせて `internal` 配下に追加する。 -`gobce` はこのリポジトリに同居させない。別リポジトリで library / CLI / HADO module interface を持たせ、HADO 側はその実行結果を module execution contract 経由で取り込む。 +`gobce` 本体はこのリポジトリに同居させない。HADO 側には薄い `hado-gobce` wrapper module だけを置き、別リポジトリの `gobce` CLI 実行結果を module execution contract に変換して取り込む。 ## CLI の 3 段階(target → charge → fire) @@ -153,7 +148,7 @@ hado charge --manifest hado.yaml --standard ... | v manifest のメタデータ(repo URL、Datadog service 等)と target で入れた値を手がかりに、 - 未充足の evidence を module / adapter / ローカルコマンドで埋める(主な出力は更新された manifest と参照パス) + 未充足の evidence を module / discovery で埋める(主な出力は更新された manifest) | v hado fire --manifest hado.yaml --standard ... @@ -167,9 +162,9 @@ JSON / Markdown / GitHub PR comment / CI exit code を出力(デプロイは **`hado target` と manifest:** `internal/manifest` の **`ResolveStandardPath`** と **`ApplyEvidenceScaffold`** で、解決した Readiness Standard に応じた **evidence のひな形**(空文字のフィールドなど)を manifest にマージする。 -**`hado charge` の責務:** manifest と CLI 入力(`--coverage-input`)から coverage evidence input を解決し、manifest の `evidence.coverage.inputs` に不足分だけマージして保存する。既存値は置換しない。 +**`hado charge` の責務:** manifest の `evidence.coverage.module` を読み、coverage module を実行して `evidence.coverage.measured_at` と `metrics` を manifest に書き戻す。module failure や invalid result の場合は manifest を保存しない。 -**`hado fire` の責務:** 更新済み manifest と Standard だけを見た **機械的な gate 判定**とレポート出力を行う。`--manifest` は必須、`--standard` は任意(未指定時は manifest の `standard.id`、指定時は上書き)。 +**`hado fire` の責務:** 更新済み manifest と Standard だけを見た **機械的な gate 判定**とレポート出力を行う。coverage module などの外部 process は起動しない。`--manifest` は必須、`--standard` は任意(未指定時は manifest の `standard.id`、指定時は上書き)。 ## ドメインモデル @@ -234,8 +229,11 @@ standard: evidence: coverage: - go: - coverprofile: coverage.out + module: gobce + measured_at: 2026-05-26T14:30:00Z + metrics: + c0: 82.1 + c1: 68.4 observability: slo_file: slo.yaml @@ -246,12 +244,6 @@ evidence: runbook_url: https://www.notion.so/example/order-api-runbook rollback_plan: docs/rollback.md -modules: - - id: hado.gobce - - id: hado.datadog - - id: hado.notion - - id: hado.slack - report: formats: - json @@ -259,7 +251,7 @@ report: github_pr_comment: true ``` -インフラ readiness や多数の standard パターンを抱えるときでも **マニフェストのトップレベル構造を増やさない**方針と、本体を薄く保つための **Adapter / Analyzer / Module の分担**については、[Infrastructure Readiness とマニフェスト設計](infrastructure-readiness-and-manifest-design.md) にまとめる。上記 YAML は論理モデルの例であり、長期的には `evidence` を adapter 参照や evidence bundle などの安定した形へ寄せることを想定する。 +インフラ readiness や多数の standard パターンを抱えるときでも **マニフェストのトップレベル構造を増やさない**方針と、本体を薄く保つための **Analyzer / Module の分担**については、[Infrastructure Readiness とマニフェスト設計](infrastructure-readiness-and-manifest-design.md) にまとめる。上記 YAML は論理モデルの例であり、長期的には `evidence` を module 指定や evidence bundle などの安定した形へ寄せることを想定する。 ## Readiness Standard @@ -308,9 +300,9 @@ Module は HADO を language-agnostic / vendor-neutral に保つための拡張 Coverage は特に producer ごとの差が大きい。Go の coverprofile、keyskey/gobce JSON、JaCoCo XML、lcov、Istanbul JSON などを HADO core が直接 gate として -解釈するのではなく、adapter が `test.c0_coverage` / `test.c1_coverage` の +解釈するのではなく、coverage module が `test.c0_coverage` / `test.c1_coverage` の 正規化済み metric に変換する。HADO core は言語、ライブラリ、推定値か実測値か -を判定せず、Readiness Standard の threshold と正規化済み metric だけを照合する。 +を判定せず、Readiness Standard の threshold と manifest に保存された metric だけを照合する。 ### Module 種別 @@ -346,38 +338,50 @@ HADO が timeout, exit code, schema validation を管理する これにより、Go、TypeScript、Python、Rust など、どのランタイムでも module を書きやすくなる。将来的に性能や streaming が必要になったら、gRPC や WASM に移行できる。 -### Coverage adapter contract +### Coverage module contract -Coverage adapter は、各言語・各ツールの coverage artifact を HADO の +Coverage module は、各言語・各ツールの coverage artifact を HADO の 正規化 coverage metric へ変換する境界である。HADO core の gate evaluator は JaCoCo、lcov、Istanbul、gobce、gobco などの producer-specific schema を直接扱わない。 -CLI では次の形式で `charge` に adapter と artifact を指定し、manifest に反映する。 +CLI では manifest に module だけを指定し、`charge` が module を実行して manifest に結果を書き戻す。 -```bash -hado charge \ - --manifest hado.yaml \ - --coverage-input hado-json:coverage-metrics.json +```yaml +evidence: + coverage: + module: gobce +``` -hado charge \ - --manifest hado.yaml \ - --coverage-input gobce-json:gobce.json +```bash +hado charge --manifest hado.yaml ``` -正規化後の metric は C0 / C1 の coverage percentage である。 +RunResult の metric は C0 / C1 の coverage percentage である。 ```json { - "c0Coverage": 82.1, - "c1Coverage": 68.4 + "apiVersion": "hado.dev/v1", + "metrics": [ + {"id": "test.c0_coverage", "value": 82.1, "unit": "percent"}, + {"id": "test.c1_coverage", "value": 68.4, "unit": "percent"} + ] } ``` -今後の Java / TypeScript 対応は、`jacoco-xml`、`lcov`、`istanbul-json` -などの adapter を追加して同じ metric に変換する。標準側の gate ID は +`charge` 後の manifest は次の inline metrics を持つ。標準側の gate ID は 言語や tool に依存せず、`test.c0_coverage` / `test.c1_coverage` のままにする。 -Adapter contract の未決事項は [未解決課題](open-design-decisions.md) で管理する。 +```yaml +evidence: + coverage: + module: gobce + measured_at: 2026-05-26T14:30:00Z + metrics: + c0: 82.1 + c1: 68.4 +``` + +Module contract の未決事項は [未解決課題](open-design-decisions.md) で管理する。 ### Module Manifest @@ -392,9 +396,7 @@ metadata: runtime: type: exec - command: gobce - args: - - hado-module + command: hado-gobce capabilities: emits: diff --git a/docs/gobce.md b/docs/gobce.md index 906c55b..6ab0e2d 100644 --- a/docs/gobce.md +++ b/docs/gobce.md @@ -60,18 +60,11 @@ gobce analyze \ ## HADO 連携 `gobce` は C1 coverage producer の一例である。HADO core は `gobce` 固有の -JSON schema や、その値が推定値か実測値かを解釈しない。HADO に渡す前に、 -producer や adapter が正規化済み coverage metrics に変換する。 +JSON schema や、その値が推定値か実測値かを解釈しない。HADO 同梱の +`hado-gobce` wrapper module が `gobce analyze` を呼び、Module Runner contract の +RunResult に変換する。 -HADO の `gobce-json` adapter は、2026-04 時点の `keyskey/gobce` main branch -で確認した JSON output を次のように正規化する。 - -```text -statementCoverage -> c0Coverage -estimatedBranchCoverage -> c1Coverage -``` - -`gobce` を HADO analyzer module として使う場合は次を出す。 +`hado-gobce` は HADO analyzer module として RunResult に次を出す。 ```text metrics: @@ -82,6 +75,9 @@ findings: test.uncovered_branch ``` +初期 HADO 実装では findings は manifest に保存せず、`test.c0_coverage` / +`test.c1_coverage` だけを `evidence.coverage.metrics.c0` / `metrics.c1` に書き戻す。 + Readiness Standard 側では、この metric を gate に使う。 ```yaml diff --git a/docs/hado.manifest.reference.yaml b/docs/hado.manifest.reference.yaml index 1b11fe1..80851da 100644 --- a/docs/hado.manifest.reference.yaml +++ b/docs/hado.manifest.reference.yaml @@ -16,14 +16,18 @@ standard: id: "" # 本番準備の証跡宣言。ゲートごとに必要なブロックだけでよい(各サブブロックは多くが `omitempty`)。 (論理型: object) evidence: - # カバレッジ成果物と adapter(ブロック)。C0/C1 ゲートがある standard で必要。 (論理型: object) + # カバレッジ計測 module と、`hado charge` が書き戻す計測結果(ブロック)。C0/C1 ゲートがある standard で必要。 (論理型: object) coverage: - # `CoverageInput` の配列。 (論理型: array of object) - inputs: - # パーサ名。`hado-json` / `go-coverprofile` / `gobce-json` など(実装は `internal/coverage`)。 (論理型: string) - - adapter: "hado-json" - # リポジトリまたは manifest 相対の成果物パス。 (論理型: string) - path: "coverage-metrics.json" + # coverage 計測に使う module。現行 MVP は `gobce`。 (論理型: string) + module: "gobce" + # coverage module を実行して metrics を書き戻した UTC RFC3339 時刻。 (論理型: string) + measured_at: "2026-05-26T14:30:00Z" + # coverage module の正規化済み計測値(ブロック)。`hado fire` はこの inline metrics だけを評価する。 (論理型: object) + metrics: + # C0 coverage percentage。`test.c0_coverage` の判定に使う。 (論理型: float) + c0: 82.1 + # C1 coverage percentage。`test.c1_coverage` の判定に使う。 (論理型: float) + c1: 68.4 # 運用責任と障害対応の入口(ブロック)。 (論理型: object) operations: # オーナー(チーム名・Slack チャンネル等)。`operations.owner_exists` で非空判定。 (論理型: string) diff --git a/docs/implementation-status.md b/docs/implementation-status.md index cc44f5b..8c67019 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -9,8 +9,8 @@ | 引数なし | 一行ヘルプ | | `version` / `-v` / `--version` | 実装済み | | `target` | 実装済み(`--manifest` 必須。TTY / フラグで `service` / `standard`。既定で resolved standard に応じ **evidence のスキャフォールド**(空文字のキーなど)をマージ) | -| `charge` | 実装済み(`--manifest` 必須。coverage artifact の adapter/path を manifest `evidence.coverage.inputs` に不足分マージ。既存値は置換しない。**`--datadog-discover`** で Datadog Monitor を探索し **`evidence.observability.monitor.refs`** に `discovery_type: auto` を upsert) | -| `fire` | 実装済み(`--manifest` 必須。判定専用。manifest の evidence を gate 評価して READY/BLOCKED/ERROR を返す) | +| `charge` | 実装済み(`--manifest` 必須。coverage gate がある場合は `evidence.coverage.module` の module を実行し、`measured_at` と `metrics` を manifest に書き戻す。**`--datadog-discover`** で Datadog Monitor を探索し **`evidence.observability.monitor.refs`** に `discovery_type: auto` を upsert) | +| `fire` | 実装済み(`--manifest` 必須。外部 process は起動せず、manifest の inline evidence を gate 評価して READY/BLOCKED/ERROR を返す) | | `manifest doc` | 実装済み(`hado manifest doc [--out path]`。コメント付き参考 manifest を stdout またはファイルへ出力。YAML パスは `types.go` からリフレクションで生成、説明は `field_docs.go`。リポジトリ既定の保存先は [hado.manifest.reference.yaml](hado.manifest.reference.yaml)。`make gen-manifest-doc`) | `evaluate` は廃止し、`target` / `charge` / `fire` / `manifest doc` で CLI を構成する。 @@ -30,7 +30,7 @@ - `--manifest`(必須) - `--standard`(任意。未指定時は manifest の `standard.id` を利用) -- `--coverage-input`(繰り返し可; `:`。**指定時も既存 manifest 値は置換せず不足分だけマージ**) +- `--coverage-module`(任意。指定時は `evidence.coverage.module` に書き、その module を実行) - `--datadog-discover`(`evidence.observability.monitor.discovery.datadog` に従って Datadog Monitor を探索。`DD_API_KEY` / `DD_APP_KEY` 必須、任意 `DD_SITE`。0 件・複数件・API 失敗時は exit 2 で **manifest を書き換えない**) `fire` の主なフラグ(`cmd/hado/fire/run.go`): @@ -45,11 +45,11 @@ `--output text` は各 gate の判定行に `severity` を表示し、FAIL 行には「リリース前に必須対応か / リリース後対応可か」の運用ヒントを併記する。総合判定(`HADO: READY/BLOCKED/ERROR`)は一覧の最後に出力する。TTY では ANSI カラーを付与し、`PASS` は緑、`FAIL` は赤/黄(required+critical の FAIL を最強調)で表示する(`NO_COLOR` が設定されている場合は無効)。 -**Coverage 入力の必須条件:** Readiness Standard が `test.c0_coverage` または `test.c1_coverage` のいずれかを含む場合、`charge` で `--coverage-input` を渡すか、manifest の `evidence.coverage.inputs` が必要。`fire` 実行時にどちらも無いとエラー終了(exit 2)。 +**Coverage 入力の必須条件:** Readiness Standard が `test.c0_coverage` または `test.c1_coverage` のいずれかを含む場合、`charge` には manifest の `evidence.coverage.module`(または `--coverage-module`)が必要。`fire` は `evidence.coverage.metrics` が無いとエラー終了(exit 2)。 終了コード: `0` = ready、`1` = blocked(`required: true` かつ `severity: critical` の gate が失敗)、`2` = error(引数・読み込み・未対応 gate など)。 -**未実装の例:** `--output markdown`、module runner、score / exception フィールド。 +**未実装の例:** `--output markdown`、score / exception フィールド。 ## 実装済みゲート(`internal/gate/evaluate.go` の `switch` 順) @@ -82,13 +82,17 @@ required として宣言されているが、ここに無い gate id は **error `severity` 未指定は `minor` と同等に扱う(非ブロック)。 -## Coverage adapter(`internal/coverage/parse.go` の `ParseAdapterInput`) +## Module runner / Coverage module -`--coverage-input` および manifest の `evidence.coverage.inputs[].adapter` に使える文字列(`types.go` の `Format*` 定数と一致): +`internal/modulerunner` は外部 executable に RunRequest JSON を stdin で渡し、RunResult JSON を stdout から読む。timeout、stdout size limit、stderr diagnostics、non-zero exit、invalid JSON、context cancel を識別する。 -- `hado-json`(正規化 JSON の `c0Coverage` / `c1Coverage`) -- `go-coverprofile`(C0 のみ) -- `gobce-json`(C0 / C1; `keyskey/gobce` の JSON) +現在の coverage module mapping: + +- `evidence.coverage.module: gobce` → `hado-gobce` + +`hado-gobce` は HADO 同梱の wrapper module executable。`gobce analyze --coverprofile --format json --output ` を呼び、gobce JSON を Module Runner の RunResult に変換する。`HADO_COVERPROFILE` があればその path を使い、未指定時は `coverage.out` を読む。 + +module result の `test.c0_coverage` / `test.c1_coverage` は `evidence.coverage.metrics.c0` / `metrics.c1` に保存される。module identity / version は初期 manifest には保存しない。 ## Manifest(`internal/manifest`) @@ -98,7 +102,7 @@ Manifest の **全プロパティが列挙された参考 YAML**(各キーに - `evidence.observability`(`slo` / `monitor` / `dashboard` を役割別レーンとして分割。各レーンに `refs`(`discovery_type: manual|auto`)・任意 `provider`・`discovery`。**YAML 配列だけ**の書き方は各レーンの `refs` 省略形として解釈される。詳細は [Observability readiness と manifest](observability-readiness.md)。) - `evidence.infra`(`deployment_spec`) - `evidence.release`(`rollback_plan`; `automation.workflow_refs`, 任意で `automation.systems`) -- `evidence.coverage.inputs`(`adapter`, `path`) +- `evidence.coverage`(`module`, `measured_at`, `metrics.c0`, `metrics.c1`) ## 外部連携(`internal/integration`) @@ -108,6 +112,6 @@ Manifest の **全プロパティが列挙された参考 YAML**(各キーに 計画全体は [roadmap.md](roadmap.md)。コードにまだ無い例: -- module runner(JSON-over-stdio 等)、インフラ向け threshold 型 gate(例: PDB の数値比較)、Markdown レポート、GitHub PR 連携 +- インフラ向け threshold 型 gate(例: PDB の数値比較)、Markdown レポート、GitHub PR 連携 - Datadog の SLO / Dashboard 自動解決、Rollbar / PagerDuty 等の他ベンダー charge - `test.uncovered_branch` など gobce findings の評価結果への載せ方 diff --git a/docs/infrastructure-readiness-and-manifest-design.md b/docs/infrastructure-readiness-and-manifest-design.md index 9d72228..a6c96cc 100644 --- a/docs/infrastructure-readiness-and-manifest-design.md +++ b/docs/infrastructure-readiness-and-manifest-design.md @@ -22,7 +22,7 @@ Infrastructure Readiness は、実行基盤(Kubernetes / ECS / Cloud Run / App | --- | --- | --- | | 「何を満たせばよいか」(gate、閾値、必須度) | Readiness Standard(YAML、複数ファイル可) | 組織・基盤・タイプ・Tier ごとにファイルが増える | | 「このサービスは誰で、証拠はどこから取るか」 | HADO Manifest(**安定した少数のブロック**) | モジュール数が増えても **トップレベルの形は変えない** | -| プロデューサ固有のファイル形式 → 正規化 metric | Adapter(in-process が既定) | adapter 種別の列挙は増えるが、manifest は `adapter` + `path`(または同等の参照)の繰り返しに留める | +| プロデューサ固有のファイル形式 → 正規化 metric | Module(別プロセスが既定) | module 種別は増えるが、manifest は利用者が選ぶ module 名と正規化済み結果に留める | | リポジトリ内の成果物の解析(IaC / マニフェストの深い意味) | Analyzer(プロセス内ライブラリまたは別プロセス module) | 実装とリリース単位は本体から分離 | | 外部 API / SaaS / クラウド制御面 | Module(別プロセス想定) | SDK や認証は module 側 | @@ -44,7 +44,7 @@ Infrastructure Readiness は、実行基盤(Kubernetes / ECS / Cloud Run / App - 新しい module ごとに **専用ブロックが足される**。 - バージョンアップのたびに **サービスリポジトリ側の hado.yaml を書き換える**頻度が上がる。 -これは「マニフェストがサービスの入口である」という利点を損ないやすい。そこで **カスタマイズの重心を Standard 側に寄せ**、Manifest には **「どの standard を使うか」「証拠の参照(パス・adapter・module 起動に必要な最小の束)」**だけを載せる。 +これは「マニフェストがサービスの入口である」という利点を損ないやすい。そこで **カスタマイズの重心を Standard 側に寄せ**、Manifest には **「どの standard を使うか」「利用者が選ぶ module と module が書き戻した正規化済み evidence」**だけを載せる。 インフラの詳細(何をチェックするかの列挙)は **Standard の束(複数 YAML の合成結果)**として表現し、Manifest は **その束を指す 1 本の論理参照**(ファイルパス、または将来の catalog ID)で足りるようにする。 @@ -80,22 +80,18 @@ Manifest は **サービスのアイデンティティ**と **証拠への参照 version service # 名前、owner、tier、language、論理カタログ参照 など(既存方針を踏襲) standard # 適用する readiness standard の参照(1 本または合成の入口) -evidence # 「どの adapter / どのパス」「どの module に何を渡すか」の宣言 -modules # (任意)module runner 導入後も、manifest では「起動する module の一覧と version 固定」程度に留める +evidence # 「どの module を使うか」と、charge が書き戻した正規化済み evidence ``` ### `evidence` の安定化の鍵: 参照と束ね方 プラットフォーム固有の構造は `evidence` の **直下にキーを増やして表現しない**。代わりに、次のいずれか(または併用)で表現する。 -1. **Adapter 参照のリスト**(coverage で既に近い形) - `adapter` + `path`(+必要なら `id` や `labels` など最小メタデータ)の繰り返し。新しいツールが増えても **配列要素の種類が増えるだけ**で、スキーマの木構造は変わらない。 - -2. **Evidence bundle(成果物の束)** +1. **Evidence bundle(成果物の束)** CI が生成する `evidence-bundle.json` のような **1 ファイル**に、複数の成果物パスやハッシュを載せ、Manifest からはその **束へのパス 1 本**だけを指す。詳細なキーは bundle 側の schema で進化させ、HADO Manifest は不変に近づける。 -3. **Module への入力** - Module に渡すのは **RunRequest 内の evidence 参照**であり、Manifest 側は「どの module をどの version で起動するか」と「リポジトリルートから見た入力の束」程度に留める。クラウド固有のパラメータは **module 設定ファイル**(別ファイル、または bundle 内)に逃がし、Manifest はそのファイルへのパスを 1 本持つだけにできる。 +2. **Module への入力** + Module に渡すのは **RunRequest 内の evidence 参照**であり、Manifest 側は「どの module を起動するか」程度に留める。クラウド固有のパラメータは **module 設定ファイル**(別ファイル、または bundle 内)に逃がし、Manifest はそのファイルへのパスを 1 本持つだけにできる。 --- @@ -110,11 +106,6 @@ modules # (任意)module runner 導入後も、manifest では「 ・プロデューサ形式を内部表現に落とす | v - Adapter(必要ならここでもう一段) - ・単一ファイル形式(terraform show JSON, helm template, k8s yaml など)を - 「gate ID に紐づけ可能な metric / boolean」へ - | - v [正規化 EvaluationContext] | v @@ -124,15 +115,14 @@ modules # (任意)module runner 導入後も、manifest では「 - **Module**: 外部システムや重い依存を隔離。言語非依存の拡張点。 - **Analyzer**: リポジトリ内の IaC / マニフェストを読み、**組織独自のルール**を含めたいときの主戦場。別プロセスの module として配布してもよい。 -- **Adapter**: 「ファイル形式 → 既知の metric ID」への写像。本体に近いが、**新しい adapter の追加で manifest のトップレベルは変えない**。 --- ## Infrastructure Readiness における評価の流れ(論理) -1. **Manifest** から `standard` 参照と `evidence` / `modules` の参照を読む。 +1. **Manifest** から `standard` 参照と `evidence` の参照を読む。 2. **Standard 解決**で、組織・タイプ・基盤・Tier を反映した **gate 一覧**を得る。 -3. **Orchestration**(本体の薄い層)が、必要な adapter / module を起動し順序づけ、**EvaluationContext**(gate ID → 値)を埋める。 +3. **Orchestration**(本体の薄い層)が、必要な module を起動し順序づけ、**EvaluationContext**(gate ID → 値)を埋める。 4. **Gate evaluator** が Standard と EvaluationContext だけを見て pass/fail を決める。 CLI の **target / charge / fire** に写すと、1〜2 は主に **target**(manifest に残す照準・standard 解決・**evidence スキャフォールド**)、3 は **charge**(manifest を読み書きしながら evidence 参照を埋める)、4 は **fire** に相当する。**正本は manifest** とし、`.hado/context.json` のような中間ファイルを必須にしない方針は [概要](overview.md) を参照する。詳細は [アーキテクチャ](architecture.md) の「CLI の 3 段階」を参照する。 diff --git a/docs/local-development.md b/docs/local-development.md index c4bc7c0..d36bc1a 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -41,7 +41,7 @@ make setup-hooks Go は **PATH 上の `go`** か **`make` が参照する `.tools/go`**(`bootstrap-go` 済み)があればよい。Docker が起動している必要がある(YAML / Markdown lint 用)。 -CI の [Test ワークフロー](https://github.com/keyskey/hado/blob/main/.github/workflows/test.yml) は `setup` のあと `readiness-check`(coverage・gobce・fire)を実行する。ローカルで合わせるときは `make readiness-check`(事前に `make setup`)。 +CI の [Test ワークフロー](https://github.com/keyskey/hado/blob/main/.github/workflows/test.yml) は `setup` のあと `readiness-check`(coverage・`hado-gobce` wrapper・`charge`・`fire`)を実行する。ローカルで合わせるときは `make readiness-check`(事前に `make setup`)。 無効にする場合: `git config --unset core.hooksPath`(またはリポジトリの config から該当行を削除)。 @@ -54,7 +54,7 @@ make fmt # Go ファイルを gofmt で整形 make fmt-check # Go の整形漏れチェック (CI 相当) make test # Go テスト make gen-manifest-doc # docs/hado.manifest.reference.yaml を型から再生成 -make readiness-check # coverage evidence 生成 + HADO 自身の readiness 評価 +make readiness-check # coverage 生成 + charge/fire による HADO 自身の readiness 評価 make ci-lint # fmt-check + lint(CI Lint ジョブ・pre-push と同じ; go test なし) make pre-pr # ci-lint + test(ローカルで PR 前にまとめて叩く用) make setup-hooks # pre-push を有効化 @@ -68,4 +68,4 @@ make setup-hooks # pre-push を有効化 実装を変えたあとのドキュメント更新は、`.cursor/rules/hado-implementation-docs.mdc` と Skill `hado-doc-sync` を参照してください。 -`make readiness-check` は `coverage.out` と `hado-coverage.json` を生成します。これらはローカル/CI の生成物として Git 管理から除外しています。 +`make readiness-check` は `coverage.out` を生成し、temp manifest に `hado charge` で coverage metrics を書き戻してから `hado fire` を実行します。`coverage.out` はローカル/CI の生成物として Git 管理から除外しています。 diff --git a/docs/open-design-decisions.md b/docs/open-design-decisions.md index bb99993..ebba7e2 100644 --- a/docs/open-design-decisions.md +++ b/docs/open-design-decisions.md @@ -209,7 +209,17 @@ Readiness Target - SARIF 互換を持たせるか - AI agent 向け report format を別に持つか -## Coverage adapter and evidence contract +## Internal evaluation model naming + +TODO: + +- `gate.Metrics` は HADO core 内部の評価入力として汎用名すぎるため、別ブランチで `gate.Shot` へのリネームを検討する。 + - `Manifest`: `charge` 後に完成する evidence の保存形式 + - `Gate`: Standard が宣言する標的 / 判定ルール + - `Shot`: `fire` が Manifest から生成し、Gate に向けて評価する入力 + - 外部 schema の `metrics`(manifest coverage metrics / module RunResult metrics)は維持し、比喩は core 内部に閉じる。 + +## Coverage module and evidence contract 決定済み: @@ -217,15 +227,14 @@ Readiness Target C0 / C1 coverage は producer-neutral な metric として扱う。 HADO core は gobce / gobco / JaCoCo / lcov / Istanbul などの計測器や、 推定値 / 実測値の違いを判定しない。 -producer-specific artifact は adapter が HADO の normalized coverage metrics へ変換する。 +producer-specific artifact は coverage module が HADO の normalized coverage metrics へ変換する。 -HADO Manifest は coverage evidence を `evidence.coverage.inputs` で宣言する。 -各 input は `adapter` と `path` を持ち、相対 path は manifest file の directory -から解決する。 +HADO Manifest は coverage module を `evidence.coverage.module` で宣言する。 +`hado charge` は module を実行し、`measured_at` と `metrics.c0` / `metrics.c1` +を manifest に直接書き戻す。 -`hado fire`(判定コマンド)が `--manifest hado.yaml` から coverage input を読める。 -`--coverage-input` が指定された場合は direct CLI override として扱い、 -manifest の coverage input より優先する。 +`hado fire`(判定コマンド)は module を実行せず、manifest inline metrics だけを読む。 +`--coverage-module` が指定された場合は manifest の module 指定を補助的に更新してから実行する。 ``` 未決: @@ -236,37 +245,37 @@ manifest の coverage input より優先する。 - producer version - metric source が estimated か measured か - 対象 package / module / path scope - - confidence や adapter warning を表現するか + - confidence や module warning を表現するか - 複数 coverage evidence が同じ metric を出した場合の merge ルール - 後勝ちにするか - 明示 priority を持たせるか - conflict として evaluation error にするか - report に overwritten evidence を残すか -- coverage metric の意味を adapter がどこまで保証するか +- coverage metric の意味を module がどこまで保証するか - Java / Kotlin の JaCoCo branch coverage を HADO の C1 とみなす条件 - JavaScript / TypeScript の lcov / Istanbul branch coverage を HADO の C1 とみなす条件 - Go coverprofile 由来 C0 と他言語 C0 の比較可能性 -- producer-specific adapter の schema versioning +- producer-specific module の schema versioning - `keyskey/gobce` は pre-1.0 で JSON output が変わる可能性がある - - adapter が producer version を検出するか - - adapter version と producer version の compatibility をどう表現するか -- HADO Manifest の coverage input 宣言を将来拡張する schema + - module が producer version を検出するか + - module version と producer version の compatibility をどう表現するか +- HADO Manifest の coverage 宣言を将来拡張する schema - CI matrix / monorepo で複数 coverage artifact を扱う表現 - language autodetection を許可するか - input ごとの include / exclude / scope をどう表現するか -- adapter warning / recommendation を report に含める形式 +- module warning / recommendation を report に含める形式 - generated code の除外漏れ - unsupported coverage feature - partial parse - source path resolution failure - report schema の coverage evidence 表現 - gate result には normalized metric の値を出す - - 詳細 report には adapter / source artifact / producer metadata を出すか + - 詳細 report には module / source artifact / producer metadata を出すか - 監査用途で元 artifact への参照を保持するか Go / gobce 固有で残る未決: -- producer が HADO module として動く場合の CLI サブコマンド名 +- producer が HADO module として動く場合の CLI サブコマンド名(初期 mapping は HADO 同梱 wrapper の `hado-gobce`) - coverprofile と source path の解決方法 - generated code の除外ルール - monorepo 内 Go module の扱い diff --git a/docs/overview.md b/docs/overview.md index d275d8f..3aa29e5 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -89,7 +89,7 @@ HADO = 出航前に「撃てる状態」を証明する readiness amplif 2. hado charge … 充填(証跡を集め、manifest を埋める) **target で回収した値**と、manifest に **すでに書かれているサービスメタデータ**(リポジトリ URL、Datadog の APM service 名や service catalog 参照など)を入力にし、Readiness Standard に照らして **まだ埋まっていない evidence を自動で埋める**。 例: `hado charge --datadog-discover` と `DD_API_KEY` / `DD_APP_KEY` で Datadog API から Monitor を特定し、`evidence.observability.monitor.refs` に `discovery_type: auto` を upsert する([Observability readiness と manifest](observability-readiness.md))。 - 外部 module の実行、adapter による正規化、CI 内の `go test` 成果物の取り込みなどはここに集約されうる。永続の主たる形は **更新後の manifest**(および manifest が指すパス上の artifact)である。 + 外部 module の実行、正規化、CI 内の `go test` 成果物の取り込みなどはここに集約されうる。永続の主たる形は **更新後の manifest** である。 3. hado fire … 発射判定(gate を評価し、リリース可否だけを返す) 充填済みの manifest(と参照 artifact)と Standard を照合し、gate を評価する。**デプロイはしない**(波動砲の「射撃」に相当する本番反映は既存の CD に任せる)。HADO は **release gate の意思決定**だけを返す(`hado fire` は「この変更を本番に出してよいか」の判定に相当する)。 @@ -106,7 +106,7 @@ HADO = 出航前に「撃てる状態」を証明する readiness amplif - **フラグでの `target`:** 対話なしで `--manifest` / `--standard` / `--service` だけ更新するモードも用意できる(スクリプト・自動化向け)。 - `--standard` には **短い alias** を許してもよい(例: `exchange-critical` → `ec`)。組織の運用に合わせて定義する。 -**実装との関係:** 現リポジトリは **`hado target` / `hado charge` / `hado fire`** を正とする。`target` は manifest の `service` / `standard` と **standard 由来の evidence スキャフォールド**を書き込み、`charge` は evidence 参照(coverage artifact の adapter/path など)を不足分マージし、`fire` は manifest を入力に gate 判定のみを行う。`fire` は `--standard` 未指定時に manifest の `standard.id` を使い、指定時はそれを上書きする。 +**実装との関係:** 現リポジトリは **`hado target` / `hado charge` / `hado fire`** を正とする。`target` は manifest の `service` / `standard` と **standard 由来の evidence スキャフォールド**を書き込み、`charge` は coverage module 実行や discovery で manifest を充填し、`fire` は manifest を入力に gate 判定のみを行う。`fire` は `--standard` 未指定時に manifest の `standard.id` を使い、指定時はそれを上書きする。 詳細な責務分担・データの流れは [アーキテクチャ](architecture.md) を参照する。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 4e45e05..573457a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,21 +55,21 @@ GitHub Actions 上で、Go service の最低限の Production Readiness を評 - HADO Manifest loader(`internal/manifest`) - Readiness Standard loader(`internal/standard`) -- coverage adapter 層と C0 / C1 coverage gate(`internal/coverage`, `internal/gate`) +- module runner と gobce coverage module 実行、C0 / C1 coverage gate(`internal/modulerunner`, `internal/gate`) - operations / observability / infra / release(rollback と `release.automation_declared`)の existence gate(manifest 由来) - `hado target`(manifest に `service` / `standard` と **standard に沿った evidence スキャフォールド**; `cmd/hado`) -- `hado charge` / `hado fire`(evidence 参照の補完と判定を分離した 2 段階。**Datadog Monitor の discovery** は `charge --datadog-discover` と `internal/integration/datadog`) +- `hado charge` / `hado fire`(coverage module による充填と manifest inline metrics の判定を分離した 2 段階。**Datadog Monitor の discovery** は `charge --datadog-discover` と `internal/integration/datadog`) -**CLI 体系:** `hado target` → `hado charge` → `hado fire`。`charge` は既存 manifest 値を保持しながら不足分をマージし、`fire` は manifest 入力のみで判定する。 +**CLI 体系:** `hado target` → `hado charge` → `hado fire`。`charge` は module / discovery で manifest を充填し、`fire` は manifest 入力のみで判定する。 -ロードマップ上は、module runner、閾値ベースの infra gate、Markdown / PR 連携などはこのあとである。 +ロードマップ上は、閾値ベースの infra gate、Markdown / PR 連携などはこのあとである。 MVP に含めるもの: - HADO Manifest loader - Readiness Standard loader - module runner -- coverage adapter layer +- coverage module - C0 coverage gate - C1 gate - SLO existence gate diff --git a/hado.yaml b/hado.yaml index 5eab9ff..9cfdadc 100644 --- a/hado.yaml +++ b/hado.yaml @@ -6,9 +6,7 @@ standard: id: cli-service evidence: coverage: - inputs: - - adapter: gobce-json - path: hado-coverage.json + module: gobce operations: owner: keyskey runbook: "" diff --git a/internal/coverage/adapter_test.go b/internal/coverage/adapter_test.go deleted file mode 100644 index 903a54f..0000000 --- a/internal/coverage/adapter_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package coverage - -import ( - "os" - "path/filepath" - "testing" -) - -func TestParseAdapterInputReadsGobceJSON(t *testing.T) { - path := writeAdapterFile(t, "gobce.json", `{ - "language": "go", - "statementCoverage": 82.1, - "estimatedBranchCoverage": 68.4, - "uncoveredBranches": [] -}`) - - metrics, err := ParseAdapterInput(AdapterInput{Format: FormatGobceJSON, Path: path}) - if err != nil { - t.Fatalf("ParseAdapterInput() error = %v", err) - } - if metrics.C0Coverage == nil || *metrics.C0Coverage != 82.1 { - t.Fatalf("C0Coverage = %v, want 82.1", metrics.C0Coverage) - } - if metrics.C1Coverage == nil || *metrics.C1Coverage != 68.4 { - t.Fatalf("C1Coverage = %v, want 68.4", metrics.C1Coverage) - } -} - -func TestParseAdapterInputReadsGoCoverprofile(t *testing.T) { - path := writeAdapterFile(t, "coverage.out", `mode: set -example.go:1.1,2.2 3 1 -example.go:3.1,4.2 2 0 -`) - - metrics, err := ParseAdapterInput(AdapterInput{Format: FormatGoCoverprofile, Path: path}) - if err != nil { - t.Fatalf("ParseAdapterInput() error = %v", err) - } - if metrics.C0Coverage == nil || *metrics.C0Coverage != 60 { - t.Fatalf("C0Coverage = %v, want 60", metrics.C0Coverage) - } - if metrics.C1Coverage != nil { - t.Fatalf("C1Coverage = %v, want nil", metrics.C1Coverage) - } -} - -func TestParseAdapterInputRejectsUnsupportedAdapter(t *testing.T) { - _, err := ParseAdapterInput(AdapterInput{Format: "lcov", Path: "coverage.info"}) - if err == nil { - t.Fatal("ParseAdapterInput() error = nil, want unsupported adapter error") - } -} - -func writeAdapterFile(t *testing.T, name, content string) string { - t.Helper() - - path := filepath.Join(t.TempDir(), name) - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("write %s: %v", name, err) - } - return path -} diff --git a/internal/coverage/doc.go b/internal/coverage/doc.go index d74f9cd..fc3de86 100644 --- a/internal/coverage/doc.go +++ b/internal/coverage/doc.go @@ -1,2 +1,2 @@ -// Package coverage parses Go coverage evidence. +// Package coverage defines normalized coverage metrics shared by HADO evaluators. package coverage diff --git a/internal/coverage/metrics.go b/internal/coverage/metrics.go new file mode 100644 index 0000000..bd0d29c --- /dev/null +++ b/internal/coverage/metrics.go @@ -0,0 +1,12 @@ +package coverage + +// Merge overlays non-nil metrics from next onto metrics. +func (metrics Metrics) Merge(next Metrics) Metrics { + if next.C0Coverage != nil { + metrics.C0Coverage = next.C0Coverage + } + if next.C1Coverage != nil { + metrics.C1Coverage = next.C1Coverage + } + return metrics +} diff --git a/internal/coverage/metrics_test.go b/internal/coverage/metrics_test.go index 600a19c..d72a54e 100644 --- a/internal/coverage/metrics_test.go +++ b/internal/coverage/metrics_test.go @@ -1,47 +1,26 @@ package coverage -import ( - "os" - "path/filepath" - "testing" -) +import "testing" -func TestParseMetricsReadsC0AndC1Coverage(t *testing.T) { - path := writeJSON(t, `{"c0Coverage": 82.1, "c1Coverage": 68.4}`) +func TestMetricsValidateRejectsOutOfRangeCoverage(t *testing.T) { + value := 101.0 - metrics, err := ParseMetrics(path) - if err != nil { - t.Fatalf("ParseMetrics() error = %v", err) - } - if metrics.C0Coverage == nil { - t.Fatal("C0Coverage = nil, want value") - } - if *metrics.C0Coverage != 82.1 { - t.Fatalf("C0Coverage = %f, want 82.1", *metrics.C0Coverage) - } - if metrics.C1Coverage == nil { - t.Fatal("C1Coverage = nil, want value") - } - if *metrics.C1Coverage != 68.4 { - t.Fatalf("C1Coverage = %f, want 68.4", *metrics.C1Coverage) - } -} - -func TestParseMetricsRejectsOutOfRangeCoverage(t *testing.T) { - path := writeJSON(t, `{"c0Coverage": 101}`) - - _, err := ParseMetrics(path) + err := Metrics{C0Coverage: &value}.Validate() if err == nil { - t.Fatal("ParseMetrics() error = nil, want error") + t.Fatal("Validate() error = nil, want error") } } -func writeJSON(t *testing.T, content string) string { - t.Helper() +func TestMetricsMergeOverlaysPresentValues(t *testing.T) { + c0 := 82.1 + c1 := 68.4 - path := filepath.Join(t.TempDir(), "coverage-metrics.json") - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("write json: %v", err) + metrics := Metrics{C0Coverage: &c0}.Merge(Metrics{C1Coverage: &c1}) + + if metrics.C0Coverage == nil || *metrics.C0Coverage != c0 { + t.Fatalf("C0Coverage = %v, want %f", metrics.C0Coverage, c0) + } + if metrics.C1Coverage == nil || *metrics.C1Coverage != c1 { + t.Fatalf("C1Coverage = %v, want %f", metrics.C1Coverage, c1) } - return path } diff --git a/internal/coverage/parse.go b/internal/coverage/parse.go deleted file mode 100644 index 0233636..0000000 --- a/internal/coverage/parse.go +++ /dev/null @@ -1,64 +0,0 @@ -package coverage - -import ( - "encoding/json" - "fmt" - "os" -) - -// ParseMetrics reads normalized coverage metrics from JSON. -func ParseMetrics(path string) (Metrics, error) { - data, err := os.ReadFile(path) - if err != nil { - return Metrics{}, fmt.Errorf("read coverage metrics: %w", err) - } - - var metrics Metrics - if err := json.Unmarshal(data, &metrics); err != nil { - return Metrics{}, fmt.Errorf("parse coverage metrics: %w", err) - } - if err := metrics.Validate(); err != nil { - return Metrics{}, err - } - - return metrics, nil -} - -// ParseAdapterInput parses one coverage report using the requested adapter. -func ParseAdapterInput(input AdapterInput) (Metrics, error) { - switch input.Format { - case FormatHADOJSON: - return ParseMetrics(input.Path) - case FormatGoCoverprofile: - summary, err := ParseGoProfile(input.Path) - if err != nil { - return Metrics{}, err - } - return Metrics{C0Coverage: &summary.C0Coverage}, nil - case FormatGobceJSON: - return ParseGobceJSON(input.Path) - default: - return Metrics{}, fmt.Errorf("unsupported coverage adapter %q", input.Format) - } -} - -// ParseGobceJSON reads the current keyskey/gobce JSON output format. -func ParseGobceJSON(path string) (Metrics, error) { - data, err := os.ReadFile(path) - if err != nil { - return Metrics{}, fmt.Errorf("read gobce json: %w", err) - } - - var result gobceResult - if err := json.Unmarshal(data, &result); err != nil { - return Metrics{}, fmt.Errorf("parse gobce json: %w", err) - } - metrics := Metrics{ - C0Coverage: &result.StatementCoverage, - C1Coverage: &result.EstimatedBranchCoverage, - } - if err := metrics.Validate(); err != nil { - return Metrics{}, err - } - return metrics, nil -} diff --git a/internal/coverage/profile.go b/internal/coverage/profile.go deleted file mode 100644 index 9850544..0000000 --- a/internal/coverage/profile.go +++ /dev/null @@ -1,88 +0,0 @@ -package coverage - -import ( - "bufio" - "fmt" - "os" - "strconv" - "strings" -) - -// Summary contains C0 coverage metrics derived from a Go coverage profile. -type Summary struct { - C0Coverage float64 `json:"c0Coverage"` - CoveredStatements int `json:"coveredStatements"` - TotalStatements int `json:"totalStatements"` -} - -// ParseGoProfile reads a Go coverprofile and returns C0 coverage percent. -func ParseGoProfile(path string) (Summary, error) { - file, err := os.Open(path) - if err != nil { - return Summary{}, fmt.Errorf("open coverage profile: %w", err) - } - defer file.Close() - - var coveredStatements int - var totalStatements int - scanner := bufio.NewScanner(file) - lineNumber := 0 - for scanner.Scan() { - lineNumber++ - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - if lineNumber == 1 { - if !strings.HasPrefix(line, "mode:") { - return Summary{}, fmt.Errorf("coverage profile line 1 must declare mode") - } - continue - } - - statements, count, err := parseProfileLine(line) - if err != nil { - return Summary{}, fmt.Errorf("coverage profile line %d: %w", lineNumber, err) - } - totalStatements += statements - if count > 0 { - coveredStatements += statements - } - } - if err := scanner.Err(); err != nil { - return Summary{}, fmt.Errorf("scan coverage profile: %w", err) - } - if totalStatements == 0 { - return Summary{}, fmt.Errorf("coverage profile has no statements") - } - - return Summary{ - C0Coverage: float64(coveredStatements) / float64(totalStatements) * 100, - CoveredStatements: coveredStatements, - TotalStatements: totalStatements, - }, nil -} - -func parseProfileLine(line string) (statements int, count int, err error) { - fields := strings.Fields(line) - if len(fields) != 3 { - return 0, 0, fmt.Errorf("expected block, statement count, and execution count") - } - - statements, err = strconv.Atoi(fields[1]) - if err != nil { - return 0, 0, fmt.Errorf("parse statement count: %w", err) - } - count, err = strconv.Atoi(fields[2]) - if err != nil { - return 0, 0, fmt.Errorf("parse execution count: %w", err) - } - if statements < 0 { - return 0, 0, fmt.Errorf("statement count must be non-negative") - } - if count < 0 { - return 0, 0, fmt.Errorf("execution count must be non-negative") - } - - return statements, count, nil -} diff --git a/internal/coverage/profile_test.go b/internal/coverage/profile_test.go deleted file mode 100644 index 10d082a..0000000 --- a/internal/coverage/profile_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package coverage - -import ( - "os" - "path/filepath" - "testing" -) - -func TestParseGoProfileCalculatesC0Coverage(t *testing.T) { - path := writeProfile(t, `mode: set -example.go:1.1,2.2 3 1 -example.go:3.1,4.2 2 0 -example.go:5.1,6.2 5 2 -`) - - summary, err := ParseGoProfile(path) - if err != nil { - t.Fatalf("ParseGoProfile() error = %v", err) - } - if summary.CoveredStatements != 8 { - t.Fatalf("CoveredStatements = %d, want 8", summary.CoveredStatements) - } - if summary.TotalStatements != 10 { - t.Fatalf("TotalStatements = %d, want 10", summary.TotalStatements) - } - if summary.C0Coverage != 80 { - t.Fatalf("C0Coverage = %f, want 80", summary.C0Coverage) - } -} - -func TestParseGoProfileRejectsEmptyProfile(t *testing.T) { - path := writeProfile(t, "mode: set\n") - - _, err := ParseGoProfile(path) - if err == nil { - t.Fatal("ParseGoProfile() error = nil, want error") - } -} - -func writeProfile(t *testing.T, content string) string { - t.Helper() - - path := filepath.Join(t.TempDir(), "coverage.out") - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("write coverage profile: %v", err) - } - return path -} diff --git a/internal/coverage/specs.go b/internal/coverage/specs.go deleted file mode 100644 index 62c072d..0000000 --- a/internal/coverage/specs.go +++ /dev/null @@ -1,55 +0,0 @@ -package coverage - -import ( - "fmt" - "strings" -) - -// Merge overlays non-nil metrics from next onto metrics. -func (metrics Metrics) Merge(next Metrics) Metrics { - if next.C0Coverage != nil { - metrics.C0Coverage = next.C0Coverage - } - if next.C1Coverage != nil { - metrics.C1Coverage = next.C1Coverage - } - return metrics -} - -// ParseCoverageSpec parses ":" CLI values. -func ParseCoverageSpec(spec string) (AdapterInput, error) { - format, path, ok := strings.Cut(spec, ":") - if !ok || format == "" || path == "" { - return AdapterInput{}, fmt.Errorf("coverage input must be :") - } - return AdapterInput{Format: format, Path: path}, nil -} - -// ParseAdapterInputs parses and merges multiple coverage adapter inputs. -func ParseAdapterInputs(inputs []AdapterInput) (Metrics, error) { - var merged Metrics - for _, input := range inputs { - metrics, err := ParseAdapterInput(input) - if err != nil { - return Metrics{}, err - } - merged = merged.Merge(metrics) - } - if err := merged.Validate(); err != nil { - return Metrics{}, err - } - return merged, nil -} - -// ParseCoverageSpecs parses and merges multiple ":" values. -func ParseCoverageSpecs(specs []string) (Metrics, error) { - inputs := make([]AdapterInput, 0, len(specs)) - for _, spec := range specs { - input, err := ParseCoverageSpec(spec) - if err != nil { - return Metrics{}, err - } - inputs = append(inputs, input) - } - return ParseAdapterInputs(inputs) -} diff --git a/internal/coverage/types.go b/internal/coverage/types.go index 446b242..5b764ca 100644 --- a/internal/coverage/types.go +++ b/internal/coverage/types.go @@ -5,23 +5,3 @@ type Metrics struct { C0Coverage *float64 `json:"c0Coverage"` C1Coverage *float64 `json:"c1Coverage"` } - -// AdapterInput identifies a coverage report and the adapter that can parse it. -type AdapterInput struct { - Format string - Path string -} - -const ( - // FormatHADOJSON is HADO's normalized coverage metrics format. - FormatHADOJSON = "hado-json" - // FormatGoCoverprofile parses Go's coverprofile format and emits C0 coverage. - FormatGoCoverprofile = "go-coverprofile" - // FormatGobceJSON parses keyskey/gobce JSON output and emits C0/C1 coverage. - FormatGobceJSON = "gobce-json" -) - -type gobceResult struct { - StatementCoverage float64 `json:"statementCoverage"` - EstimatedBranchCoverage float64 `json:"estimatedBranchCoverage"` -} diff --git a/internal/manifest/README.md b/internal/manifest/README.md index 806d688..9404997 100644 --- a/internal/manifest/README.md +++ b/internal/manifest/README.md @@ -43,7 +43,7 @@ HADO Manifest の **型・読み書き・gate 向けヘルパ・scaffold** を | `evidence.*` ブロック | ファイル | gate id(例) | manifest 側ヘルパ | | --- | --- | --- | --- | -| `coverage` | `coverage.go` | `test.c0_coverage`, `test.c1_coverage` | `CoverageAdapterInputs`(パス解決) | +| `coverage` | `coverage.go` | `test.c0_coverage`, `test.c1_coverage` | `CoverageModule` / `CoverageC0` / `CoverageC1` | | `operations` | `operations.go` | `operations.owner_exists` | `OwnerForGate` / `OwnerExistForGate` | | | | `operations.runbook_exists` | `RunbookForGate` / `RunbookExistForGate` | | `observability` | `observability.go` | `observability.slo_exists` | `SLOExistForGate` | diff --git a/internal/manifest/coverage.go b/internal/manifest/coverage.go index 4fded70..3f1cc68 100644 --- a/internal/manifest/coverage.go +++ b/internal/manifest/coverage.go @@ -1,26 +1,25 @@ package manifest -import ( - "path/filepath" - - "github.com/keyskey/hado/internal/coverage" -) - -// CoverageAdapterInputs returns coverage adapter inputs with manifest-relative paths resolved. -func (m Manifest) CoverageAdapterInputs() []coverage.AdapterInput { +// CoverageModule returns the configured coverage module id. +func (m Manifest) CoverageModule() string { if m.Evidence.Coverage == nil { + return "" + } + return m.Evidence.Coverage.Module +} + +// CoverageC0 returns the inline C0 coverage metric if charge has written one. +func (m Manifest) CoverageC0() *float64 { + if m.Evidence.Coverage == nil || m.Evidence.Coverage.Metrics == nil { return nil } - inputs := make([]coverage.AdapterInput, 0, len(m.Evidence.Coverage.Inputs)) - for _, input := range m.Evidence.Coverage.Inputs { - path := input.Path - if !filepath.IsAbs(path) && m.baseDir != "" { - path = filepath.Join(m.baseDir, path) - } - inputs = append(inputs, coverage.AdapterInput{ - Format: input.Adapter, - Path: path, - }) + return m.Evidence.Coverage.Metrics.C0 +} + +// CoverageC1 returns the inline C1 coverage metric if charge has written one. +func (m Manifest) CoverageC1() *float64 { + if m.Evidence.Coverage == nil || m.Evidence.Coverage.Metrics == nil { + return nil } - return inputs + return m.Evidence.Coverage.Metrics.C1 } diff --git a/internal/manifest/coverage_test.go b/internal/manifest/coverage_test.go index 257a18f..6367e43 100644 --- a/internal/manifest/coverage_test.go +++ b/internal/manifest/coverage_test.go @@ -4,21 +4,19 @@ import ( "os" "path/filepath" "testing" - - "github.com/keyskey/hado/internal/coverage" ) -func TestLoadReturnsCoverageAdapterInputs(t *testing.T) { +func TestLoadReturnsCoverageModuleMetrics(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, "hado.yaml") if err := os.WriteFile(manifestPath, []byte(`version: v1 evidence: coverage: - inputs: - - adapter: hado-json - path: coverage-metrics.json - - adapter: gobce-json - path: /tmp/gobce.json + module: gobce + measured_at: 2026-05-26T14:30:00Z + metrics: + c0: 82.1 + c1: 68.4 `), 0o600); err != nil { t.Fatalf("write manifest: %v", err) } @@ -27,52 +25,57 @@ evidence: if err != nil { t.Fatalf("Load() error = %v", err) } - inputs := hadoManifest.CoverageAdapterInputs() - - want := []coverage.AdapterInput{ - {Format: coverage.FormatHADOJSON, Path: filepath.Join(dir, "coverage-metrics.json")}, - {Format: coverage.FormatGobceJSON, Path: "/tmp/gobce.json"}, + if hadoManifest.CoverageModule() != "gobce" { + t.Fatalf("CoverageModule() = %q, want gobce", hadoManifest.CoverageModule()) } - if len(inputs) != len(want) { - t.Fatalf("len(inputs) = %d, want %d", len(inputs), len(want)) + if hadoManifest.CoverageC0() == nil || *hadoManifest.CoverageC0() != 82.1 { + t.Fatalf("CoverageC0() = %v, want 82.1", hadoManifest.CoverageC0()) } - for i := range want { - if inputs[i] != want[i] { - t.Fatalf("inputs[%d] = %+v, want %+v", i, inputs[i], want[i]) - } + if hadoManifest.CoverageC1() == nil || *hadoManifest.CoverageC1() != 68.4 { + t.Fatalf("CoverageC1() = %v, want 68.4", hadoManifest.CoverageC1()) } } -func TestLoadProjectManifestCoverageInputs(t *testing.T) { +func TestLoadProjectManifestCoverageModule(t *testing.T) { hadoManifest, err := Load(filepath.Join("..", "..", "hado.yaml")) if err != nil { t.Fatalf("Load() error = %v", err) } - inputs := hadoManifest.CoverageAdapterInputs() - if len(inputs) != 1 { - t.Fatalf("len(inputs) = %d, want 1", len(inputs)) + if hadoManifest.CoverageModule() != "gobce" { + t.Fatalf("CoverageModule() = %q, want gobce", hadoManifest.CoverageModule()) } - if inputs[0].Format != coverage.FormatGobceJSON { - t.Fatalf("coverage input format = %q, want %q", inputs[0].Format, coverage.FormatGobceJSON) +} + +func TestLoadRejectsInvalidCoverageMeasuredAt(t *testing.T) { + manifestPath := filepath.Join(t.TempDir(), "hado.yaml") + if err := os.WriteFile(manifestPath, []byte(`version: v1 +evidence: + coverage: + module: gobce + measured_at: yesterday +`), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) } - if filepath.Base(inputs[0].Path) != "hado-coverage.json" { - t.Fatalf("coverage input path = %q, want hado-coverage.json", inputs[0].Path) + + if _, err := Load(manifestPath); err == nil { + t.Fatal("Load() error = nil, want measured_at validation error") } } -func TestLoadRejectsCoverageInputWithoutAdapter(t *testing.T) { +func TestLoadRejectsOutOfRangeCoverageMetric(t *testing.T) { manifestPath := filepath.Join(t.TempDir(), "hado.yaml") - if err := os.WriteFile(manifestPath, []byte(`evidence: + if err := os.WriteFile(manifestPath, []byte(`version: v1 +evidence: coverage: - inputs: - - path: coverage-metrics.json + module: gobce + metrics: + c0: 101 `), 0o600); err != nil { t.Fatalf("write manifest: %v", err) } - _, err := Load(manifestPath) - if err == nil { - t.Fatal("Load() error = nil, want validation error") + if _, err := Load(manifestPath); err == nil { + t.Fatal("Load() error = nil, want coverage metric validation error") } } diff --git a/internal/manifest/coverage_validate.go b/internal/manifest/coverage_validate.go new file mode 100644 index 0000000..a9d1043 --- /dev/null +++ b/internal/manifest/coverage_validate.go @@ -0,0 +1,38 @@ +package manifest + +import ( + "fmt" + "time" +) + +type coverageEvidenceValidator struct { + coverage *CoverageEvidence +} + +func (v coverageEvidenceValidator) Validate() error { + if v.coverage == nil { + return nil + } + if v.coverage.MeasuredAt != "" { + if _, err := time.Parse(time.RFC3339, v.coverage.MeasuredAt); err != nil { + return fmt.Errorf("evidence.coverage.measured_at must be RFC3339: %w", err) + } + } + if v.coverage.Metrics == nil { + return nil + } + if err := validateCoveragePercent("evidence.coverage.metrics.c0", v.coverage.Metrics.C0); err != nil { + return err + } + return validateCoveragePercent("evidence.coverage.metrics.c1", v.coverage.Metrics.C1) +} + +func validateCoveragePercent(path string, value *float64) error { + if value == nil { + return nil + } + if *value < 0 || *value > 100 { + return fmt.Errorf("%s must be between 0 and 100", path) + } + return nil +} diff --git a/internal/manifest/refdoc/field_docs.go b/internal/manifest/refdoc/field_docs.go index 976dec5..6d6594c 100644 --- a/internal/manifest/refdoc/field_docs.go +++ b/internal/manifest/refdoc/field_docs.go @@ -14,10 +14,12 @@ var yamlDoc = map[string]string{ "evidence": "本番準備の証跡宣言。ゲートごとに必要なブロックだけでよい(各サブブロックは多くが `omitempty`)。", - "evidence.coverage": "カバレッジ成果物と adapter(ブロック)。C0/C1 ゲートがある standard で必要。", - "evidence.coverage.inputs": "`CoverageInput` の配列。", - "evidence.coverage.inputs.adapter": "パーサ名。`hado-json` / `go-coverprofile` / `gobce-json` など(実装は `internal/coverage`)。", - "evidence.coverage.inputs.path": "リポジトリまたは manifest 相対の成果物パス。", + "evidence.coverage": "カバレッジ計測 module と、`hado charge` が書き戻す計測結果(ブロック)。C0/C1 ゲートがある standard で必要。", + "evidence.coverage.module": "coverage 計測に使う module。現行 MVP は `gobce`。", + "evidence.coverage.measured_at": "coverage module を実行して metrics を書き戻した UTC RFC3339 時刻。", + "evidence.coverage.metrics": "coverage module の正規化済み計測値(ブロック)。`hado fire` はこの inline metrics だけを評価する。", + "evidence.coverage.metrics.c0": "C0 coverage percentage。`test.c0_coverage` の判定に使う。", + "evidence.coverage.metrics.c1": "C1 coverage percentage。`test.c1_coverage` の判定に使う。", "evidence.operations": "運用責任と障害対応の入口(ブロック)。", "evidence.operations.owner": "オーナー(チーム名・Slack チャンネル等)。`operations.owner_exists` で非空判定。", diff --git a/internal/manifest/refdoc/generate.go b/internal/manifest/refdoc/generate.go index e5fd207..a9b3eff 100644 --- a/internal/manifest/refdoc/generate.go +++ b/internal/manifest/refdoc/generate.go @@ -177,6 +177,8 @@ func writeYAMLScalarNoNewline(ft reflect.Type, path string, b *strings.Builder) switch ft.Kind() { case reflect.String: fmt.Fprintf(b, "%q", referenceStringValue(path)) + case reflect.Float32, reflect.Float64: + fmt.Fprintf(b, "%.1f", referenceFloatValue(path)) case reflect.Slice: el := ft.Elem() for el.Kind() == reflect.Ptr { @@ -197,10 +199,10 @@ func referenceStringValue(path string) string { switch path { case "version": return "v1" - case "evidence.coverage.inputs.adapter": - return "hado-json" - case "evidence.coverage.inputs.path": - return "coverage-metrics.json" + case "evidence.coverage.module": + return "gobce" + case "evidence.coverage.measured_at": + return "2026-05-26T14:30:00Z" } if strings.HasSuffix(path, ".provider") { return "datadog" @@ -236,6 +238,17 @@ func referenceStringValue(path string) string { return "" } +func referenceFloatValue(path string) float64 { + switch path { + case "evidence.coverage.metrics.c0": + return 82.1 + case "evidence.coverage.metrics.c1": + return 68.4 + default: + return 0 + } +} + func writeYAMLCommentBlock(level int, doc string, b *strings.Builder) { pad := indentSpaces(level) for _, line := range strings.Split(doc, "\n") { diff --git a/internal/manifest/refdoc/refdoc_test.go b/internal/manifest/refdoc/refdoc_test.go index 4f0b08e..2bbaf4b 100644 --- a/internal/manifest/refdoc/refdoc_test.go +++ b/internal/manifest/refdoc/refdoc_test.go @@ -55,8 +55,8 @@ func TestWriteReferenceYAML_loads(t *testing.T) { if m.Version != "v1" { t.Fatalf("Version = %q want v1", m.Version) } - if m.Evidence.Coverage == nil || len(m.Evidence.Coverage.Inputs) != 1 { - t.Fatalf("expected one coverage input, got %#v", m.Evidence.Coverage) + if m.Evidence.Coverage == nil || m.Evidence.Coverage.Module != "gobce" { + t.Fatalf("expected gobce coverage module, got %#v", m.Evidence.Coverage) } } diff --git a/internal/manifest/scaffold.go b/internal/manifest/scaffold.go index e9fe530..60d6088 100644 --- a/internal/manifest/scaffold.go +++ b/internal/manifest/scaffold.go @@ -19,15 +19,13 @@ func ApplyEvidenceScaffold(m *Manifest, st standard.Standard, opts ApplyEvidence needsC1 := st.RequiresGate(standard.C1CoverageGateID) if needsC0 || needsC1 { if !merge { - m.Evidence.Coverage = &CoverageEvidence{ - Inputs: []CoverageInput{{Adapter: "hado-json", Path: "coverage-metrics.json"}}, - } + m.Evidence.Coverage = &CoverageEvidence{Module: "gobce"} } else { if m.Evidence.Coverage == nil { m.Evidence.Coverage = &CoverageEvidence{} } - if len(m.Evidence.Coverage.Inputs) == 0 { - m.Evidence.Coverage.Inputs = []CoverageInput{{Adapter: "hado-json", Path: "coverage-metrics.json"}} + if m.Evidence.Coverage.Module == "" { + m.Evidence.Coverage.Module = "gobce" } } } diff --git a/internal/manifest/scaffold_test.go b/internal/manifest/scaffold_test.go index 573b440..4233cde 100644 --- a/internal/manifest/scaffold_test.go +++ b/internal/manifest/scaffold_test.go @@ -31,8 +31,8 @@ gates: var m Manifest m.Version = "v1" ApplyEvidenceScaffold(&m, st, ApplyEvidenceScaffoldOptions{}) - if m.Evidence.Coverage == nil || len(m.Evidence.Coverage.Inputs) != 1 { - t.Fatalf("coverage inputs: %+v", m.Evidence.Coverage) + if m.Evidence.Coverage == nil || m.Evidence.Coverage.Module != "gobce" { + t.Fatalf("coverage module: %+v", m.Evidence.Coverage) } if m.Evidence.Operations == nil || m.Evidence.Operations.Owner != "" { t.Fatalf("owner = %+v", m.Evidence.Operations) diff --git a/internal/manifest/types.go b/internal/manifest/types.go index 3ba075c..3497ed4 100644 --- a/internal/manifest/types.go +++ b/internal/manifest/types.go @@ -31,15 +31,17 @@ type Evidence struct { Release *ReleaseEvidence `yaml:"release,omitempty" json:"release,omitempty"` } -// CoverageEvidence declares coverage reports and the adapters that parse them. +// CoverageEvidence declares which module collects coverage and stores the latest measured metrics. type CoverageEvidence struct { - Inputs []CoverageInput `yaml:"inputs" json:"inputs,omitempty"` + Module string `yaml:"module,omitempty" json:"module,omitempty"` + MeasuredAt string `yaml:"measured_at,omitempty" json:"measured_at,omitempty"` + Metrics *CoverageMetrics `yaml:"metrics,omitempty" json:"metrics,omitempty"` } -// CoverageInput identifies one coverage artifact and its adapter. -type CoverageInput struct { - Adapter string `yaml:"adapter" json:"adapter"` - Path string `yaml:"path" json:"path"` +// CoverageMetrics contains coverage percentages written by charge. +type CoverageMetrics struct { + C0 *float64 `yaml:"c0,omitempty" json:"c0,omitempty"` + C1 *float64 `yaml:"c1,omitempty" json:"c1,omitempty"` } // OperationsEvidence declares operational ownership and response evidence. diff --git a/internal/manifest/validate.go b/internal/manifest/validate.go index 8ccdf73..0c0f8c1 100644 --- a/internal/manifest/validate.go +++ b/internal/manifest/validate.go @@ -1,18 +1,17 @@ package manifest -import "fmt" +type manifestValidator interface { + Validate() error +} // Validate checks manifest fields used by the current evaluator. func (m Manifest) Validate() error { - if m.Evidence.Coverage == nil { - return nil + validators := []manifestValidator{ + coverageEvidenceValidator{coverage: m.Evidence.Coverage}, } - for i, input := range m.Evidence.Coverage.Inputs { - if input.Adapter == "" { - return fmt.Errorf("evidence.coverage.inputs[%d].adapter is required", i) - } - if input.Path == "" { - return fmt.Errorf("evidence.coverage.inputs[%d].path is required", i) + for _, validator := range validators { + if err := validator.Validate(); err != nil { + return err } } return nil diff --git a/internal/modulerunner/buffer.go b/internal/modulerunner/buffer.go new file mode 100644 index 0000000..6241792 --- /dev/null +++ b/internal/modulerunner/buffer.go @@ -0,0 +1,36 @@ +package modulerunner + +import "bytes" + +type limitedBuffer struct { + buf bytes.Buffer + limit int64 + exceeded bool +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + if b.limit <= 0 { + _, _ = b.buf.Write(p) + return len(p), nil + } + remaining := b.limit - int64(b.buf.Len()) + if remaining <= 0 { + b.exceeded = true + return len(p), nil + } + if int64(len(p)) > remaining { + _, _ = b.buf.Write(p[:int(remaining)]) + b.exceeded = true + return len(p), nil + } + _, _ = b.buf.Write(p) + return len(p), nil +} + +func (b *limitedBuffer) String() string { + return b.buf.String() +} + +func (b *limitedBuffer) Exceeded() bool { + return b.exceeded +} diff --git a/internal/modulerunner/doc.go b/internal/modulerunner/doc.go new file mode 100644 index 0000000..6597f15 --- /dev/null +++ b/internal/modulerunner/doc.go @@ -0,0 +1,10 @@ +// Package modulerunner executes external HADO modules over a small JSON stdin/stdout contract. +// +// File layout: +// - types.go: public request/result/spec types. +// - errors.go: typed execution and decoding errors. +// - runner.go: public orchestration entrypoint. +// - invocation.go: process setup, execution, and execution error mapping. +// - transport.go: JSON request/result encoding and contract checks. +// - buffer.go: bounded stdout/stderr capture. +package modulerunner diff --git a/internal/modulerunner/errors.go b/internal/modulerunner/errors.go new file mode 100644 index 0000000..dda6082 --- /dev/null +++ b/internal/modulerunner/errors.go @@ -0,0 +1,72 @@ +package modulerunner + +import ( + "fmt" + "strings" + "time" +) + +// TimeoutError reports a module invocation that exceeded its configured timeout. +type TimeoutError struct { + ModuleID string + Timeout time.Duration + Stderr string +} + +func (e *TimeoutError) Error() string { + if e.Timeout > 0 { + return fmt.Sprintf("module %q timed out after %s", e.ModuleID, e.Timeout) + } + return fmt.Sprintf("module %q timed out", e.ModuleID) +} + +// CanceledError reports a module invocation canceled by the caller's context. +type CanceledError struct { + ModuleID string + Stderr string +} + +func (e *CanceledError) Error() string { + return fmt.Sprintf("module %q canceled", e.ModuleID) +} + +// NonZeroExitError reports a module process that exited unsuccessfully. +type NonZeroExitError struct { + ModuleID string + ExitCode int + Stderr string +} + +func (e *NonZeroExitError) Error() string { + if strings.TrimSpace(e.Stderr) == "" { + return fmt.Sprintf("module %q exited with code %d", e.ModuleID, e.ExitCode) + } + return fmt.Sprintf("module %q exited with code %d: %s", e.ModuleID, e.ExitCode, strings.TrimSpace(e.Stderr)) +} + +// StdoutLimitError reports a module that produced more stdout than accepted. +type StdoutLimitError struct { + ModuleID string + Limit int64 + Stderr string +} + +func (e *StdoutLimitError) Error() string { + return fmt.Sprintf("module %q stdout exceeded %d bytes", e.ModuleID, e.Limit) +} + +// InvalidJSONError reports stdout that was not a valid RunResult JSON document. +type InvalidJSONError struct { + ModuleID string + Err error + Stdout string + Stderr string +} + +func (e *InvalidJSONError) Error() string { + return fmt.Sprintf("module %q returned invalid JSON: %v", e.ModuleID, e.Err) +} + +func (e *InvalidJSONError) Unwrap() error { + return e.Err +} diff --git a/internal/modulerunner/invocation.go b/internal/modulerunner/invocation.go new file mode 100644 index 0000000..9f84217 --- /dev/null +++ b/internal/modulerunner/invocation.go @@ -0,0 +1,101 @@ +package modulerunner + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" +) + +type invocation struct { + spec Spec + ctx context.Context + cancel context.CancelFunc + cmd *exec.Cmd + stdout *limitedBuffer + stderr *limitedBuffer + limit int64 +} + +type moduleOutput struct { + Stdout string + Stderr string +} + +func newInvocation(ctx context.Context, spec Spec, request RunRequest) (*invocation, error) { + if spec.Command == "" { + return nil, fmt.Errorf("module %q command is required", spec.ID) + } + stdin, err := encodeRunRequest(request) + if err != nil { + return nil, err + } + + runCtx := ctx + var cancel context.CancelFunc + if spec.Timeout > 0 { + runCtx, cancel = context.WithTimeout(ctx, spec.Timeout) + } + + limit := spec.MaxStdoutBytes + if limit <= 0 { + limit = DefaultMaxStdoutBytes + } + + inv := &invocation{ + spec: spec, + ctx: runCtx, + cancel: cancel, + stdout: &limitedBuffer{limit: limit}, + stderr: &limitedBuffer{limit: maxStderrBytes}, + limit: limit, + } + inv.cmd = exec.CommandContext(runCtx, spec.Command, spec.Args...) + inv.cmd.Stdin = bytes.NewReader(stdin) + inv.cmd.Env = append(os.Environ(), spec.Env...) + inv.cmd.Stdout = inv.stdout + inv.cmd.Stderr = inv.stderr + return inv, nil +} + +func (i *invocation) Run() (moduleOutput, error) { + if i.cancel != nil { + defer i.cancel() + } + + err := i.cmd.Run() + if ctxErr := i.ctx.Err(); ctxErr != nil { + return moduleOutput{}, i.contextError(ctxErr) + } + if i.stdout.Exceeded() { + return moduleOutput{}, &StdoutLimitError{ModuleID: i.spec.ID, Limit: i.limit, Stderr: i.stderrText()} + } + if err != nil { + return moduleOutput{}, i.executionError(err) + } + return moduleOutput{ + Stdout: i.stdout.String(), + Stderr: i.stderrText(), + }, nil +} + +func (i *invocation) contextError(err error) error { + if errors.Is(err, context.DeadlineExceeded) { + return &TimeoutError{ModuleID: i.spec.ID, Timeout: i.spec.Timeout, Stderr: i.stderrText()} + } + return &CanceledError{ModuleID: i.spec.ID, Stderr: i.stderrText()} +} + +func (i *invocation) executionError(err error) error { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return &NonZeroExitError{ModuleID: i.spec.ID, ExitCode: exitErr.ExitCode(), Stderr: i.stderrText()} + } + return fmt.Errorf("start module %q: %w", i.spec.ID, err) +} + +func (i *invocation) stderrText() string { + return i.stderr.String() +} diff --git a/internal/modulerunner/runner.go b/internal/modulerunner/runner.go new file mode 100644 index 0000000..c752cee --- /dev/null +++ b/internal/modulerunner/runner.go @@ -0,0 +1,18 @@ +package modulerunner + +import "context" + +const maxStderrBytes int64 = 64 * 1024 + +// Run executes spec.Command, sends request as JSON on stdin, and decodes one RunResult from stdout. +func Run(ctx context.Context, spec Spec, request RunRequest) (RunResult, error) { + invocation, err := newInvocation(ctx, spec, request) + if err != nil { + return RunResult{}, err + } + output, err := invocation.Run() + if err != nil { + return RunResult{}, err + } + return decodeRunResult(spec.ID, output.Stdout, output.Stderr) +} diff --git a/internal/modulerunner/runner_test.go b/internal/modulerunner/runner_test.go new file mode 100644 index 0000000..e2d9a32 --- /dev/null +++ b/internal/modulerunner/runner_test.go @@ -0,0 +1,135 @@ +package modulerunner + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "testing" + "time" +) + +func TestRunReadsSuccessfulJSON(t *testing.T) { + result, err := Run(context.Background(), helperSpec("success"), RunRequest{ + Operation: "coverage.analyze", + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if result.APIVersion != APIVersion { + t.Fatalf("APIVersion = %q, want %q", result.APIVersion, APIVersion) + } + if len(result.Metrics) != 1 || result.Metrics[0].ID != "test.c0_coverage" { + t.Fatalf("Metrics = %#v", result.Metrics) + } +} + +func TestRunInvalidJSON(t *testing.T) { + _, err := Run(context.Background(), helperSpec("invalid-json"), RunRequest{Operation: "coverage.analyze"}) + var invalid *InvalidJSONError + if !errors.As(err, &invalid) { + t.Fatalf("Run() error = %T %v, want InvalidJSONError", err, err) + } +} + +func TestRunNonZeroExitIncludesDiagnostics(t *testing.T) { + _, err := Run(context.Background(), helperSpec("nonzero"), RunRequest{Operation: "coverage.analyze"}) + var exitErr *NonZeroExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Run() error = %T %v, want NonZeroExitError", err, err) + } + if exitErr.ExitCode != 3 { + t.Fatalf("ExitCode = %d, want 3", exitErr.ExitCode) + } + if exitErr.Stderr != "module failed\n" { + t.Fatalf("Stderr = %q", exitErr.Stderr) + } +} + +func TestRunTimeout(t *testing.T) { + spec := helperSpec("sleep") + spec.Timeout = 10 * time.Millisecond + + _, err := Run(context.Background(), spec, RunRequest{Operation: "coverage.analyze"}) + var timeoutErr *TimeoutError + if !errors.As(err, &timeoutErr) { + t.Fatalf("Run() error = %T %v, want TimeoutError", err, err) + } +} + +func TestRunStdoutSizeLimit(t *testing.T) { + spec := helperSpec("large") + spec.MaxStdoutBytes = 4 + + _, err := Run(context.Background(), spec, RunRequest{Operation: "coverage.analyze"}) + var limitErr *StdoutLimitError + if !errors.As(err, &limitErr) { + t.Fatalf("Run() error = %T %v, want StdoutLimitError", err, err) + } +} + +func TestRunContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(10 * time.Millisecond) + cancel() + }() + + _, err := Run(ctx, helperSpec("sleep"), RunRequest{Operation: "coverage.analyze"}) + var canceledErr *CanceledError + if !errors.As(err, &canceledErr) { + t.Fatalf("Run() error = %T %v, want CanceledError", err, err) + } +} + +func TestRunCommandNotFound(t *testing.T) { + _, err := Run(context.Background(), Spec{ID: "missing", Command: "definitely-not-a-hado-module"}, RunRequest{Operation: "coverage.analyze"}) + if err == nil { + t.Fatal("Run() error = nil, want command error") + } +} + +func helperSpec(mode string) Spec { + return Spec{ + ID: "test-helper", + Command: os.Args[0], + Args: []string{"-test.run=TestHelperProcess", "--", mode}, + Env: []string{"GO_WANT_HELPER_PROCESS=1"}, + Timeout: time.Second, + } +} + +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + args := os.Args + mode := args[len(args)-1] + switch mode { + case "success": + var request RunRequest + if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if request.APIVersion != APIVersion || request.Operation != "coverage.analyze" { + fmt.Fprintf(os.Stderr, "unexpected request: %#v\n", request) + os.Exit(2) + } + fmt.Printf(`{"apiVersion":%q,"module":{"id":"test"},"metrics":[{"id":"test.c0_coverage","value":82.1,"unit":"percent"}]}`+"\n", APIVersion) + case "invalid-json": + fmt.Println("{") + case "nonzero": + fmt.Fprintln(os.Stderr, "module failed") + os.Exit(3) + case "sleep": + time.Sleep(2 * time.Second) + case "large": + fmt.Println("abcdef") + default: + fmt.Fprintf(os.Stderr, "unknown mode %q\n", mode) + os.Exit(2) + } + os.Exit(0) +} diff --git a/internal/modulerunner/transport.go b/internal/modulerunner/transport.go new file mode 100644 index 0000000..9f51a0c --- /dev/null +++ b/internal/modulerunner/transport.go @@ -0,0 +1,35 @@ +package modulerunner + +import ( + "encoding/json" + "fmt" + "strings" +) + +func encodeRunRequest(request RunRequest) ([]byte, error) { + if request.APIVersion == "" { + request.APIVersion = APIVersion + } + data, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("marshal module request: %w", err) + } + return append(data, '\n'), nil +} + +func decodeRunResult(moduleID, stdout, stderr string) (RunResult, error) { + var result RunResult + decoder := json.NewDecoder(strings.NewReader(stdout)) + if err := decoder.Decode(&result); err != nil { + return RunResult{}, &InvalidJSONError{ModuleID: moduleID, Err: err, Stdout: stdout, Stderr: stderr} + } + if result.APIVersion != APIVersion { + return RunResult{}, &InvalidJSONError{ + ModuleID: moduleID, + Err: fmt.Errorf("apiVersion must be %q", APIVersion), + Stdout: stdout, + Stderr: stderr, + } + } + return result, nil +} diff --git a/internal/modulerunner/types.go b/internal/modulerunner/types.go new file mode 100644 index 0000000..3206f66 --- /dev/null +++ b/internal/modulerunner/types.go @@ -0,0 +1,51 @@ +package modulerunner + +import "time" + +const ( + // APIVersion is the module contract version spoken by this runner. + APIVersion = "hado.dev/v1" + // DefaultMaxStdoutBytes caps module stdout when Spec.MaxStdoutBytes is unset. + DefaultMaxStdoutBytes int64 = 1024 * 1024 +) + +// Spec describes one external module invocation. +type Spec struct { + ID string + Command string + Args []string + Env []string + Timeout time.Duration + MaxStdoutBytes int64 +} + +// ModuleIdentity identifies the module that produced a result. +type ModuleIdentity struct { + ID string `json:"id,omitempty"` + Version string `json:"version,omitempty"` +} + +// Metric is one normalized measurement returned by a module. +type Metric struct { + ID string `json:"id"` + Value float64 `json:"value"` + Unit string `json:"unit,omitempty"` +} + +// RunRequest is written as JSON to module stdin. +type RunRequest struct { + APIVersion string `json:"apiVersion"` + RunID string `json:"runId,omitempty"` + Operation string `json:"operation"` + Service any `json:"service,omitempty"` + Standard any `json:"standard,omitempty"` + Evidence any `json:"evidence,omitempty"` + Inputs map[string]any `json:"inputs,omitempty"` +} + +// RunResult is read as JSON from module stdout. +type RunResult struct { + APIVersion string `json:"apiVersion"` + Module ModuleIdentity `json:"module,omitempty"` + Metrics []Metric `json:"metrics,omitempty"` +} diff --git a/modules/github/README.md b/modules/github/README.md deleted file mode 100644 index e055b90..0000000 --- a/modules/github/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# GitHub Module - -Placeholder for GitHub repository, pull request, checks, comments, and artifact evidence. diff --git a/modules/gobce/README.md b/modules/gobce/README.md new file mode 100644 index 0000000..5a3d975 --- /dev/null +++ b/modules/gobce/README.md @@ -0,0 +1,7 @@ +# gobce module + +`gobce` is HADO's bundled coverage module wrapper for Go services. + +The executable entrypoint lives at `modules/gobce/cmd/hado-gobce`. It reads a +Module Runner `RunRequest` from stdin, calls the external `gobce analyze` CLI, +and writes a Module Runner `RunResult` to stdout. diff --git a/modules/gobce/cmd/hado-gobce/main.go b/modules/gobce/cmd/hado-gobce/main.go new file mode 100644 index 0000000..e61a47c --- /dev/null +++ b/modules/gobce/cmd/hado-gobce/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/keyskey/hado/modules/gobce" +) + +func main() { + os.Exit(gobce.Run(os.Stdin, os.Stdout, os.Stderr)) +} diff --git a/modules/gobce/module.go b/modules/gobce/module.go new file mode 100644 index 0000000..6e64b00 --- /dev/null +++ b/modules/gobce/module.go @@ -0,0 +1,112 @@ +package gobce + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + + "github.com/keyskey/hado/internal/coverage" + "github.com/keyskey/hado/internal/modulerunner" + "github.com/keyskey/hado/internal/standard" +) + +const ( + defaultCoverprofile = "coverage.out" + defaultGobceCommand = "gobce" +) + +type gobceResult struct { + StatementCoverage float64 `json:"statementCoverage"` + EstimatedBranchCoverage float64 `json:"estimatedBranchCoverage"` +} + +// Run executes the gobce wrapper module process. +func Run(stdin io.Reader, stdout, stderr io.Writer) int { + if err := run(stdin, stdout); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} + +func run(stdin io.Reader, stdout io.Writer) error { + var request modulerunner.RunRequest + if err := json.NewDecoder(stdin).Decode(&request); err != nil { + return fmt.Errorf("decode run request: %w", err) + } + if request.APIVersion != modulerunner.APIVersion { + return fmt.Errorf("apiVersion must be %q", modulerunner.APIVersion) + } + if request.Operation != "coverage.analyze" { + return fmt.Errorf("unsupported operation %q", request.Operation) + } + + result, err := runGobceAnalyze(gobceCommand(), coverprofilePath()) + if err != nil { + return err + } + metrics := coverage.Metrics{ + C0Coverage: &result.StatementCoverage, + C1Coverage: &result.EstimatedBranchCoverage, + } + if err := metrics.Validate(); err != nil { + return err + } + + return json.NewEncoder(stdout).Encode(modulerunner.RunResult{ + APIVersion: modulerunner.APIVersion, + Module: modulerunner.ModuleIdentity{ + ID: "hado.gobce", + }, + Metrics: []modulerunner.Metric{ + {ID: standard.C0CoverageGateID, Value: result.StatementCoverage, Unit: "percent"}, + {ID: standard.C1CoverageGateID, Value: result.EstimatedBranchCoverage, Unit: "percent"}, + }, + }) +} + +func runGobceAnalyze(command, coverprofile string) (gobceResult, error) { + output, err := os.CreateTemp("", "hado-gobce-*.json") + if err != nil { + return gobceResult{}, fmt.Errorf("create gobce output: %w", err) + } + outputPath := output.Name() + if err := output.Close(); err != nil { + return gobceResult{}, fmt.Errorf("close gobce output: %w", err) + } + defer os.Remove(outputPath) + + cmd := exec.Command(command, "analyze", "--coverprofile", coverprofile, "--format", "json", "--output", outputPath) + stderr, err := cmd.CombinedOutput() + if err != nil { + return gobceResult{}, fmt.Errorf("run gobce analyze: %w: %s", err, stderr) + } + + file, err := os.Open(outputPath) + if err != nil { + return gobceResult{}, fmt.Errorf("open gobce output: %w", err) + } + defer file.Close() + + var result gobceResult + if err := json.NewDecoder(file).Decode(&result); err != nil { + return gobceResult{}, fmt.Errorf("decode gobce output: %w", err) + } + return result, nil +} + +func coverprofilePath() string { + if path := os.Getenv("HADO_COVERPROFILE"); path != "" { + return path + } + return defaultCoverprofile +} + +func gobceCommand() string { + if command := os.Getenv("HADO_GOBCE_COMMAND"); command != "" { + return command + } + return defaultGobceCommand +} diff --git a/modules/gobce/module_test.go b/modules/gobce/module_test.go new file mode 100644 index 0000000..10842a0 --- /dev/null +++ b/modules/gobce/module_test.go @@ -0,0 +1,85 @@ +package gobce + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/keyskey/hado/internal/modulerunner" + "github.com/keyskey/hado/internal/standard" +) + +func TestRunEmitsModuleResultFromGobceAnalyze(t *testing.T) { + dir := t.TempDir() + fakeGobce := filepath.Join(dir, "gobce") + if err := os.WriteFile(fakeGobce, []byte(`#!/bin/sh +out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output" ]; then + out="$2" + shift 2 + continue + fi + shift +done +if [ -z "$out" ]; then + echo "missing --output" >&2 + exit 2 +fi +cat > "$out" <<'JSON' +{ + "statementCoverage": 82.1, + "estimatedBranchCoverage": 68.4 +} +JSON +`), 0o755); err != nil { + t.Fatalf("write fake gobce: %v", err) + } + t.Setenv("HADO_GOBCE_COMMAND", fakeGobce) + t.Setenv("HADO_COVERPROFILE", filepath.Join(dir, "coverage.out")) + + stdin := bytes.NewBufferString(`{"apiVersion":"hado.dev/v1","operation":"coverage.analyze"}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run(stdin, &stdout, &stderr) + if code != 0 { + t.Fatalf("Run() code = %d stderr = %q", code, stderr.String()) + } + + var result modulerunner.RunResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if result.APIVersion != modulerunner.APIVersion { + t.Fatalf("APIVersion = %q", result.APIVersion) + } + if result.Module.ID != "hado.gobce" { + t.Fatalf("Module.ID = %q", result.Module.ID) + } + if len(result.Metrics) != 2 { + t.Fatalf("len(Metrics) = %d, want 2", len(result.Metrics)) + } + values := map[string]float64{} + for _, metric := range result.Metrics { + values[metric.ID] = metric.Value + } + if values[standard.C0CoverageGateID] != 82.1 { + t.Fatalf("C0 metric = %f", values[standard.C0CoverageGateID]) + } + if values[standard.C1CoverageGateID] != 68.4 { + t.Fatalf("C1 metric = %f", values[standard.C1CoverageGateID]) + } +} + +func TestRunRejectsUnsupportedOperation(t *testing.T) { + stdin := bytes.NewBufferString(`{"apiVersion":"hado.dev/v1","operation":"other"}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run(stdin, &stdout, &stderr) + if code == 0 { + t.Fatalf("Run() code = 0, want failure") + } +} diff --git a/modules/openapi/README.md b/modules/openapi/README.md deleted file mode 100644 index cdf0cde..0000000 --- a/modules/openapi/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# OpenAPI Module - -Placeholder for OpenAPI contract and API readiness analysis. diff --git a/modules/sbom/README.md b/modules/sbom/README.md deleted file mode 100644 index 9745b23..0000000 --- a/modules/sbom/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# SBOM Module - -Placeholder for software bill of materials parsing and dependency evidence. diff --git a/modules/slack/README.md b/modules/slack/README.md deleted file mode 100644 index e86b45a..0000000 --- a/modules/slack/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Slack Module - -Placeholder for Slack readiness notifications and follow-up reporting.