diff --git a/.github/workflows/architon-example.yml b/.github/workflows/architon-example.yml new file mode 100644 index 0000000..273e5b9 --- /dev/null +++ b/.github/workflows/architon-example.yml @@ -0,0 +1,69 @@ +name: Architon Example + +on: + pull_request: + push: + branches: [main] + +jobs: + architon: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install rv + run: go install ./cmd/rv + + - name: Scan clean fixture + shell: bash + run: | + rv scan internal/importers/kicad/testdata/bom_minimal.csv \ + --format json \ + --out architon-clean-report.json \ + | tee architon-clean-ci.json + + grep -q '"report_version": "1"' architon-clean-ci.json + grep -q '"violations": 0' architon-clean-ci.json + grep -q '"warnings": 0' architon-clean-ci.json + + - name: Scan expected violation fixture + shell: bash + run: | + set +e + rv scan testdata/esp32_overvoltage/netlist.net \ + --meta testdata/esp32_overvoltage/meta.yaml \ + --format github \ + --out architon-violation-report.json \ + | tee architon-violation-annotations.txt + scan_status=${PIPESTATUS[0]} + set -e + + if [ "$scan_status" -ne 2 ]; then + echo "expected rv scan to exit 2 for the overvoltage fixture, got $scan_status" + exit 1 + fi + + grep -q '::error title=ARCHITON CONTRACT VIOLATION::' architon-violation-annotations.txt + grep -q 'contract_id=ESP32-WROOM-32' architon-violation-annotations.txt + grep -q 'component=U1' architon-violation-annotations.txt + grep -q 'net=/+5V' architon-violation-annotations.txt + + - name: Upload Architon reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: architon-reports + path: | + architon-*-report.json + architon-*-ci.json + architon-*-annotations.txt + if-no-files-found: ignore diff --git a/README.md b/README.md index 6d4d30e..f0f3476 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Detailed CLI examples, scan behavior, import modes, rail inference, and advanced - [docs/cli.md](docs/cli.md) - [docs/contracts.md](docs/contracts.md) +- [docs/ci.md](docs/ci.md) - [docs/importers.md](docs/importers.md) - [docs/rail-inference.md](docs/rail-inference.md) - [docs/report-format.md](docs/report-format.md) @@ -189,6 +190,7 @@ No probabilistic models or network calls are used. Validation operates only on t Detailed technical documentation is available in `docs/`: - [docs/architecture.md](docs/architecture.md) — engine architecture and system design +- [docs/ci.md](docs/ci.md) — GitHub Actions, PR comments, and scan artifacts - [docs/contracts.md](docs/contracts.md) — built-in and user system contracts - [docs/importers.md](docs/importers.md) — KiCad/BOM/netlist import behavior - [docs/rail-inference.md](docs/rail-inference.md) — rail voltage inference and coverage diff --git a/cmd/scan.go b/cmd/scan.go index cde0828..e769725 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/csv" - "encoding/json" "errors" "fmt" "io" @@ -62,9 +61,11 @@ func init() { // newScanCmd builds the rv scan command and wires all scan flags. func newScanCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "scan ", - Args: cobra.ExactArgs(1), - Short: "Scan an electronics BOM and generate a deterministic verification report", + Use: "scan ", + Args: cobra.ExactArgs(1), + Short: "Scan an electronics BOM and generate a deterministic verification report", + SilenceUsage: true, + SilenceErrors: true, Long: `Scan an electronics BOM and generate a deterministic verification report. Current supported input: @@ -97,10 +98,10 @@ Examples: if outputFormat == "" { outputFormat = "text" } - if outputFormat != "text" && outputFormat != "json" { + if outputFormat != "text" && outputFormat != "json" && outputFormat != "markdown" && outputFormat != "github" { return &ExitError{ Code: 3, - Err: fmt.Errorf("unsupported output format %q (allowed: text, json)", outputFormat), + Err: fmt.Errorf("unsupported output format %q (allowed: text, json, markdown, github)", outputFormat), } } @@ -200,7 +201,7 @@ Examples: } metaObj = prepared metaLoaded = true - } else { + } else if outputFormat == "text" { fmt.Fprintf(cmd.OutOrStdout(), "Hint: edit .architon/meta.yaml (sources/components) to enable voltage rules\n") } } @@ -329,25 +330,20 @@ Examples: // 3 tool execution failure // ------------------------- exitCode := scanExitCode(designReport) - if outputFormat == "json" { - jsonReport := report.CanonicalizeVerificationReport(designReport) - data, err := json.MarshalIndent(jsonReport, "", " ") + switch outputFormat { + case "json": + data, err := scanRenderCIJSON(designReport, args[0]) if err != nil { - return internalError(fmt.Errorf("marshal scan report JSON: %w", err)) + return internalError(fmt.Errorf("marshal scan CI JSON: %w", err)) } fmt.Fprintln(cmd.OutOrStdout(), string(data)) - switch exitCode { - case 0: - return nil - case 1: - return &ExitError{Code: 1, Err: errors.New("scan completed with warnings")} - case 2: - return &ExitError{Code: 2, Err: errors.New("scan violations detected")} - case 3: - return &ExitError{Code: 3, Err: errors.New("scan failed")} - default: - return &ExitError{Code: 3, Err: fmt.Errorf("scan failed with unexpected exit code %d", exitCode)} - } + return scanReturnExit(exitCode) + case "markdown": + fmt.Fprint(cmd.OutOrStdout(), scanRenderMarkdown(designReport, args[0])) + return scanReturnExit(exitCode) + case "github": + fmt.Fprint(cmd.OutOrStdout(), scanRenderGitHub(designReport, args[0])) + return scanReturnExit(exitCode) } fmt.Fprintf(cmd.OutOrStdout(), "ARCHITON SCAN\n") @@ -436,7 +432,7 @@ Examples: cmd.Flags().String("netlist", "", "Override netlist file path when scanning a project directory") cmd.Flags().String("meta", "", "Override meta file path (default: .architon/meta.yaml if present)") cmd.Flags().String("contracts", "", "Override contracts file path (default: .architon/contracts.yaml if present)") - cmd.Flags().String("format", "text", "Output format: text or json") + cmd.Flags().String("format", "text", "Output format: text, json, markdown, or github") cmd.Flags().Bool("verbose", false, "Show detailed scan diagnostics") cmd.Flags().Bool("explain-rails", false, "Print rail voltage inference provenance and confidence") cmd.Flags().Bool("rails", false, "Alias for --explain-rails") diff --git a/cmd/scan_format.go b/cmd/scan_format.go new file mode 100644 index 0000000..0696ba3 --- /dev/null +++ b/cmd/scan_format.go @@ -0,0 +1,371 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/badimirzai/architon-cli/internal/contracts" + "github.com/badimirzai/architon-cli/internal/report" + "github.com/badimirzai/architon-cli/internal/version" +) + +type scanCIReport struct { + ReportVersion string `json:"report_version"` + RVVersion string `json:"rv_version"` + Summary scanCISummary `json:"summary"` + Findings []scanCIFinding `json:"findings"` +} + +type scanCISummary struct { + InputPath string `json:"input_path"` + Source string `json:"source"` + Violations int `json:"violations"` + Warnings int `json:"warnings"` + Infos int `json:"infos"` + HasFailures bool `json:"has_failures"` + ContractsLoaded int `json:"contracts_loaded"` + UserContractsLoaded int `json:"user_contracts_loaded"` + BuiltInContractsLoaded int `json:"built_in_contracts_loaded"` + ContractCoveragePct float64 `json:"contract_coverage_pct"` + RulesEnabled []string `json:"rules_enabled"` +} + +type scanCIFinding struct { + ID string `json:"id"` + RuleID string `json:"rule_id"` + ContractID string `json:"contract_id"` + ContractSource string `json:"contract_source"` + Severity string `json:"severity"` + Message string `json:"message"` + ComponentRef string `json:"component_ref"` + Net string `json:"net"` + Pin string `json:"pin"` + Requirement string `json:"requirement"` + Fix string `json:"fix"` + Provenance string `json:"provenance"` +} + +func scanRenderCIJSON(result report.VerificationReport, inputPath string) ([]byte, error) { + payload := scanBuildCIReport(result, inputPath) + return json.MarshalIndent(payload, "", " ") +} + +func scanBuildCIReport(result report.VerificationReport, inputPath string) scanCIReport { + result = report.CanonicalizeVerificationReport(result) + violations, findingWarnings, infos := scanFindingSeverityCounts(result.Findings) + warnings := findingWarnings + result.Summary.ParseWarningsCount + rulesEnabled := append([]string{}, result.Summary.EnabledContractRules...) + sort.Strings(rulesEnabled) + + inputPath = strings.TrimSpace(inputPath) + if inputPath == "" { + inputPath = result.Summary.InputFile + } + + findings := make([]scanCIFinding, 0, len(result.Findings)) + for _, finding := range result.Findings { + findings = append(findings, scanBuildCIFinding(finding)) + } + + return scanCIReport{ + ReportVersion: report.SchemaVersion, + RVVersion: version.Get().Version, + Summary: scanCISummary{ + InputPath: inputPath, + Source: result.Summary.Source, + Violations: violations, + Warnings: warnings, + Infos: infos, + HasFailures: result.Summary.HasFailures || result.Summary.ParseErrorsCount > 0 || violations > 0, + ContractsLoaded: result.Summary.UserContractsLoaded + result.Summary.BuiltInContractsLoaded, + UserContractsLoaded: result.Summary.UserContractsLoaded, + BuiltInContractsLoaded: result.Summary.BuiltInContractsLoaded, + ContractCoveragePct: result.Summary.ContractCoveragePercentage, + RulesEnabled: rulesEnabled, + }, + Findings: findings, + } +} + +func scanBuildCIFinding(finding report.RuleResult) scanCIFinding { + id := strings.TrimSpace(finding.ID) + ruleID := strings.TrimSpace(finding.RuleID) + if id == "" { + id = ruleID + } + if ruleID == "" { + ruleID = id + } + + componentRef := strings.TrimSpace(finding.ComponentRef) + if componentRef == "" { + componentRef = strings.TrimSpace(finding.Ref) + } + + return scanCIFinding{ + ID: id, + RuleID: ruleID, + ContractID: scanFindingContractID(finding, ruleID), + ContractSource: scanFindingContractSource(finding), + Severity: normalizeSeverity(finding.Severity), + Message: strings.TrimSpace(finding.Message), + ComponentRef: componentRef, + Net: strings.TrimSpace(finding.Net), + Pin: strings.TrimSpace(finding.Pin), + Requirement: scanFindingRequirement(finding, ruleID), + Fix: strings.TrimSpace(finding.Fix), + Provenance: scanFindingProvenance(finding), + } +} + +func scanRenderMarkdown(result report.VerificationReport, inputPath string) string { + payload := scanBuildCIReport(result, inputPath) + var b strings.Builder + b.WriteString("# Architon Hardware Contract Review\n\n") + b.WriteString(fmt.Sprintf("**Status:** %s - %s, %s, %s\n\n", + scanMarkdownStatus(payload.Summary), + scanPlural(payload.Summary.Violations, "violation"), + scanPlural(payload.Summary.Warnings, "warning"), + scanPlural(payload.Summary.Infos, "info"), + )) + b.WriteString(fmt.Sprintf("**Contract coverage:** %.2f%% (%d applied, %d user contracts loaded, %d built-in contracts loaded)\n\n", + payload.Summary.ContractCoveragePct, + result.Summary.ContractsApplied, + payload.Summary.UserContractsLoaded, + payload.Summary.BuiltInContractsLoaded, + )) + + b.WriteString("## Violations\n\n") + scanWriteMarkdownTable(&b, payload.Findings, "ERROR", "No violations.") + b.WriteString("\n## Warnings\n\n") + scanWriteMarkdownTable(&b, payload.Findings, "WARN", "No warnings.") + b.WriteString("\n## Suggested Fixes\n\n") + scanWriteMarkdownFixes(&b, payload.Findings) + b.WriteString("\n---\n") + b.WriteString("Exit codes: 0 clean/info only, 1 warnings, 2 violations, 3 tool/import/internal failure.\n") + return b.String() +} + +func scanRenderGitHub(result report.VerificationReport, inputPath string) string { + payload := scanBuildCIReport(result, inputPath) + var b strings.Builder + for _, finding := range payload.Findings { + switch normalizeSeverity(finding.Severity) { + case "ERROR": + fmt.Fprintf(&b, "::error title=ARCHITON CONTRACT VIOLATION::%s\n", scanEscapeGitHubAnnotation(scanGitHubAnnotationMessage(finding))) + case "WARN": + fmt.Fprintf(&b, "::warning title=ARCHITON CONTRACT WARNING::%s\n", scanEscapeGitHubAnnotation(scanGitHubAnnotationMessage(finding))) + } + } + return b.String() +} + +func scanReturnExit(exitCode int) error { + if exitCode == 0 { + return nil + } + if exitCode > 0 && exitCode <= 3 { + return silentExit(exitCode) + } + return &ExitError{ + Code: 3, + Err: fmt.Errorf("scan failed with unexpected exit code %d", exitCode), + } +} + +func scanFindingSeverityCounts(findings []report.RuleResult) (violations int, warnings int, infos int) { + for _, finding := range findings { + switch normalizeSeverity(finding.Severity) { + case "ERROR": + violations++ + case "WARN": + warnings++ + case "INFO": + infos++ + } + } + return violations, warnings, infos +} + +func scanFindingContractID(finding report.RuleResult, fallback string) string { + if id := strings.TrimSpace(finding.ContractID); id != "" { + return id + } + if finding.Provenance != nil { + if id := strings.TrimSpace(finding.Provenance.SourceID); id != "" { + return id + } + } + return strings.TrimSpace(fallback) +} + +func scanFindingContractSource(finding report.RuleResult) string { + source := strings.TrimSpace(finding.ContractSource) + switch source { + case string(contracts.ContractSourceBuiltIn), + string(contracts.ContractSourceUserYAML), + string(contracts.ContractSourceMetaYAML), + string(contracts.ContractSourceInferred): + return source + } + return string(contracts.ReportContractSource(finding.Source)) +} + +func scanFindingRequirement(finding report.RuleResult, fallback string) string { + if requirement := strings.TrimSpace(finding.Requirement); requirement != "" { + return requirement + } + return strings.TrimSpace(fallback) +} + +func scanFindingProvenance(finding report.RuleResult) string { + if finding.Provenance == nil { + return "" + } + parts := make([]string, 0, 3) + if source := strings.TrimSpace(finding.Provenance.Source); source != "" { + parts = append(parts, "source="+source) + } + if sourceID := strings.TrimSpace(finding.Provenance.SourceID); sourceID != "" { + parts = append(parts, "source_id="+sourceID) + } + if detail := strings.TrimSpace(finding.Provenance.Detail); detail != "" { + parts = append(parts, "detail="+detail) + } + return strings.Join(parts, "; ") +} + +func scanMarkdownStatus(summary scanCISummary) string { + if summary.Violations > 0 { + return "FAIL" + } + if summary.Warnings > 0 { + return "WARN" + } + return "OK" +} + +func scanWriteMarkdownTable(b *strings.Builder, findings []scanCIFinding, severity string, empty string) { + rows := make([]scanCIFinding, 0) + for _, finding := range findings { + if normalizeSeverity(finding.Severity) == severity { + rows = append(rows, finding) + } + } + if len(rows) == 0 { + b.WriteString(empty) + b.WriteString("\n") + return + } + b.WriteString("| Severity | Contract | Component | Net | Finding | Fix |\n") + b.WriteString("| --- | --- | --- | --- | --- | --- |\n") + for _, finding := range rows { + fmt.Fprintf(b, "| %s | %s | %s | %s | %s | %s |\n", + scanEscapeMarkdownCell(finding.Severity), + scanEscapeMarkdownCell(finding.ContractID), + scanEscapeMarkdownCell(finding.ComponentRef), + scanEscapeMarkdownCell(finding.Net), + scanEscapeMarkdownCell(finding.Message), + scanEscapeMarkdownCell(finding.Fix), + ) + } +} + +func scanWriteMarkdownFixes(b *strings.Builder, findings []scanCIFinding) { + seen := map[string]struct{}{} + wrote := false + for _, finding := range findings { + severity := normalizeSeverity(finding.Severity) + if severity != "ERROR" && severity != "WARN" { + continue + } + fix := strings.TrimSpace(finding.Fix) + if fix == "" { + continue + } + context := scanFindingContext(finding) + key := finding.ContractID + "\x00" + context + "\x00" + fix + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + wrote = true + fmt.Fprintf(b, "- **%s**", scanEscapeMarkdownInline(finding.ContractID)) + if context != "" { + fmt.Fprintf(b, " (%s)", scanEscapeMarkdownInline(context)) + } + fmt.Fprintf(b, ": %s\n", scanEscapeMarkdownInline(fix)) + } + if !wrote { + b.WriteString("No fixes suggested.\n") + } +} + +func scanFindingContext(finding scanCIFinding) string { + parts := make([]string, 0, 3) + if finding.ComponentRef != "" { + parts = append(parts, finding.ComponentRef) + } + if finding.Net != "" { + parts = append(parts, finding.Net) + } + if finding.Pin != "" { + parts = append(parts, "pin "+finding.Pin) + } + return strings.Join(parts, ", ") +} + +func scanGitHubAnnotationMessage(finding scanCIFinding) string { + fields := []string{ + "contract_id=" + scanValueOrNA(finding.ContractID), + "component=" + scanValueOrNA(finding.ComponentRef), + "net=" + scanValueOrNA(finding.Net), + } + if finding.Pin != "" { + fields = append(fields, "pin="+finding.Pin) + } + if finding.RuleID != "" { + fields = append(fields, "rule_id="+finding.RuleID) + } + message := strings.TrimSpace(finding.Message) + if message != "" { + fields = append(fields, message) + } + return strings.Join(fields, "; ") +} + +func scanEscapeGitHubAnnotation(s string) string { + s = strings.ReplaceAll(s, "%", "%25") + s = strings.ReplaceAll(s, "\r", "%0D") + s = strings.ReplaceAll(s, "\n", "%0A") + return s +} + +func scanEscapeMarkdownCell(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\r\n", " ") + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "|", "\\|") + return s +} + +func scanEscapeMarkdownInline(s string) string { + return scanEscapeMarkdownCell(s) +} + +func scanPlural(n int, singular string) string { + if n == 1 { + return fmt.Sprintf("1 %s", singular) + } + return fmt.Sprintf("%d %ss", n, singular) +} + +func scanValueOrNA(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "n/a" + } + return value +} diff --git a/cmd/scan_test.go b/cmd/scan_test.go index 3825944..8e4c3bd 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -128,6 +128,40 @@ type scanRuleFinding struct { } `json:"inference"` } +type scanCIOutput struct { + ReportVersion string `json:"report_version"` + RVVersion string `json:"rv_version"` + Summary struct { + InputPath string `json:"input_path"` + Source string `json:"source"` + Violations int `json:"violations"` + Warnings int `json:"warnings"` + Infos int `json:"infos"` + HasFailures bool `json:"has_failures"` + ContractsLoaded int `json:"contracts_loaded"` + UserContractsLoaded int `json:"user_contracts_loaded"` + BuiltInContractsLoaded int `json:"built_in_contracts_loaded"` + ContractCoveragePct float64 `json:"contract_coverage_pct"` + RulesEnabled []string `json:"rules_enabled"` + } `json:"summary"` + Findings []scanCIFindingOutput `json:"findings"` +} + +type scanCIFindingOutput struct { + ID string `json:"id"` + RuleID string `json:"rule_id"` + ContractID string `json:"contract_id"` + ContractSource string `json:"contract_source"` + Severity string `json:"severity"` + Message string `json:"message"` + ComponentRef string `json:"component_ref"` + Net string `json:"net"` + Pin string `json:"pin"` + Requirement string `json:"requirement"` + Fix string `json:"fix"` + Provenance string `json:"provenance"` +} + func kicadFixturePath(t *testing.T, name string) string { t.Helper() _, file, _, ok := runtime.Caller(0) @@ -284,6 +318,106 @@ func TestScan_ESP32BuiltInContractOvervoltage(t *testing.T) { } } +func TestScan_FormatJSONEmitsCIReport(t *testing.T) { + tmpDir := t.TempDir() + reportPath := filepath.Join(tmpDir, "full-report.json") + netlist := rootFixturePath(t, filepath.Join("esp32_overvoltage", "netlist.net")) + metaPath := rootFixturePath(t, filepath.Join("esp32_overvoltage", "meta.yaml")) + + stdout, err := runScanCommand(t, tmpDir, netlist, "--meta", metaPath, "--out", reportPath, "--format", "json") + if err == nil { + t.Fatal("expected overvoltage exit") + } + var exitErr *ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T", err) + } + if exitErr.Code != 2 { + t.Fatalf("expected exit code 2 to be preserved for violations, got %d", exitErr.Code) + } + if strings.Contains(stdout, "ARCHITON SCAN") || strings.Contains(stdout, "Wrote ") { + t.Fatalf("expected clean JSON stdout, got %q", stdout) + } + + var payload scanCIOutput + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("expected JSON output to parse: %v\n%s", err, stdout) + } + if payload.ReportVersion != "1" { + t.Fatalf("expected report_version 1, got %q", payload.ReportVersion) + } + if payload.RVVersion == "" { + t.Fatalf("expected rv_version, got %+v", payload) + } + if payload.Summary.InputPath != netlist { + t.Fatalf("expected input path %q, got %q", netlist, payload.Summary.InputPath) + } + if payload.Summary.Violations != 1 || !payload.Summary.HasFailures { + t.Fatalf("expected one failed violation summary, got %+v", payload.Summary) + } + if len(payload.Findings) != 1 { + t.Fatalf("expected one finding, got %+v", payload.Findings) + } + finding := payload.Findings[0] + if finding.ContractID != "ESP32-WROOM-32" { + t.Fatalf("expected contract_id ESP32-WROOM-32, got %+v", finding) + } + if finding.ContractSource != "built_in" { + t.Fatalf("expected built_in contract source, got %+v", finding) + } + if finding.ComponentRef != "U1" || finding.Net != "/+5V" || finding.Pin != "VDD" { + t.Fatalf("expected component/net/pin context, got %+v", finding) + } +} + +func TestScan_FormatMarkdownContainsViolatedContractID(t *testing.T) { + tmpDir := t.TempDir() + netlist := rootFixturePath(t, filepath.Join("esp32_overvoltage", "netlist.net")) + metaPath := rootFixturePath(t, filepath.Join("esp32_overvoltage", "meta.yaml")) + + stdout, err := runScanCommand(t, tmpDir, netlist, "--meta", metaPath, "--format", "markdown") + if err == nil { + t.Fatal("expected overvoltage exit") + } + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 2 { + t.Fatalf("expected exit code 2, got %T %v", err, err) + } + if !strings.Contains(stdout, "# Architon Hardware Contract Review\n") { + t.Fatalf("expected markdown title, got %q", stdout) + } + if !strings.Contains(stdout, "| ERROR | ESP32-WROOM-32 | U1 | /+5V |") { + t.Fatalf("expected violated contract ID in markdown table, got %q", stdout) + } + if !strings.Contains(stdout, "Exit codes: 0 clean/info only, 1 warnings, 2 violations, 3 tool/import/internal failure.") { + t.Fatalf("expected exit-code footer, got %q", stdout) + } +} + +func TestScan_FormatGitHubEmitsAnnotations(t *testing.T) { + tmpDir := t.TempDir() + netlist := rootFixturePath(t, filepath.Join("esp32_overvoltage", "netlist.net")) + metaPath := rootFixturePath(t, filepath.Join("esp32_overvoltage", "meta.yaml")) + + stdout, err := runScanCommand(t, tmpDir, netlist, "--meta", metaPath, "--format", "github") + if err == nil { + t.Fatal("expected overvoltage exit") + } + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 2 { + t.Fatalf("expected exit code 2, got %T %v", err, err) + } + if !strings.Contains(stdout, "::error title=ARCHITON CONTRACT VIOLATION::") { + t.Fatalf("expected GitHub error annotation, got %q", stdout) + } + if !strings.Contains(stdout, "contract_id=ESP32-WROOM-32") || !strings.Contains(stdout, "component=U1") || !strings.Contains(stdout, "net=/+5V") { + t.Fatalf("expected annotation context, got %q", stdout) + } + if strings.Contains(stdout, "ARCHITON SCAN") || strings.Contains(stdout, "Wrote ") { + t.Fatalf("expected annotation-only stdout, got %q", stdout) + } +} + func TestScan_KiCadBuiltInPinAliasesAcrossParts(t *testing.T) { tmpDir := t.TempDir() reportPath := filepath.Join(tmpDir, "report.json") @@ -1256,6 +1390,16 @@ func TestScanExitCode(t *testing.T) { }, want: 2, }, + { + name: "rule failure with parse warning", + report: reportpkg.VerificationReport{ + Summary: reportpkg.Summary{ParseWarningsCount: 1}, + Rules: []reportpkg.RuleResult{ + {ID: "BOM_RULE", Severity: "ERROR", Message: "bad part"}, + }, + }, + want: 2, + }, { name: "warning only", report: reportpkg.VerificationReport{ @@ -1265,6 +1409,22 @@ func TestScanExitCode(t *testing.T) { }, want: 1, }, + { + name: "parse warning only", + report: reportpkg.VerificationReport{ + Summary: reportpkg.Summary{ParseWarningsCount: 1}, + }, + want: 1, + }, + { + name: "info only", + report: reportpkg.VerificationReport{ + Rules: []reportpkg.RuleResult{ + {ID: "BOM_RULE", Severity: "INFO", Message: "note"}, + }, + }, + want: 0, + }, { name: "clean scan", report: reportpkg.VerificationReport{}, diff --git a/docs/CLI.md b/docs/CLI.md index 9ed099d..0891b6f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -15,6 +15,9 @@ rv parts show Show one built-in contract part rv init Create .architon metadata or write a starter robot spec rv version Show installed version rv check --output json Emit JSON findings to stdout +rv scan . --format json Emit stable scan JSON to stdout +rv scan . --format markdown Emit PR-comment-ready Markdown +rv scan . --format github Emit GitHub Actions annotations rv --help Show all commands and flags rv check --help Show check command options ``` @@ -36,6 +39,7 @@ rv scan examples/bom/bom.csv rv scan examples/bom/bom.csv --map examples/mapping.yaml rv scan exports/project.net --meta .architon/meta.yaml --rails rv scan . --contracts i2c_pullup_policy.yaml --verbose +rv scan . --format github rv parts list rv parts show ESP32-WROOM-32 ``` @@ -46,6 +50,6 @@ Contracts come from built-in component data, project metadata, schematic/BOM fie Use `--verbose` or `--rails` to inspect rail inference, confidence, and voltage coverage. See [docs/rail-inference.md](docs/rail-inference.md). -Architon writes deterministic JSON reports for CI and tooling. Default scan output is `architon-report.json`. See [docs/report-format.md](docs/report-format.md). +Architon writes deterministic JSON reports for CI and tooling. Default scan output is `architon-report.json`. See [docs/report-format.md](docs/report-format.md) and [docs/ci.md](docs/ci.md). YAML architecture specs and part lookup behavior are documented in [docs/spec.md](docs/spec.md). diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..dfc0f07 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,146 @@ +# CI and PR Review Output + +`rv scan` is deterministic and keeps the same exit-code contract in CI: + +| Exit code | Meaning | +| --- | --- | +| 0 | Clean scan or informational findings only | +| 1 | Warnings were found | +| 2 | Contract violations were found | +| 3 | Tool, import, or internal failure | + +`--format json`, `--format markdown`, and `--format github` do not emit ANSI color. For human-readable logs, use `--no-color` or `NO_COLOR=1` when your runner needs plain text. + +## GitHub Actions Annotations + +Use `--format github` to emit GitHub Actions annotations while preserving the scan exit code. Exit code `2` fails the PR when contract violations are present. + +```yaml +name: Architon Hardware Review + +on: + pull_request: + push: + branches: [main] + +jobs: + architon: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install rv + run: go install ./cmd/rv + + - name: Run Architon scan + run: rv scan . --format github + + - name: Upload JSON report + if: always() + uses: actions/upload-artifact@v4 + with: + name: architon-report + path: architon-report.json + if-no-files-found: ignore +``` + +The scan step does not need `continue-on-error`. GitHub Actions fails the job on exit code `2`, and exit code `1` also marks the step as failed if you choose to treat warnings as blocking. + +## Testing This Source Repository + +This repository is the `rv` source tree, not a KiCad project, so `rv scan .` is expected to exit `3` here. The checked-in example workflow uses deterministic fixtures instead: + +- `internal/importers/kicad/testdata/bom_minimal.csv` must scan cleanly. +- `testdata/esp32_overvoltage/netlist.net` with `testdata/esp32_overvoltage/meta.yaml` must emit a GitHub error annotation and exit `2`. + +That keeps pushes and PRs green when behavior is correct while still proving that GitHub annotation output works. For a hardware project repository, use `rv scan . --format github` once the project root contains a discoverable BOM, netlist, or root KiCad schematic. + +For an external demo project, [badimirzai/architon-kicad-demo](https://github.com/badimirzai/architon-kicad-demo) is a BOM CSV demo. Its README documents `rv scan bom/bom.csv`; it is not intended to prove `rv scan .` project auto-discovery. + +## Fail PRs on Violations Only + +To allow warnings but fail on contract violations or tool failures, capture the scan status and exit only for codes `2` and `3`. + +```yaml +- name: Run Architon scan + shell: bash + run: | + set +e + rv scan . --format github + scan_status=$? + set -e + if [ "$scan_status" -ge 2 ]; then + exit "$scan_status" + fi +``` + +## Machine-Readable JSON + +Use `--format json` when another tool needs a stable CI schema on stdout. + +```bash +rv scan . --format json > architon-ci-report.json +``` + +`rv scan` also writes the full deterministic scan report to `architon-report.json` by default. Override that path with `--out` when needed. + +```bash +rv scan . --format json --out architon-full-report.json > architon-ci-report.json +``` + +## PR Comment Markdown + +Use `--format markdown` to generate a PR-comment-ready review. Capture the exit code, post the comment, then exit with the original status so PR failure behavior is preserved. + +```yaml +- name: Generate Architon PR review + id: architon + shell: bash + run: | + set +e + rv scan . --format markdown > architon-review.md + scan_status=$? + set -e + echo "status=$scan_status" >> "$GITHUB_OUTPUT" + +- name: Post Architon PR comment + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('architon-review.md', 'utf8'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + +- name: Preserve Architon exit code + if: always() + run: exit "${{ steps.architon.outputs.status }}" +``` + +For the comment step, add `pull-requests: write` or `issues: write` permissions according to your repository policy. + +## Artifact Upload + +`rv scan . --format github` writes `architon-report.json` unless `--out` is set. Upload it with `if: always()` so the report is available even when the scan fails. + +```yaml +- name: Upload Architon report + if: always() + uses: actions/upload-artifact@v4 + with: + name: architon-report + path: architon-report.json + if-no-files-found: ignore +``` diff --git a/internal/version/version.go b/internal/version/version.go index 92db541..00b62e7 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -11,7 +11,7 @@ type Info struct { // Get returns version info derived from Go build metadata when available. func Get() Info { - info := Info{Version: "v0.3.1-dev"} + info := Info{Version: "v0.5.0-dev"} buildInfo, ok := debug.ReadBuildInfo() if ok && buildInfo != nil { if buildInfo.Main.Version != "" && buildInfo.Main.Version != "(devel)" {