diff --git a/README.md b/README.md index 6b1ab7c..0ed4ad2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,19 @@ Run Architon on a real hardware design and detect an integration failure: Architon detects integration failures deterministically before hardware is built. ![Invalid I2C pull-up resistance detected by Architon](assets/pullup-high.png) -Architon detects invalid I2C pull-up resistance before bring-up. +Faulty I2C pull-up configuration in a real KiCad schematic. + + +**Offline report summary** + +![Architon offline report summary showing failed hardware contract checks](assets/architon-offline-report.png) + +**Finding details** + +![Architon offline report findings table showing I2C pull-down violations and fix guidance](assets/architon-offline-report2.png) + +Offline HTML reports show failed contracts, affected nets, electrical impact, and fix guidance. + --- @@ -83,8 +95,9 @@ Try Architon on a real KiCad project: ```bash git clone https://github.com/badimirzai/architon-kicad-demo.git -cd demos/pull_up_ohms/no_pull_up -rv scan . --contracts i2c_pullup_policy.yaml +cd demos/pull_up_ohms/pull_down_fail +rv init contracts +rv scan . ``` Expected output example: @@ -94,20 +107,20 @@ ARCHITON SCAN Target: . Result: FAIL — scan violations detected -Parts: 2 +Parts: 4 Nets: 56 Rules: 2 Violations: 2 User contracts loaded: 1 Built-in contracts loaded: 12 -Active user requirements: 1 -Part contract coverage: 100.00% -Parts matched: 2/2 +Active user requirements: 2 +Part contract coverage: 50.00% +Parts matched: 2/4 Rule findings: -- ERROR pullup_ohms: Net /I2C_SCL has no pull-up resistor in scope -- ERROR pullup_ohms: Net /I2C_SDA has no pull-up resistor in scope +- ERROR pullup_ohms: Observed: R1 = 4.7k connects /I2C_SDA to GND. Expected: pull-up resistor between 2.2k and 10k to a compatible positive rail. +- ERROR pullup_ohms: Observed: R2 = 4.7k connects /I2C_SCL to GND. Expected: pull-up resistor between 2.2k and 10k to a compatible positive rail. Generated Netlist: .architon/generated.net Wrote architon-report.json @@ -125,6 +138,7 @@ Core commands: rv check Run deterministic analysis rv scan Import KiCad/BOM data and emit DesignIR report rv graph Emit stable GraphIR JSON for Studio/renderers +rv report Generate offline HTML reports for review/CI artifacts rv contracts validate Validate contracts schema rv parts list List built-in contract parts rv parts show Show one built-in contract part diff --git a/assets/architon-offline-report.png b/assets/architon-offline-report.png new file mode 100644 index 0000000..b406297 Binary files /dev/null and b/assets/architon-offline-report.png differ diff --git a/assets/architon-offline-report2.png b/assets/architon-offline-report2.png new file mode 100644 index 0000000..e9fd600 Binary files /dev/null and b/assets/architon-offline-report2.png differ diff --git a/cmd/graph.go b/cmd/graph.go index 60c003d..81d867f 100644 --- a/cmd/graph.go +++ b/cmd/graph.go @@ -41,6 +41,7 @@ Examples: contractsOverride, _ := cmd.Flags().GetString("contracts") outputFormat, _ := cmd.Flags().GetString("format") outputPath, _ := cmd.Flags().GetString("out") + failOnFindings, _ := cmd.Flags().GetBool("fail-on-findings") noKiCadCLI, _ := cmd.Flags().GetBool("no-kicad-cli") kicadCLIPath, _ := cmd.Flags().GetString("kicad-cli") @@ -88,6 +89,9 @@ Examples: } } fmt.Fprint(cmd.OutOrStdout(), string(data)) + if failOnFindings { + return scanReturnExit(scanExitCode(pipeline.Report)) + } return nil }, } @@ -99,6 +103,7 @@ Examples: cmd.Flags().String("contracts", "", "Override contracts file path (default: .architon/contracts.yaml if present)") cmd.Flags().String("format", "json", "Output format: json") cmd.Flags().String("out", "", "Path to write GraphIR JSON") + cmd.Flags().Bool("fail-on-findings", false, "Exit 1 on warnings and 2 on violations after writing GraphIR") cmd.Flags().Bool("no-kicad-cli", false, "Disable automatic KiCad netlist generation for project directories") cmd.Flags().String("kicad-cli", defaultKiCadCLI, "KiCad CLI binary name or path for automatic netlist generation") return cmd diff --git a/cmd/graph_test.go b/cmd/graph_test.go index a2b629b..e8dfb36 100644 --- a/cmd/graph_test.go +++ b/cmd/graph_test.go @@ -26,10 +26,14 @@ type graphCommandOutput struct { } type graphCommandSummary struct { - Violations int `json:"violations"` - Warnings int `json:"warnings"` - Infos int `json:"infos"` - Findings int `json:"findings"` + Violations int `json:"violations"` + Warnings int `json:"warnings"` + Infos int `json:"infos"` + Findings int `json:"findings"` + HasFailures bool `json:"has_failures"` + UserContractsLoaded int `json:"user_contracts_loaded"` + BuiltInContractsLoaded int `json:"built_in_contracts_loaded"` + ActiveUserRequirements int `json:"active_user_requirements"` } type graphCommandNode struct { @@ -98,6 +102,7 @@ type graphCommandFinding struct { Pin string `json:"pin"` Requirement string `json:"requirement"` Fix string `json:"fix"` + WhyThisMatters string `json:"why_this_matters"` Provenance string `json:"provenance"` } @@ -354,7 +359,7 @@ func TestGraphCommand_DefaultContractsAndOutFlag(t *testing.T) { t.Fatalf("expected JSON stdout without ANSI escapes, got %q", stdout) } stdoutGraph := parseGraphOutput(t, stdout) - if stdoutGraph.Summary.Violations == 0 || len(stdoutGraph.FindingsIndex) == 0 { + if stdoutGraph.Summary.Violations == 0 || stdoutGraph.Summary.UserContractsLoaded != 1 || len(stdoutGraph.FindingsIndex) == 0 { t.Fatalf("expected default .architon/contracts.yaml to be loaded, got %+v", stdoutGraph) } fileData, err := os.ReadFile(filepath.Join(cwd, "graph.json")) @@ -391,6 +396,25 @@ func TestGraphCommand_ContractsFlagOverridesArchitonContractsYAML(t *testing.T) requireNoGraphViolations(t, parseGraphOutput(t, stdout)) } +func TestGraphCommand_FailOnFindingsUsesScanExitSemantics(t *testing.T) { + cwd := writeGraphPullupFixture(t, nil, true) + + stdout, err := runGraphCommand(t, cwd, ".", "--format", "json") + if err != nil { + t.Fatalf("expected default graph command to preserve success exit, got %v\n%s", err, stdout) + } + graph := parseGraphOutput(t, stdout) + if graph.Summary.Violations != 2 { + t.Fatalf("expected graph fixture violations, got %+v", graph.Summary) + } + + failStdout, failErr := runGraphCommand(t, cwd, ".", "--format", "json", "--fail-on-findings") + requireExitCode(t, failErr, 2, failStdout) + if failStdout != stdout { + t.Fatalf("expected fail-on-findings to preserve GraphIR output\nwithout:\n%s\nwith:\n%s", stdout, failStdout) + } +} + func requireGraphNode(t *testing.T, graph graphCommandOutput, id string) graphCommandNode { t.Helper() for _, node := range graph.Nodes { diff --git a/cmd/report.go b/cmd/report.go new file mode 100644 index 0000000..77a75a4 --- /dev/null +++ b/cmd/report.go @@ -0,0 +1,830 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "html/template" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/badimirzai/architon-cli/internal/contracts" + graphir "github.com/badimirzai/architon-cli/internal/graph" + "github.com/badimirzai/architon-cli/internal/report" + "github.com/badimirzai/architon-cli/internal/version" + "github.com/spf13/cobra" +) + +const ( + defaultHTMLReportPath = "architon-report.html" + htmlReportVersion = "1" +) + +func init() { + rootCmd.AddCommand(newReportCmd()) +} + +func newReportCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "report ", + Args: cobra.ExactArgs(1), + Short: "Generate a static offline HTML report from scan and GraphIR data", + SilenceUsage: true, + SilenceErrors: true, + Long: `Generate a professional local HTML report from Architon scan and GraphIR data. + +The report command runs the deterministic scan pipeline and GraphIR generation +internally, embeds both JSON payloads, and writes a static offline HTML artifact. + +Examples: + rv report . --format html --out architon-report.html + rv report exports/project.net --meta .architon/meta.yaml --format html --out report.html + rv report . --contracts .architon/contracts.yaml --format html --out report.html`, + RunE: func(cmd *cobra.Command, args []string) error { + mappingFile, _ := cmd.Flags().GetString("map") + bomOverride, _ := cmd.Flags().GetString("bom") + netlistOverride, _ := cmd.Flags().GetString("netlist") + metaOverride, _ := cmd.Flags().GetString("meta") + contractsOverride, _ := cmd.Flags().GetString("contracts") + outputFormat, _ := cmd.Flags().GetString("format") + outputPath, _ := cmd.Flags().GetString("out") + scanOutputPath, _ := cmd.Flags().GetString("scan-out") + graphOutputPath, _ := cmd.Flags().GetString("graph-out") + noKiCadCLI, _ := cmd.Flags().GetBool("no-kicad-cli") + kicadCLIPath, _ := cmd.Flags().GetString("kicad-cli") + + outputFormat = strings.ToLower(strings.TrimSpace(outputFormat)) + if outputFormat == "" { + outputFormat = "html" + } + if outputFormat != "html" { + return &ExitError{ + Code: 3, + Err: fmt.Errorf("unsupported output format %q (allowed: html)", outputFormat), + } + } + if strings.TrimSpace(outputPath) == "" { + outputPath = defaultHTMLReportPath + } + + pipeline, err := runScanPipeline(args[0], scanPipelineOptions{ + MappingFile: mappingFile, + BOMOverride: bomOverride, + NetlistOverride: netlistOverride, + MetaOverride: metaOverride, + ContractsOverride: contractsOverride, + NoKiCadCLI: noKiCadCLI, + KiCadCLIPath: kicadCLIPath, + }) + if err != nil { + return err + } + + scanResult := report.CanonicalizeVerificationReport(pipeline.Report) + scanJSON, err := json.MarshalIndent(scanResult, "", " ") + if err != nil { + return internalError(fmt.Errorf("marshal embedded scan JSON: %w", err)) + } + graph := graphir.Build(graphir.BuildInput{ + RVVersion: version.Get().Version, + InputPath: args[0], + Design: pipeline.Design, + Report: scanResult, + ContractIR: pipeline.ContractIR, + }) + graphJSON, err := graphir.RenderJSON(graph) + if err != nil { + return internalError(fmt.Errorf("marshal embedded GraphIR JSON: %w", err)) + } + + html, err := renderHTMLReport(htmlReportInput{ + InputPath: args[0], + Scan: scanResult, + Graph: graph, + ContractIR: pipeline.ContractIR, + UserContracts: pipeline.UserContracts, + EmbeddedScanJSON: scanJSON, + EmbeddedGraphJSON: graphJSON, + }) + if err != nil { + return internalError(fmt.Errorf("render HTML report: %w", err)) + } + if err := os.WriteFile(outputPath, html, 0o644); err != nil { + return &ExitError{ + Code: 3, + Err: fmt.Errorf("write HTML report %s: %w", outputPath, err), + } + } + if strings.TrimSpace(scanOutputPath) != "" { + if err := os.WriteFile(scanOutputPath, scanJSON, 0o644); err != nil { + return &ExitError{ + Code: 3, + Err: fmt.Errorf("write embedded scan JSON %s: %w", scanOutputPath, err), + } + } + } + if strings.TrimSpace(graphOutputPath) != "" { + if err := os.WriteFile(graphOutputPath, graphJSON, 0o644); err != nil { + return &ExitError{ + Code: 3, + Err: fmt.Errorf("write embedded GraphIR JSON %s: %w", graphOutputPath, err), + } + } + } + + exitCode := scanExitCode(scanResult) + fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", outputPath) + fmt.Fprintf(cmd.OutOrStdout(), "Embedded scan findings: %d\n", len(scanResult.Findings)) + fmt.Fprintf(cmd.OutOrStdout(), "Embedded graph findings: %d\n", graph.Summary.Findings) + fmt.Fprintf(cmd.OutOrStdout(), "User contracts loaded: %d\n", scanResult.Summary.UserContractsLoaded) + fmt.Fprintf(cmd.OutOrStdout(), "exit code: %d\n", exitCode) + return scanReturnExit(exitCode) + }, + } + + cmd.Flags().String("map", "", "Path to YAML file with explicit BOM header mapping") + cmd.Flags().String("bom", "", "Override BOM file path when scanning a project directory") + 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", "html", "Output format: html") + cmd.Flags().String("out", defaultHTMLReportPath, "Path to write the offline HTML report") + cmd.Flags().String("scan-out", "", "Optional path to write the exact embedded scan JSON") + cmd.Flags().String("graph-out", "", "Optional path to write the exact embedded GraphIR JSON") + cmd.Flags().Bool("no-kicad-cli", false, "Disable automatic KiCad netlist generation for project directories") + cmd.Flags().String("kicad-cli", defaultKiCadCLI, "KiCad CLI binary name or path for automatic netlist generation") + return cmd +} + +type htmlReportInput struct { + InputPath string + Scan report.VerificationReport + Graph graphir.GraphIR + ContractIR *contracts.ContractIR + UserContracts []contracts.SystemContract + EmbeddedScanJSON []byte + EmbeddedGraphJSON []byte +} + +type htmlReportView struct { + Title string + InputPath string + Status string + StatusClass string + RVVersion string + ReportVersion string + Summary htmlSummary + Findings []htmlFinding + Contracts []htmlContract + Components []htmlComponent + Rails []htmlRail + EmbeddedScanJSON template.JS + EmbeddedGraphJSON template.JS +} + +type htmlSummary struct { + Violations int + Warnings int + ContractsLoaded int + UserContractsLoaded int + BuiltInContractsLoaded int + ContractCoverage string + RailCoverage string +} + +type htmlFinding struct { + Severity string + Class string + ContractID string + Source string + Component string + Net string + Message string + WhyThisMatters string + Fix string +} + +type htmlContract struct { + ID string + Source string + Severity string + Component string + Type string +} + +type htmlComponent struct { + Ref string + Value string + Type string + ContractCoverage string + FindingsCount int +} + +type htmlRail struct { + Name string + Voltage string + Source string + Consumers string + FindingsCount int +} + +func renderHTMLReport(input htmlReportInput) ([]byte, error) { + view, err := buildHTMLReportView(input) + if err != nil { + return nil, err + } + var b strings.Builder + tmpl, err := template.New("offline_html_report").Parse(htmlReportTemplate) + if err != nil { + return nil, err + } + if err := tmpl.Execute(&b, view); err != nil { + return nil, err + } + return []byte(b.String()), nil +} + +func buildHTMLReportView(input htmlReportInput) (htmlReportView, error) { + scanResult := report.CanonicalizeVerificationReport(input.Scan) + scanJSON := input.EmbeddedScanJSON + if len(scanJSON) == 0 { + var err error + scanJSON, err = json.MarshalIndent(scanResult, "", " ") + if err != nil { + return htmlReportView{}, fmt.Errorf("marshal embedded scan JSON: %w", err) + } + } + graphJSON := input.EmbeddedGraphJSON + if len(graphJSON) == 0 { + var err error + graphJSON, err = graphir.RenderJSON(input.Graph) + if err != nil { + return htmlReportView{}, fmt.Errorf("marshal embedded GraphIR JSON: %w", err) + } + } + + violations, findingWarnings, _ := scanFindingSeverityCounts(scanResult.Findings) + warnings := findingWarnings + scanResult.Summary.ParseWarningsCount + exitCode := scanExitCode(scanResult) + status, statusClass := htmlStatus(exitCode) + inputPath := strings.TrimSpace(input.InputPath) + if inputPath == "" { + inputPath = scanResult.Summary.InputFile + } + displayPath := htmlReportDisplayInputPath(inputPath) + + return htmlReportView{ + Title: "Architon Offline HTML Report", + InputPath: displayPath, + Status: status, + StatusClass: statusClass, + RVVersion: version.Get().Version, + ReportVersion: htmlReportVersion, + Summary: htmlSummary{ + Violations: violations, + Warnings: warnings, + ContractsLoaded: scanResult.Summary.UserContractsLoaded + scanResult.Summary.BuiltInContractsLoaded, + UserContractsLoaded: scanResult.Summary.UserContractsLoaded, + BuiltInContractsLoaded: scanResult.Summary.BuiltInContractsLoaded, + ContractCoverage: fmt.Sprintf("%.2f%%", scanResult.Summary.ContractCoveragePercentage), + RailCoverage: htmlRailCoverage(scanResult), + }, + Findings: htmlFindings(scanResult), + Contracts: htmlContracts(input.ContractIR, input.UserContracts), + Components: htmlComponents(input.Graph), + Rails: htmlRails(input.Graph), + EmbeddedScanJSON: template.JS(string(scanJSON)), + EmbeddedGraphJSON: template.JS(string(graphJSON)), + }, nil +} + +func htmlStatus(exitCode int) (string, string) { + switch exitCode { + case 0: + return "PASS", "status-pass" + case 1: + return "WARN", "status-warn" + default: + return "FAIL", "status-fail" + } +} + +func htmlRailCoverage(scanResult report.VerificationReport) string { + if scanResult.Derived == nil { + return "n/a" + } + coverage := scanResult.Derived.RailCoverage + if coverage.TotalNets == 0 { + return "n/a" + } + return fmt.Sprintf("%.2f%% %s", coverage.CoverageRatio*100, strings.TrimSpace(coverage.OverallLevel)) +} + +func htmlFindings(scanResult report.VerificationReport) []htmlFinding { + scanResult = report.CanonicalizeVerificationReport(scanResult) + out := make([]htmlFinding, 0, len(scanResult.Findings)) + for _, finding := range scanResult.Findings { + ciFinding := scanBuildCIFinding(finding) + out = append(out, htmlFinding{ + Severity: ciFinding.Severity, + Class: "severity-" + strings.ToLower(ciFinding.Severity), + ContractID: ciFinding.ContractID, + Source: ciFinding.ContractSource, + Component: ciFinding.ComponentRef, + Net: ciFinding.Net, + Message: ciFinding.Message, + WhyThisMatters: htmlOptionalText(ciFinding.WhyThisMatters), + Fix: ciFinding.Fix, + }) + } + return out +} + +func htmlOptionalText(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "-" + } + return value +} + +func htmlReportDisplayInputPath(inputPath string) string { + inputPath = strings.TrimSpace(inputPath) + if inputPath == "" { + return "" + } + clean := filepath.Clean(inputPath) + if clean == "." { + if wd, err := os.Getwd(); err == nil { + if base := filepath.Base(wd); base != "." && base != string(filepath.Separator) { + return base + } + } + return clean + } + if info, err := os.Stat(clean); err == nil && info.IsDir() { + return filepath.Base(clean) + } + if strings.EqualFold(filepath.Base(clean), "generated.net") && filepath.Base(filepath.Dir(clean)) == ".architon" { + projectRoot := filepath.Dir(filepath.Dir(clean)) + if projectRoot == "." || projectRoot == "" { + if wd, err := os.Getwd(); err == nil { + return filepath.Base(wd) + } + } else { + projectBase := filepath.Base(projectRoot) + if projectBase != string(filepath.Separator) && projectBase != "" { + return projectBase + } + } + } + return inputPath +} + +func htmlContracts(contractIR *contracts.ContractIR, userContracts []contracts.SystemContract) []htmlContract { + rows := make([]htmlContract, 0) + seen := map[string]struct{}{} + addSystemContract := func(contract contracts.SystemContract, defaultSource contracts.ContractSourceKind) { + id := strings.TrimSpace(contract.ID) + if id == "" { + id = strings.TrimSpace(contract.MPN) + } + if id == "" { + return + } + source := contract.SourceKind + if source == "" { + source = defaultSource + } + requirementTypes := make([]string, 0, len(contract.Requirements)) + requirementSeen := map[string]struct{}{} + severity := "" + for _, req := range contract.Requirements { + if req.Type != "" { + reqType := string(req.Type) + if _, ok := requirementSeen[reqType]; !ok { + requirementSeen[reqType] = struct{}{} + requirementTypes = append(requirementTypes, reqType) + } + } + severity = htmlMaxSeverity(severity, req.Severity) + } + sort.Strings(requirementTypes) + row := htmlContract{ + ID: id, + Source: string(source), + Severity: normalizeSeverity(severity), + Type: strings.Join(requirementTypes, ", "), + } + key := htmlContractKey(row) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + rows = append(rows, row) + } + + for _, contract := range userContracts { + addSystemContract(contract, contracts.ContractSourceUserYAML) + } + for _, contract := range contracts.BuiltinContracts() { + addSystemContract(contract, contracts.ContractSourceBuiltIn) + } + + if contractIR == nil { + sortHTMLContracts(rows) + return rows + } + for _, req := range contractIR.AppliedRequirements { + id := strings.TrimSpace(req.ContractID) + if id == "" { + id = strings.TrimSpace(req.Provenance.SourceID) + } + if id == "" { + id = string(req.Type) + } + source := strings.TrimSpace(string(req.ContractSource)) + if source == "" { + source = string(contracts.ReportContractSource(req.Source)) + } + if source == string(contracts.ContractSourceUserYAML) || source == string(contracts.ContractSourceBuiltIn) { + continue + } + severity := normalizeSeverity(req.Severity) + row := htmlContract{ + ID: id, + Source: source, + Severity: severity, + Component: req.ComponentRef, + Type: string(req.Type), + } + key := htmlContractKey(row) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + rows = append(rows, row) + } + sortHTMLContracts(rows) + return rows +} + +func sortHTMLContracts(rows []htmlContract) { + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].ID != rows[j].ID { + return rows[i].ID < rows[j].ID + } + if rows[i].Component != rows[j].Component { + return rows[i].Component < rows[j].Component + } + return rows[i].Type < rows[j].Type + }) +} + +func htmlContractKey(row htmlContract) string { + return row.ID + "\x00" + row.Source + "\x00" + row.Component + "\x00" + row.Type +} + +func htmlMaxSeverity(current string, next string) string { + current = normalizeSeverity(current) + next = normalizeSeverity(next) + rank := map[string]int{"INFO": 1, "WARN": 2, "ERROR": 3} + if rank[next] > rank[current] { + return next + } + return current +} + +func htmlComponents(graph graphir.GraphIR) []htmlComponent { + out := make([]htmlComponent, 0, len(graph.Nodes)) + for _, node := range graph.Nodes { + out = append(out, htmlComponent{ + Ref: node.Ref, + Value: node.Metadata.Value, + Type: node.Type, + ContractCoverage: node.ContractCoverage, + FindingsCount: len(node.FindingIDs), + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Ref < out[j].Ref }) + return out +} + +func htmlRails(graph graphir.GraphIR) []htmlRail { + out := make([]htmlRail, 0, len(graph.Rails)) + for _, rail := range graph.Rails { + voltage := "unknown" + if rail.VoltageV != nil { + voltage = fmt.Sprintf("%.3g V", *rail.VoltageV) + } + consumers := append([]string{}, rail.Consumers...) + sort.Strings(consumers) + out = append(out, htmlRail{ + Name: rail.Name, + Voltage: voltage, + Source: rail.SourceRef, + Consumers: strings.Join(consumers, ", "), + FindingsCount: len(rail.FindingIDs), + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +const htmlReportTemplate = ` + + + + + {{.Title}} + + + +
+
+
+

Architon Offline Report

+
{{.InputPath}}
+
+ rv {{.RVVersion}} + report version {{.ReportVersion}} +
+
+
{{.Status}}
+
+
+
+
+
Violations
{{.Summary.Violations}}
+
Warnings
{{.Summary.Warnings}}
+
Contracts Loaded
{{.Summary.ContractsLoaded}}
+
User Contracts
{{.Summary.UserContractsLoaded}}
+
Built-in Contracts
{{.Summary.BuiltInContractsLoaded}}
+
Contract Coverage
{{.Summary.ContractCoverage}}
+
Rail Coverage
{{.Summary.RailCoverage}}
+
+ +
+

Findings

+ {{if .Findings}} +
+ + + + + + + + {{range .Findings}} + + + + + + + + + + + {{end}} + +
SeverityContract IDSourceComponentNetMessageWhy it mattersFix
{{.Severity}}{{.ContractID}}{{.Source}}{{.Component}}{{.Net}}{{.Message}}{{.WhyThisMatters}}{{.Fix}}
+
+ {{else}} +
No findings.
+ {{end}} +
+ +
+

Contracts

+ {{if .Contracts}} +
+ + + + {{range .Contracts}} + + {{end}} + +
Contract IDSourceSeverityComponentRequirement
{{.ID}}{{.Source}}{{.Severity}}{{.Component}}{{.Type}}
+
+ {{else}} +
No contract requirements were applied.
+ {{end}} +
+ +
+

Components

+ {{if .Components}} +
+ + + + {{range .Components}} + + {{end}} + +
Component RefValueTypeContract CoverageFindings Count
{{.Ref}}{{.Value}}{{.Type}}{{.ContractCoverage}}{{.FindingsCount}}
+
+ {{else}} +
No components were imported.
+ {{end}} +
+ +
+

Rails

+ {{if .Rails}} +
+ + + + {{range .Rails}} + + {{end}} + +
RailVoltageSourceConsumersFindings
{{.Name}}{{.Voltage}}{{.Source}}{{.Consumers}}{{.FindingsCount}}
+
+ {{else}} +
No rails were detected.
+ {{end}} +
+ +
+

Embedded Data

+

The scan report and GraphIR payloads are embedded below for offline inspection and artifact reuse.

+
+
+ + + + +` diff --git a/cmd/report_test.go b/cmd/report_test.go new file mode 100644 index 0000000..7ef7aaf --- /dev/null +++ b/cmd/report_test.go @@ -0,0 +1,501 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/badimirzai/architon-cli/internal/ui" +) + +func runReportCommand(t *testing.T, cwd string, args ...string) (string, error) { + t.Helper() + ui.EnableColors(false) + t.Cleanup(func() { + ui.EnableColors(ui.DefaultColorEnabled()) + }) + + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("get wd: %v", err) + } + if err := os.Chdir(cwd); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWD) + }) + + cmd := newReportCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stdout) + cmd.SetArgs(args) + err = cmd.Execute() + return stdout.String(), err +} + +func TestReportCommand_CreatesOfflineHTMLWithEmbeddedDataAndViolationExit(t *testing.T) { + cwd := writeGraphPullupFixture(t, nil, false) + + stdout, err := runReportCommand(t, cwd, "design.net", "--contracts", "contracts.yaml", "--format", "html", "--out", "report.html") + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 2 { + t.Fatalf("expected report command to return scan violation exit 2, got err=%v stdout=%s", err, stdout) + } + + htmlPath := filepath.Join(cwd, "report.html") + data, err := os.ReadFile(htmlPath) + if err != nil { + t.Fatalf("expected report file to be created: %v", err) + } + html := string(data) + for _, want := range []string{ + "Architon Offline Report", + "FAIL", + "i2c_pullups", + "no pull-up resistor", + `id="architon-scan-json"`, + `id="architon-graph-json"`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected HTML report to contain %q\n%s", want, html) + } + } + if strings.Contains(html, "https://") || strings.Contains(html, "http://") { + t.Fatalf("expected report to avoid external network references") + } + + var scan scanReport + if err := json.Unmarshal([]byte(extractEmbeddedJSON(t, html, "architon-scan-json")), &scan); err != nil { + t.Fatalf("embedded scan JSON is invalid: %v", err) + } + if len(scan.Findings) == 0 || scan.Findings[0].ContractID != "i2c_pullups" { + t.Fatalf("expected embedded scan JSON to contain contract finding, got %+v", scan.Findings) + } + + graph := parseGraphOutput(t, extractEmbeddedJSON(t, html, "architon-graph-json")) + if graph.Summary.Violations == 0 || len(graph.Findings) == 0 { + t.Fatalf("expected embedded GraphIR JSON to contain findings, got %+v", graph) + } +} + +func TestReportCommand_WorksWithZeroFindings(t *testing.T) { + cwd := writeGraphPullupFixture(t, []graphResistor{ + {Ref: "R1", Value: "4.7k", A: "/I2C_SDA", B: "/+3V3"}, + {Ref: "R2", Value: "4.7k", A: "/I2C_SCL", B: "/+3V3"}, + }, false) + + stdout, err := runReportCommand(t, cwd, "design.net", "--contracts", "contracts.yaml", "--format", "html", "--out", "clean.html") + if err != nil { + t.Fatalf("expected report command to succeed for zero findings, got %v\n%s", err, stdout) + } + + data, err := os.ReadFile(filepath.Join(cwd, "clean.html")) + if err != nil { + t.Fatalf("expected clean report file to be created: %v", err) + } + html := string(data) + if !strings.Contains(html, "PASS") { + t.Fatalf("expected clean report status PASS\n%s", html) + } + if !strings.Contains(html, "No findings.") { + t.Fatalf("expected clean report to render empty findings state\n%s", html) + } + var scan scanReport + if err := json.Unmarshal([]byte(extractEmbeddedJSON(t, html, "architon-scan-json")), &scan); err != nil { + t.Fatalf("embedded clean scan JSON is invalid: %v", err) + } + if len(scan.Findings) != 0 { + t.Fatalf("expected zero embedded scan findings, got %+v", scan.Findings) + } + graph := parseGraphOutput(t, extractEmbeddedJSON(t, html, "architon-graph-json")) + if graph.Summary.Findings != 0 { + t.Fatalf("expected zero embedded graph findings, got %+v", graph.Summary) + } +} + +func TestReportCommand_RejectsUnsupportedFormat(t *testing.T) { + cwd := writeGraphPullupFixture(t, nil, false) + stdout, err := runReportCommand(t, cwd, "design.net", "--format", "json") + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 3 { + t.Fatalf("expected unsupported format exit 3, got err=%v stdout=%s", err, stdout) + } +} + +func TestReportCommand_HeaderUsesDirectoryBaseNameForDotInput(t *testing.T) { + cwd := t.TempDir() + writeScanTestFile(t, filepath.Join(cwd, "design.net"), graphPullupNetlist([]graphResistor{ + {Ref: "R1", Value: "4.7k", A: "/I2C_SDA", B: "/+3V3"}, + {Ref: "R2", Value: "4.7k", A: "/I2C_SCL", B: "/+3V3"}, + })) + writeScanTestFile(t, filepath.Join(cwd, ".architon", "contracts.yaml"), graphPullupContracts()) + + stdout, err := runReportCommand(t, cwd, ".", "--format", "html", "--out", "report.html") + if err != nil { + t.Fatalf("expected clean report command, got %v\n%s", err, stdout) + } + html := readTestFileString(t, filepath.Join(cwd, "report.html")) + want := `
` + filepath.Base(cwd) + `
` + if !strings.Contains(html, want) { + t.Fatalf("expected report header subhead %q, got\n%s", want, html) + } + if strings.Contains(html, `
.
`) { + t.Fatalf("expected report header not to show dot input") + } +} + +func TestReportCommand_HeaderUsesProjectBaseNameForGeneratedNetlist(t *testing.T) { + cwd := t.TempDir() + writeScanTestFile(t, filepath.Join(cwd, ".architon", "generated.net"), graphPullupNetlist([]graphResistor{ + {Ref: "R1", Value: "4.7k", A: "/I2C_SDA", B: "/+3V3"}, + {Ref: "R2", Value: "4.7k", A: "/I2C_SCL", B: "/+3V3"}, + })) + writeScanTestFile(t, filepath.Join(cwd, ".architon", "contracts.yaml"), graphPullupContracts()) + + stdout, err := runReportCommand(t, cwd, ".architon/generated.net", "--format", "html", "--out", "report.html") + if err != nil { + t.Fatalf("expected clean report command, got %v\n%s", err, stdout) + } + html := readTestFileString(t, filepath.Join(cwd, "report.html")) + want := `
` + filepath.Base(cwd) + `
` + if !strings.Contains(html, want) { + t.Fatalf("expected generated netlist header subhead %q, got\n%s", want, html) + } +} + +func TestReportCommand_PullupFindingsIncludeWhyThisMatters(t *testing.T) { + tests := []struct { + name string + resistors []graphResistor + wantMessage string + wantWhy string + }{ + { + name: "missing", + wantMessage: "Observed: no pull-up resistor found on net /I2C_SDA.", + wantWhy: "I2C lines are open-drain and must idle high. Without pull-ups, SDA/SCL may never reach a valid HIGH level, so devices may not communicate.", + }, + { + name: "pull-down", + resistors: []graphResistor{ + {Ref: "R1", Value: "4.7k", A: "/I2C_SDA", B: "GND"}, + {Ref: "R2", Value: "4.7k", A: "/I2C_SCL", B: "GND"}, + }, + wantMessage: "Observed: R1 = 4.7k connects /I2C_SDA to GND.", + wantWhy: "I2C lines are open-drain and must idle high. A resistor to GND holds the bus low, which can prevent communication entirely.", + }, + { + name: "too strong", + resistors: []graphResistor{ + {Ref: "R1", Value: "1k", A: "/I2C_SDA", B: "/+3V3"}, + {Ref: "R2", Value: "1k", A: "/I2C_SCL", B: "/+3V3"}, + }, + wantMessage: "Observed: effective pull-up on /I2C_SDA is 1k.", + wantWhy: "Too-low pull-up resistance increases sink current when devices pull the line low. This can exceed device limits and distort bus behavior.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cwd := writeGraphPullupFixture(t, tt.resistors, true) + stdout, err := runReportCommand(t, cwd, ".", "--format", "html", "--out", "report.html") + requireExitCode(t, err, 2, stdout) + html := readTestFileString(t, filepath.Join(cwd, "report.html")) + if !strings.Contains(html, "Why it matters") { + t.Fatalf("expected HTML findings table to include Why it matters column") + } + if !strings.Contains(html, tt.wantWhy) { + t.Fatalf("expected HTML to render why_this_matters %q\n%s", tt.wantWhy, html) + } + var scan scanReport + if err := json.Unmarshal([]byte(extractEmbeddedJSON(t, html, "architon-scan-json")), &scan); err != nil { + t.Fatalf("embedded scan JSON invalid: %v", err) + } + finding := requireScanFindingForNet(t, scan, "/I2C_SDA") + if !strings.Contains(finding.Message, tt.wantMessage) { + t.Fatalf("expected message containing %q, got %+v", tt.wantMessage, finding) + } + if finding.WhyThisMatters != tt.wantWhy { + t.Fatalf("expected why_this_matters %q, got %+v", tt.wantWhy, finding) + } + graph := parseGraphOutput(t, extractEmbeddedJSON(t, html, "architon-graph-json")) + graphFinding := requireGraphFindingForNet(t, graph, "/I2C_SDA") + if graphFinding.WhyThisMatters != tt.wantWhy { + t.Fatalf("expected GraphIR why_this_matters %q, got %+v", tt.wantWhy, graphFinding) + } + }) + } +} + +func TestGraphCommand_RailSourceDoesNotUsePassiveOrLoadFallback(t *testing.T) { + cwd := writeGraphPullupFixture(t, nil, true) + + stdout, err := runGraphCommand(t, cwd, ".", "--format", "json") + if err != nil { + t.Fatalf("expected graph command to succeed, got %v\n%s", err, stdout) + } + rail := requireGraphRail(t, parseGraphOutput(t, stdout), "/+3V3") + if rail.SourceRef != "" { + t.Fatalf("expected inferred /+3V3 source_ref to stay blank without a real source, got %+v", rail) + } +} + +func TestReportCommand_ProjectDirectoryArtifactsMatchEmbeddedJSON(t *testing.T) { + cwd := writeGraphPullupFixture(t, nil, true) + + scanStdout, scanErr := runScanCommand(t, cwd, ".", "--contracts", ".architon/contracts.yaml", "--format", "json", "--out", "scan.json") + requireExitCode(t, scanErr, 2, scanStdout) + scan := readScanReport(t, filepath.Join(cwd, "scan.json")) + if !scan.Summary.HasFailures || scan.Summary.UserContractsLoaded != 1 || len(scan.Findings) != 2 { + t.Fatalf("expected scan to report two contract failures, got summary=%+v findings=%+v", scan.Summary, scan.Findings) + } + + graphStdout, err := runGraphCommand(t, cwd, ".", "--contracts", ".architon/contracts.yaml", "--format", "json", "--out", "graph.json") + if err != nil { + t.Fatalf("expected graph command to succeed, got %v\n%s", err, graphStdout) + } + graph := parseGraphOutput(t, graphStdout) + if graph.Summary.Findings != 2 || graph.Summary.Violations != 2 || graph.Summary.UserContractsLoaded != 1 || !graph.Summary.HasFailures { + t.Fatalf("expected graph to report two violations, got %+v", graph.Summary) + } + + reportStdout, reportErr := runReportCommand(t, cwd, ".", "--contracts", ".architon/contracts.yaml", "--format", "html", "--out", "report.html", "--scan-out", "report.json", "--graph-out", "report-graph.json") + requireExitCode(t, reportErr, 2, reportStdout) + for _, want := range []string{ + "Wrote report.html", + "Embedded scan findings: 2", + "Embedded graph findings: 2", + "User contracts loaded: 1", + "exit code: 2", + } { + if !strings.Contains(reportStdout, want) { + t.Fatalf("expected report stdout to contain %q, got %q", want, reportStdout) + } + } + html := readTestFileString(t, filepath.Join(cwd, "report.html")) + embeddedScan := extractEmbeddedJSON(t, html, "architon-scan-json") + embeddedGraph := extractEmbeddedJSON(t, html, "architon-graph-json") + if got := readTestFileString(t, filepath.Join(cwd, "report.json")); got != embeddedScan { + t.Fatalf("--scan-out JSON must equal embedded scan JSON") + } + if got := readTestFileString(t, filepath.Join(cwd, "report-graph.json")); got != embeddedGraph { + t.Fatalf("--graph-out JSON must equal embedded GraphIR JSON") + } + var reportScan scanReport + if err := json.Unmarshal([]byte(embeddedScan), &reportScan); err != nil { + t.Fatalf("embedded scan JSON invalid: %v", err) + } + reportGraph := parseGraphOutput(t, embeddedGraph) + if len(reportScan.Findings) != 2 || reportScan.Summary.UserContractsLoaded != 1 || !reportScan.Summary.HasFailures { + t.Fatalf("expected embedded scan to match scan result, got summary=%+v findings=%+v", reportScan.Summary, reportScan.Findings) + } + if reportGraph.Summary.Findings != 2 || reportGraph.Summary.Violations != 2 || reportGraph.Summary.UserContractsLoaded != 1 || !reportGraph.Summary.HasFailures { + t.Fatalf("expected embedded graph to match graph result, got %+v", reportGraph.Summary) + } +} + +func TestReportCommand_DirectGeneratedNetlistDiscoversProjectContracts(t *testing.T) { + cwd := t.TempDir() + writeScanTestFile(t, filepath.Join(cwd, ".architon", "generated.net"), graphPullupNetlist(nil)) + writeScanTestFile(t, filepath.Join(cwd, ".architon", "contracts.yaml"), graphPullupContracts()) + + scanStdout, scanErr := runScanCommand(t, cwd, ".architon/generated.net", "--format", "json", "--out", "scan.json") + requireExitCode(t, scanErr, 2, scanStdout) + scan := readScanReport(t, filepath.Join(cwd, "scan.json")) + if scan.Summary.UserContractsLoaded != 1 || len(scan.Findings) != 2 { + t.Fatalf("expected direct generated netlist to auto-load project contracts, got summary=%+v findings=%+v", scan.Summary, scan.Findings) + } + + graphStdout, err := runGraphCommand(t, cwd, ".architon/generated.net", "--format", "json") + if err != nil { + t.Fatalf("expected graph command to preserve default success behavior, got %v\n%s", err, graphStdout) + } + if graph := parseGraphOutput(t, graphStdout); graph.Summary.Findings != 2 || graph.Summary.Violations != 2 || graph.Summary.UserContractsLoaded != 1 || !graph.Summary.HasFailures { + t.Fatalf("expected direct generated netlist graph findings, got %+v", graph.Summary) + } + + reportStdout, reportErr := runReportCommand(t, cwd, ".architon/generated.net", "--format", "html", "--out", "report.html") + requireExitCode(t, reportErr, 2, reportStdout) + html := readTestFileString(t, filepath.Join(cwd, "report.html")) + var reportScan scanReport + if err := json.Unmarshal([]byte(extractEmbeddedJSON(t, html, "architon-scan-json")), &reportScan); err != nil { + t.Fatalf("embedded scan JSON invalid: %v", err) + } + reportGraph := parseGraphOutput(t, extractEmbeddedJSON(t, html, "architon-graph-json")) + if reportScan.Summary.UserContractsLoaded != 1 || len(reportScan.Findings) != 2 || reportGraph.Summary.Findings != 2 || reportGraph.Summary.UserContractsLoaded != 1 { + t.Fatalf("expected report embedded generated-netlist findings, scan=%+v graph=%+v", reportScan.Summary, reportGraph.Summary) + } +} + +func TestReportCommand_StandaloneNetlistAndNoContractsAgreeCleanly(t *testing.T) { + for _, tt := range []struct { + name string + setup func(t *testing.T, cwd string) string + }{ + { + name: "standalone netlist", + setup: func(t *testing.T, cwd string) string { + writeScanTestFile(t, filepath.Join(cwd, "standalone.net"), graphPullupNetlist(nil)) + return "standalone.net" + }, + }, + { + name: "project without contracts file", + setup: func(t *testing.T, cwd string) string { + writeScanTestFile(t, filepath.Join(cwd, "design.net"), graphPullupNetlist(nil)) + return "." + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + cwd := t.TempDir() + input := tt.setup(t, cwd) + stdout, err := runScanCommand(t, cwd, input, "--format", "json", "--out", "scan.json") + if err != nil { + t.Fatalf("expected scan to be clean, got %v\n%s", err, stdout) + } + scan := readScanReport(t, filepath.Join(cwd, "scan.json")) + if scan.Summary.UserContractsLoaded != 0 || scan.Summary.ActiveUserRequirements != 0 || len(scan.Findings) != 0 || scan.Summary.HasFailures { + t.Fatalf("expected no contract findings, got summary=%+v findings=%+v", scan.Summary, scan.Findings) + } + graphStdout, err := runGraphCommand(t, cwd, input, "--format", "json") + if err != nil { + t.Fatalf("expected graph to be clean, got %v\n%s", err, graphStdout) + } + if graph := parseGraphOutput(t, graphStdout); graph.Summary.Findings != 0 || graph.Summary.Violations != 0 || graph.Summary.UserContractsLoaded != 0 || graph.Summary.ActiveUserRequirements != 0 || graph.Summary.HasFailures { + t.Fatalf("expected graph to be clean, got %+v", graph.Summary) + } + reportStdout, err := runReportCommand(t, cwd, input, "--format", "html", "--out", "report.html") + if err != nil { + t.Fatalf("expected report to be clean, got %v\n%s", err, reportStdout) + } + html := readTestFileString(t, filepath.Join(cwd, "report.html")) + var embedded scanReport + if err := json.Unmarshal([]byte(extractEmbeddedJSON(t, html, "architon-scan-json")), &embedded); err != nil { + t.Fatalf("embedded scan JSON invalid: %v", err) + } + if embedded.Summary.UserContractsLoaded != 0 || embedded.Summary.ActiveUserRequirements != 0 || len(embedded.Findings) != 0 { + t.Fatalf("expected embedded scan to be clean, got summary=%+v findings=%+v", embedded.Summary, embedded.Findings) + } + }) + } +} + +func TestReportCommand_PullupFindingVariantsStayConsistent(t *testing.T) { + tests := []struct { + name string + resistors []graphResistor + wantNets []string + }{ + { + name: "too strong", + resistors: []graphResistor{ + {Ref: "R1", Value: "1k", A: "/I2C_SDA", B: "/+3V3"}, + {Ref: "R2", Value: "1k", A: "/I2C_SCL", B: "/+3V3"}, + }, + wantNets: []string{"/I2C_SCL", "/I2C_SDA"}, + }, + { + name: "one line broken", + resistors: []graphResistor{ + {Ref: "R2", Value: "4.7k", A: "/I2C_SCL", B: "/+3V3"}, + }, + wantNets: []string{"/I2C_SDA"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cwd := writeGraphPullupFixture(t, tt.resistors, true) + scanStdout, scanErr := runScanCommand(t, cwd, ".", "--format", "json", "--out", "scan.json") + requireExitCode(t, scanErr, 2, scanStdout) + scan := readScanReport(t, filepath.Join(cwd, "scan.json")) + if len(scan.Findings) != len(tt.wantNets) { + t.Fatalf("expected %d scan findings, got %+v", len(tt.wantNets), scan.Findings) + } + graphStdout, err := runGraphCommand(t, cwd, ".", "--format", "json") + if err != nil { + t.Fatalf("expected graph command to succeed, got %v\n%s", err, graphStdout) + } + graph := parseGraphOutput(t, graphStdout) + if graph.Summary.Findings != len(tt.wantNets) || graph.Summary.Violations != len(tt.wantNets) { + t.Fatalf("expected graph findings to match scan, got %+v", graph.Summary) + } + reportStdout, reportErr := runReportCommand(t, cwd, ".", "--format", "html", "--out", "report.html") + requireExitCode(t, reportErr, 2, reportStdout) + html := readTestFileString(t, filepath.Join(cwd, "report.html")) + reportGraph := parseGraphOutput(t, extractEmbeddedJSON(t, html, "architon-graph-json")) + if reportGraph.Summary.Findings != len(tt.wantNets) || reportGraph.Summary.Violations != len(tt.wantNets) { + t.Fatalf("expected embedded graph findings to match, got %+v", reportGraph.Summary) + } + for _, net := range tt.wantNets { + requireViolatingEdgeForNet(t, reportGraph, net) + } + if len(tt.wantNets) == 1 { + for _, edge := range reportGraph.Edges { + if edge.Net != tt.wantNets[0] && edge.Violations != 0 { + t.Fatalf("expected only %s to carry violation linkage, got edge %+v", tt.wantNets[0], edge) + } + } + } + }) + } +} + +func requireExitCode(t *testing.T, err error, want int, stdout string) { + t.Helper() + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != want { + t.Fatalf("expected exit code %d, got err=%v stdout=%s", want, err, stdout) + } +} + +func readTestFileString(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +func requireScanFindingForNet(t *testing.T, scan scanReport, net string) scanRuleFinding { + t.Helper() + for _, finding := range scan.Findings { + if finding.Net == net { + return finding + } + } + t.Fatalf("missing scan finding for net %s in %+v", net, scan.Findings) + return scanRuleFinding{} +} + +func requireGraphFindingForNet(t *testing.T, graph graphCommandOutput, net string) graphCommandFinding { + t.Helper() + for _, finding := range graph.Findings { + if finding.Net == net { + return finding + } + } + t.Fatalf("missing graph finding for net %s in %+v", net, graph.Findings) + return graphCommandFinding{} +} + +func extractEmbeddedJSON(t *testing.T, html string, id string) string { + t.Helper() + startTag := `") + if end < 0 { + t.Fatalf("missing embedded JSON closing tag %s", id) + } + return html[start : start+end] +} diff --git a/cmd/root.go b/cmd/root.go index e19faf8..d40112c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,11 +20,13 @@ Quick help: rv check Run analysis rv scan Import KiCad BOM/netlist/schematic and emit DesignIR report JSON rv graph Emit stable architecture GraphIR JSON + rv report Generate a static offline HTML report rv contracts validate Validate a custom contracts.yaml schema only rv parts list List built-in deterministic contract parts rv init Initialize Architon metadata or write a starter robot spec rv doctor Check local rv and KiCad CLI setup rv check --output json Emit JSON findings + rv report . --format html Write a static offline HTML report rv version Show installed version rv --help Show all commands and flags`, SilenceUsage: true, diff --git a/cmd/scan.go b/cmd/scan.go index 20a4d20..7dc2b78 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -269,6 +269,7 @@ type scanPipelineResult struct { Design *ir.DesignIR Report report.VerificationReport ContractIR *contracts.ContractIR + UserContracts []contracts.SystemContract MetaLoaded bool NameInferResult infer.Result RailInferResult infer.Result @@ -473,6 +474,7 @@ func runScanPipeline(inputPath string, opts scanPipelineOptions) (scanPipelineRe Design: design, Report: designReport, ContractIR: contractIR, + UserContracts: append([]contracts.SystemContract{}, userContracts...), MetaLoaded: metaLoaded, NameInferResult: nameInferRes, RailInferResult: railInferRes, @@ -588,9 +590,9 @@ func resolveScanContractsPath(input resolvedScanInput, override string) (string, return path, true, nil } - candidate := filepath.Join(".architon", "contracts.yaml") - if input.Directory && strings.TrimSpace(input.ProjectPath) != "" { - candidate = filepath.Join(input.ProjectPath, ".architon", "contracts.yaml") + candidate := defaultProjectContractsPath(input) + if candidate == "" { + return "", false, nil } info, err := os.Stat(candidate) if err == nil { @@ -606,6 +608,33 @@ func resolveScanContractsPath(input resolvedScanInput, override string) (string, return candidate, false, err } +// defaultProjectContractsPath returns the only implicit user contract location. +// Direct generated netlist inputs under .architon are treated as project +// artifacts; arbitrary standalone .net files do not trigger project guessing. +func defaultProjectContractsPath(input resolvedScanInput) string { + if input.Directory && strings.TrimSpace(input.ProjectPath) != "" { + return filepath.Join(input.ProjectPath, ".architon", "contracts.yaml") + } + + directPath := strings.TrimSpace(input.DirectPath) + if directPath == "" { + return "" + } + directPath = filepath.Clean(directPath) + if !strings.EqualFold(filepath.Ext(directPath), ".net") { + return "" + } + architonDir := filepath.Dir(directPath) + if filepath.Base(architonDir) != ".architon" { + return "" + } + projectRoot := filepath.Dir(architonDir) + if projectRoot == "." || projectRoot == "" { + projectRoot = "." + } + return filepath.Join(projectRoot, ".architon", "contracts.yaml") +} + // resolveScanInput resolves scan input with default discovery behavior. func resolveScanInput(inputPath string, bomOverride string, netlistOverride string) (resolvedScanInput, error) { return resolveScanInputWithOptions(inputPath, bomOverride, netlistOverride, scanInputOptions{}) @@ -1351,6 +1380,7 @@ func scanReportContractResults(findings []contracts.Finding, inferencesByNet map ContractSource: string(finding.ContractSource), ContractFile: finding.ContractFile, Requirement: finding.Requirement, + WhyThisMatters: finding.WhyThisMatters, Fix: finding.Fix, } if finding.Provenance.Source != "" { diff --git a/cmd/scan_format.go b/cmd/scan_format.go index 0696ba3..fe0934a 100644 --- a/cmd/scan_format.go +++ b/cmd/scan_format.go @@ -44,6 +44,7 @@ type scanCIFinding struct { Pin string `json:"pin"` Requirement string `json:"requirement"` Fix string `json:"fix"` + WhyThisMatters string `json:"why_this_matters,omitempty"` Provenance string `json:"provenance"` } @@ -116,6 +117,7 @@ func scanBuildCIFinding(finding report.RuleResult) scanCIFinding { Pin: strings.TrimSpace(finding.Pin), Requirement: scanFindingRequirement(finding, ruleID), Fix: strings.TrimSpace(finding.Fix), + WhyThisMatters: strings.TrimSpace(finding.WhyThisMatters), Provenance: scanFindingProvenance(finding), } } diff --git a/cmd/scan_test.go b/cmd/scan_test.go index 5293daa..280987b 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -22,6 +22,7 @@ type scanReport struct { Summary struct { Parts int `json:"parts"` Nets int `json:"nets"` + HasFailures bool `json:"has_failures"` ParseErrorsCount int `json:"parse_errors_count"` ParseWarnings []string `json:"parse_warnings"` ParseErrors []string `json:"parse_errors"` @@ -113,6 +114,7 @@ type scanRuleFinding struct { ContractSource string `json:"contract_source"` ContractFile string `json:"contract_file"` Requirement string `json:"requirement"` + WhyThisMatters string `json:"why_this_matters"` Provenance *struct { Source string `json:"source"` SourceID string `json:"source_id"` @@ -159,6 +161,7 @@ type scanCIFindingOutput struct { Pin string `json:"pin"` Requirement string `json:"requirement"` Fix string `json:"fix"` + WhyThisMatters string `json:"why_this_matters"` Provenance string `json:"provenance"` } diff --git a/docs/CLI.md b/docs/CLI.md index 1db8b38..0e4fe25 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -4,6 +4,7 @@ `rv check` validates system architecture from a YAML specification. `rv scan` imports KiCad BOM/netlist data and generates a normalized DesignIR report. `rv graph` emits stable GraphIR JSON for Studio and other renderers. +`rv report` generates a static offline HTML report for CI artifacts and sharing. Core commands: @@ -11,6 +12,7 @@ Core commands: rv check Run deterministic analysis rv scan Import BOM CSV, KiCad .net, or project directory and emit DesignIR report JSON rv graph Emit stable architecture GraphIR JSON +rv report Generate a static offline HTML report rv contracts validate Validate a custom contracts.yaml schema only rv parts list List built-in deterministic contract parts rv parts show Show one built-in contract part @@ -21,6 +23,8 @@ rv scan . --format json Emit stable scan JSON to stdout rv graph . --format json Emit stable GraphIR JSON to stdout rv graph . --format json --out graph.json Emit GraphIR JSON to stdout and graph.json +rv report . --format html --out architon-report.html + Write an offline HTML report rv scan . --format markdown Emit PR-comment-ready Markdown rv scan . --format github Emit GitHub Actions annotations rv --help Show all commands and flags @@ -45,6 +49,7 @@ rv scan examples/bom/bom.csv --map examples/mapping.yaml rv scan exports/project.net --meta .architon/meta.yaml --rails rv graph exports/project.net --meta .architon/meta.yaml --format json rv graph . --contracts examples/contracts/i2c_policy.yaml --format json --out graph.json +rv report . --format html --out architon-report.html rv scan . --contracts i2c_pullup_policy.yaml --verbose rv scan . --format github rv parts list @@ -57,6 +62,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), [docs/graph-ir.md](docs/graph-ir.md), and [docs/ci.md](docs/ci.md). +Architon writes deterministic JSON reports for CI and tooling. Default scan output is `architon-report.json`; default HTML report output is `architon-report.html`. See [docs/report-format.md](docs/report-format.md), [docs/html-report.md](docs/html-report.md), [docs/graph-ir.md](docs/graph-ir.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/demo-readme.gif b/docs/demo-readme.gif index ec4add3..6f7a2ad 100644 Binary files a/docs/demo-readme.gif and b/docs/demo-readme.gif differ diff --git a/docs/demo-readme.tape b/docs/demo-readme.tape index f1ff59c..8e4d714 100644 --- a/docs/demo-readme.tape +++ b/docs/demo-readme.tape @@ -1,8 +1,8 @@ Output demo-readme.gif Set FontSize 20 -Set Width 1400 -Set Height 900 +Set Width 1300 +Set Height 850 Set Theme "Catppuccin Mocha" Set Padding 40 Set PlaybackSpeed 0.8 @@ -13,6 +13,6 @@ Enter Sleep 2000ms -Type "rv scan . --contracts i2c_pullup_policy.yaml" +Type "rv scan ." Enter Sleep 20000ms \ No newline at end of file diff --git a/docs/graph-ir.md b/docs/graph-ir.md index 04b2429..65f07f9 100644 --- a/docs/graph-ir.md +++ b/docs/graph-ir.md @@ -30,7 +30,11 @@ Top-level object: "violations": 0, "warnings": 0, "infos": 0, - "findings": 0 + "findings": 0, + "has_failures": false, + "user_contracts_loaded": 0, + "built_in_contracts_loaded": 0, + "active_user_requirements": 0 }, "nodes": [], "edges": [], @@ -43,6 +47,8 @@ Top-level object: `summary` counts embedded scan findings by severity. `ERROR` increments `violations`, `WARN` increments `warnings`, and `INFO` increments `infos`. +The contract counts and `has_failures` are copied from the same canonical scan +result used to build the graph. ### Nodes @@ -136,8 +142,10 @@ voltage propagation. ``` `voltage_v` is emitted for explicit, propagated, or inferred rails. `source_ref` -is the best deterministic source component when one is known, otherwise an empty -string. +is set only when a plausible power-producing source is known, such as a +regulator, power source, or contract-backed power output. Passive pull-ups, +loads, and arbitrary first components are not used as rail sources; unknown +sources are emitted as an empty string. ### Interfaces @@ -180,12 +188,13 @@ stable IDs assigned for graph linking. "contract_id": "i2c_pullups", "contract_source": "user_yaml", "severity": "ERROR", - "message": "Net /I2C_SDA has no pull-up resistor in scope", + "message": "Observed: no pull-up resistor found on net /I2C_SDA. Expected: pull-up resistor between 2.2k and 10k to a compatible positive rail.", "component_ref": "", "net": "/I2C_SDA", "pin": "", "requirement": "pullup_ohms", - "fix": "Add a pull-up resistor from /I2C_SDA to the I2C rail between 2200 and 10000 ohms.", + "fix": "Use pull-ups between 2.2k and 10k to the bus voltage rail, commonly 4.7k for a 3.3V I2C bus.", + "why_this_matters": "I2C lines are open-drain and must idle high. Without pull-ups, SDA/SCL may never reach a valid HIGH level, so devices may not communicate.", "provenance": "source=user_yaml; source_id=i2c_pullups" } ``` diff --git a/docs/html-report.md b/docs/html-report.md new file mode 100644 index 0000000..85d8c4b --- /dev/null +++ b/docs/html-report.md @@ -0,0 +1,153 @@ +# Offline HTML Report + +`rv report` creates a static, local HTML artifact from the same deterministic +pipeline used by `rv scan` and `rv graph`. + +```bash +rv report --format html --out architon-report.html +``` + +The command runs scan internally, builds GraphIR internally, embeds both JSON +payloads in the HTML, and writes a report that works without a network +connection, external fonts, CDN assets, or frontend frameworks. + +## Use Cases + +- CI artifacts for pull requests and releases +- Sharing a single visual proof file with teammates +- Inspecting scan findings, contract coverage, components, and rails offline +- Keeping the raw scan report and GraphIR attached to the same artifact + +This is not Studio. It is a static report optimized for local viewing and +artifact preview. + +## Command + +```bash +rv report examples/custom-contracts/exports/custom_contracts.net --format html --out architon-report.html +rv report . --format html --out architon-report.html +rv report . --contracts .architon/contracts.yaml --format html --out report.html +rv report exports/project.net --meta .architon/meta.yaml --format html --out report.html +``` + +`--format` currently supports only `html`. If `--out` is omitted, the default +output path is: + +```text +architon-report.html +``` + +The command accepts the same scan inputs as `rv scan`: KiCad BOM CSV, KiCad +`.net` netlist, or a project directory with discoverable scan inputs. It also +supports the same project input overrides as scan and graph: + +```text +--map +--bom +--netlist +--meta +--contracts +--no-kicad-cli +--kicad-cli +``` + +## Contracts + +For project directories, `rv report` uses the same default contract discovery as +`rv scan` and `rv graph`: + +```bash +rv report . --format html --out report.html +``` + +If `.architon/contracts.yaml` exists in the project, it is loaded. You can also +pin the exact contract file explicitly: + +```bash +rv report . --contracts .architon/contracts.yaml --format html --out report.html +``` + +When the input is a generated netlist under `.architon`, such as +`.architon/generated.net`, the project root is inferred as the parent directory +of `.architon`, so `.architon/contracts.yaml` is still discovered. Standalone +`.net` files outside a project do not guess a contract file; pass `--contracts` +when you want user policies applied. + +## Companion Artifacts + +The HTML embeds the canonical scan JSON and GraphIR JSON used to render the +page. To write those exact payloads next to the report, use: + +```bash +rv report . \ + --contracts .architon/contracts.yaml \ + --format html \ + --out report.html \ + --scan-out report-scan.json \ + --graph-out report-graph.json +``` + +`--scan-out` writes the exact embedded scan JSON. `--graph-out` writes the exact +embedded GraphIR JSON. If either flag is omitted, only the HTML is written. + +Avoid comparing an HTML report with stale JSON files produced by a different +command, different input path, or different flags. Use `--scan-out` and +`--graph-out` when you need artifacts that are guaranteed to match the HTML. + +## CI Example + +```bash +rv report . \ + --contracts .architon/contracts.yaml \ + --format html \ + --out artifacts/report.html \ + --scan-out artifacts/report-scan.json \ + --graph-out artifacts/report-graph.json +``` + +The command writes artifacts before returning the scan-style exit code, so CI can +upload `artifacts/report.html`, `artifacts/report-scan.json`, and +`artifacts/report-graph.json` even when violations fail the job. + +## Contents + +The HTML report includes: + +- Header with project path, PASS/WARN/FAIL status, rv version, and report version +- Summary cards for violations, warnings, loaded contracts, contract coverage, and rail coverage +- Findings table with severity, contract ID, source, component, net, message, why it matters, and fix +- Contracts table with loaded contracts, source, severity, and requirement types +- Components table with component reference, value, type, contract coverage, and finding count +- Rails table with rail name, voltage, source, consumers, and finding count +- Embedded scan JSON and GraphIR JSON + +The embedded payloads are available in the document as: + +```html + + +``` + +## Exit Codes + +`rv report` follows `rv scan` exit behavior after the HTML file is written: + +```text +0 clean or info-only findings +1 warnings +2 violations +3 internal, import, parse, or tool failure +``` + +This means CI can upload the report artifact even when violations are present, +then fail the job with exit code `2`. + +After writing the report, stdout includes the embedded scan finding count, +embedded graph finding count, user contract count, and exit code. This makes +artifact mismatches visible immediately. + +## Offline Guarantees + +The report is a single HTML file. It uses Go `html/template`, inline CSS, and +embedded JSON only. It does not load external JavaScript, external CSS, CDN +resources, web fonts, or remote images. diff --git a/docs/report-format.md b/docs/report-format.md index ade24dd..f25b2ea 100644 --- a/docs/report-format.md +++ b/docs/report-format.md @@ -118,7 +118,7 @@ On parse failures, the report still includes `report_version`, `design_ir.versio Netlist-backed scan reports may include `derived.net_voltages`, `derived.inferred_net_voltages`, `derived.unknown_voltage_nets`, `derived.rail_inferences`, `derived.rail_coverage`, and optional `findings[].inference` provenance. -Contract findings may include `rule_id`, `severity`, `message`, `component_ref`, `net`, `pin`, `bus_id`, `bus_type`, `bus_nets`, `source`, `provenance`, and `fix`. +Contract findings may include `rule_id`, `severity`, `message`, `component_ref`, `net`, `pin`, `bus_id`, `bus_type`, `bus_nets`, `source`, `provenance`, `why_this_matters`, and `fix`. `rules` is a deprecated alias of `findings`. diff --git a/internal/contracts/evaluator.go b/internal/contracts/evaluator.go index fe8eafd..5d80c0d 100644 --- a/internal/contracts/evaluator.go +++ b/internal/contracts/evaluator.go @@ -32,6 +32,7 @@ type Finding struct { Requirement string `json:"requirement,omitempty"` Provenance Provenance `json:"provenance,omitempty"` Fix string `json:"fix,omitempty"` + WhyThisMatters string `json:"why_this_matters,omitempty"` } // EnabledRuleIDs returns the deterministic contract evaluator rule set. diff --git a/internal/contracts/evaluator_system.go b/internal/contracts/evaluator_system.go index ef6ce39..e62a18b 100644 --- a/internal/contracts/evaluator_system.go +++ b/internal/contracts/evaluator_system.go @@ -88,15 +88,16 @@ func evaluatePullupOhms(design *ir.DesignIR, contractIR *ContractIR, req Applied for _, signalNet := range signalNets { pullups := pullupsForSignalNet(design, contractIR, signalNet.Net.Name, req.Scope) if len(pullups) == 0 { - finding := findingForRequirement(req, fmt.Sprintf("Net %s has no pull-up resistor in scope", signalNet.Net.Name)) + invalidPullups := invalidPullupCandidatesForSignalNet(design, contractIR, signalNet.Net.Name, req.Scope) + finding := findingForRequirement(req, pullupMissingMessage(signalNet.Net.Name, invalidPullups)) finding.Net = signalNet.Net.Name attachI2CBusToFinding(&finding, signalNet.BusID, signalNet.BusNets) attachPullupBoundsToFinding(&finding, req) - invalidPullups := invalidPullupCandidatesForSignalNet(design, contractIR, signalNet.Net.Name, req.Scope) if len(invalidPullups) > 0 { finding.ComponentRef = invalidPullups[0].Ref finding.PullupResistors = pullupRefs(invalidPullups) } + finding.WhyThisMatters = pullupMissingWhyThisMatters(invalidPullups) findings = append(findings, finding) continue } @@ -105,18 +106,20 @@ func evaluatePullupOhms(design *ir.DesignIR, contractIR *ContractIR, req Applied continue } if req.MinOhms != nil && effective < *req.MinOhms { - finding := findingForRequirement(req, fmt.Sprintf("Net %s effective pull-up %.0f ohms is below minimum %.0f ohms", signalNet.Net.Name, effective, *req.MinOhms)) + finding := findingForRequirement(req, fmt.Sprintf("Observed: effective pull-up on %s is %s. Expected: %s.", signalNet.Net.Name, formatPullupOhms(effective), pullupExpectedRange(req))) finding.Net = signalNet.Net.Name finding.ComponentRef = pullups[0].Ref + finding.WhyThisMatters = "Too-low pull-up resistance increases sink current when devices pull the line low. This can exceed device limits and distort bus behavior." attachI2CBusToFinding(&finding, signalNet.BusID, signalNet.BusNets) attachPullupDetailsToFinding(&finding, req, effective, pullups) findings = append(findings, finding) continue } if req.MaxOhms != nil && effective > *req.MaxOhms { - finding := findingForRequirement(req, fmt.Sprintf("Net %s effective pull-up %.0f ohms is above maximum %.0f ohms", signalNet.Net.Name, effective, *req.MaxOhms)) + finding := findingForRequirement(req, fmt.Sprintf("Observed: effective pull-up on %s is %s. Expected: %s.", signalNet.Net.Name, formatPullupOhms(effective), pullupExpectedRange(req))) finding.Net = signalNet.Net.Name finding.ComponentRef = pullups[0].Ref + finding.WhyThisMatters = "Too-high pull-up resistance slows rising edges. At higher bus speeds or larger bus capacitance, devices may read invalid logic levels." attachI2CBusToFinding(&finding, signalNet.BusID, signalNet.BusNets) attachPullupDetailsToFinding(&finding, req, effective, pullups) findings = append(findings, finding) @@ -125,6 +128,50 @@ func evaluatePullupOhms(design *ir.DesignIR, contractIR *ContractIR, req Applied return findings } +func pullupMissingMessage(netName string, invalidPullups []pullupCandidate) string { + if len(invalidPullups) > 0 { + pullup := invalidPullups[0] + return fmt.Sprintf("Observed: %s = %s connects %s to %s. Expected: pull-up resistor between 2.2k and 10k to a compatible positive rail.", pullup.Ref, formatPullupOhms(pullup.Ohms), netName, pullup.RailNet) + } + return fmt.Sprintf("Observed: no pull-up resistor found on net %s. Expected: pull-up resistor between 2.2k and 10k to a compatible positive rail.", netName) +} + +func pullupMissingWhyThisMatters(invalidPullups []pullupCandidate) string { + for _, pullup := range invalidPullups { + if isGroundNetName(pullup.RailNet) { + return "I2C lines are open-drain and must idle high. A resistor to GND holds the bus low, which can prevent communication entirely." + } + } + return "I2C lines are open-drain and must idle high. Without pull-ups, SDA/SCL may never reach a valid HIGH level, so devices may not communicate." +} + +func pullupExpectedRange(req AppliedRequirement) string { + if req.MinOhms != nil && req.MaxOhms != nil { + return fmt.Sprintf("%s to %s", formatPullupOhms(*req.MinOhms), formatPullupOhms(*req.MaxOhms)) + } + if req.MinOhms != nil { + return fmt.Sprintf("at least %s", formatPullupOhms(*req.MinOhms)) + } + if req.MaxOhms != nil { + return fmt.Sprintf("at most %s", formatPullupOhms(*req.MaxOhms)) + } + return "a compatible pull-up resistance" +} + +func formatPullupOhms(ohms float64) string { + if math.Abs(ohms) >= 1000 && math.Abs(math.Mod(ohms, 100)) < 1e-9 { + kOhms := ohms / 1000 + if math.Abs(kOhms-math.Round(kOhms)) < 1e-9 { + return fmt.Sprintf("%.0fk", kOhms) + } + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1fk", kOhms), "0"), ".") + } + if math.Abs(ohms-math.Round(ohms)) < 1e-9 { + return fmt.Sprintf("%.0f ohms", ohms) + } + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f ohms", ohms), "0"), ".") +} + // evaluateVoltageCompatible checks scoped nets against explicit voltage limits. func evaluateVoltageCompatible(design *ir.DesignIR, contractIR *ContractIR, req AppliedRequirement) []Finding { nets := scopedVoltageNets(design, req.Scope) diff --git a/internal/contracts/yaml.go b/internal/contracts/yaml.go index 6236474..7ed3c97 100644 --- a/internal/contracts/yaml.go +++ b/internal/contracts/yaml.go @@ -443,7 +443,7 @@ func normalizeRequirementsYAML(id string, scope ContractScope, severity string, Type: ContractPullupOhms, MinOhms: cloneFloat(raw.PullupOhms.Min), MaxOhms: cloneFloat(raw.PullupOhms.Max), - Fix: "Add or resize pull-up resistors so the effective pull-up resistance is within the contract range.", + Fix: "Use pull-ups between 2.2k and 10k to the bus voltage rail, commonly 4.7k for a 3.3V I2C bus.", }) } if raw.VoltageCompatible != nil && *raw.VoltageCompatible { diff --git a/internal/contracts/yaml_test.go b/internal/contracts/yaml_test.go index eb0a822..8318806 100644 --- a/internal/contracts/yaml_test.go +++ b/internal/contracts/yaml_test.go @@ -349,7 +349,7 @@ func TestUserYAMLPullupBelowMinTriggersFinding(t *testing.T) { contractIR := userContractIR(t, design, pullupPolicyYAML(2200, 10000)) finding := requireContractFinding(t, contracts.Evaluate(design, contractIR), contracts.ContractPullupOhms) - if !strings.Contains(finding.Message, "below minimum") { + if !strings.Contains(finding.Message, "Observed: effective pull-up on I2C_SDA is 1k. Expected: 2.2k to 10k.") { t.Fatalf("expected below-min pullup finding, got %+v", finding) } } @@ -359,7 +359,7 @@ func TestUserYAMLPullupAboveMaxTriggersFinding(t *testing.T) { contractIR := userContractIR(t, design, pullupPolicyYAML(2200, 10000)) finding := requireContractFinding(t, contracts.Evaluate(design, contractIR), contracts.ContractPullupOhms) - if !strings.Contains(finding.Message, "above maximum") { + if !strings.Contains(finding.Message, "Observed: effective pull-up on I2C_SDA is 20k. Expected: 2.2k to 10k.") { t.Fatalf("expected above-max pullup finding, got %+v", finding) } } @@ -670,7 +670,7 @@ func TestUserYAMLPullupDetection(t *testing.T) { maxOhms: 10000, resistors: []pullupFixture{{ref: "R1", value: "1k", netA: "I2C_SDA", netB: "+3V3"}}, wantFinding: true, - wantMessage: "effective pull-up 1000 ohms is below minimum 2200 ohms", + wantMessage: "Observed: effective pull-up on I2C_SDA is 1k. Expected: 2.2k to 10k.", }, { name: "20k above max fails", @@ -678,7 +678,7 @@ func TestUserYAMLPullupDetection(t *testing.T) { maxOhms: 10000, resistors: []pullupFixture{{ref: "R1", value: "20k", netA: "I2C_SDA", netB: "+3V3"}}, wantFinding: true, - wantMessage: "effective pull-up 20000 ohms is above maximum 10000 ohms", + wantMessage: "Observed: effective pull-up on I2C_SDA is 20k. Expected: 2.2k to 10k.", }, { name: "two 10k pull-ups compute effective 5k", @@ -695,7 +695,7 @@ func TestUserYAMLPullupDetection(t *testing.T) { maxOhms: 10000, resistors: []pullupFixture{{ref: "R1", value: "4.7k", netA: "I2C_SDA", netB: "GND"}}, wantFinding: true, - wantMessage: "has no pull-up resistor", + wantMessage: "Observed: R1 = 4.7k connects I2C_SDA to GND. Expected: pull-up resistor between 2.2k and 10k to a compatible positive rail.", }, { name: "resistor between SDA and SCL ignored", @@ -703,14 +703,14 @@ func TestUserYAMLPullupDetection(t *testing.T) { maxOhms: 10000, resistors: []pullupFixture{{ref: "R1", value: "4.7k", netA: "I2C_SDA", netB: "I2C_SCL"}}, wantFinding: true, - wantMessage: "has no pull-up resistor", + wantMessage: "Observed: R1 = 4.7k connects I2C_SDA to I2C_SCL. Expected: pull-up resistor between 2.2k and 10k to a compatible positive rail.", }, { name: "no pull-up fails", minOhms: 2200, maxOhms: 10000, wantFinding: true, - wantMessage: "has no pull-up resistor", + wantMessage: "Observed: no pull-up resistor found on net I2C_SDA. Expected: pull-up resistor between 2.2k and 10k to a compatible positive rail.", }, } diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 65a79b5..2e3264d 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -31,10 +31,14 @@ type GraphIR struct { } type Summary struct { - Violations int `json:"violations"` - Warnings int `json:"warnings"` - Infos int `json:"infos"` - Findings int `json:"findings"` + Violations int `json:"violations"` + Warnings int `json:"warnings"` + Infos int `json:"infos"` + Findings int `json:"findings"` + HasFailures bool `json:"has_failures"` + UserContractsLoaded int `json:"user_contracts_loaded"` + BuiltInContractsLoaded int `json:"built_in_contracts_loaded"` + ActiveUserRequirements int `json:"active_user_requirements"` } type Node struct { @@ -111,6 +115,7 @@ type Finding struct { Pin string `json:"pin"` Requirement string `json:"requirement"` Fix string `json:"fix"` + WhyThisMatters string `json:"why_this_matters,omitempty"` Provenance string `json:"provenance"` } @@ -197,12 +202,17 @@ func Build(input BuildInput) GraphIR { b.interfaces = b.buildInterfaces() findingsIndex := b.attachFindings() findings := b.graphFindings() + summary := summarizeFindings(findings) + summary.HasFailures = input.Report.Summary.HasFailures || summary.Violations > 0 + summary.UserContractsLoaded = input.Report.Summary.UserContractsLoaded + summary.BuiltInContractsLoaded = input.Report.Summary.BuiltInContractsLoaded + summary.ActiveUserRequirements = input.Report.Summary.ActiveUserRequirements return GraphIR{ GraphVersion: SchemaVersion, RVVersion: strings.TrimSpace(input.RVVersion), InputPath: input.InputPath, - Summary: summarizeFindings(findings), + Summary: summary, Nodes: b.nodes, Edges: b.edges, Rails: b.rails, @@ -263,6 +273,7 @@ func buildGraphFinding(id string, finding report.RuleResult) Finding { Pin: strings.TrimSpace(finding.Pin), Requirement: findingRequirement(finding, ruleID), Fix: strings.TrimSpace(finding.Fix), + WhyThisMatters: strings.TrimSpace(finding.WhyThisMatters), Provenance: findingProvenance(finding), } } @@ -697,14 +708,6 @@ func (b *builder) sourceRefForRail(net ir.Net) string { return ref } } - for _, ref := range refs { - if classifyNode(b.partByRef[ref], b.input.ContractIR) == "connector" { - return ref - } - } - if len(refs) > 0 && !infer.IsGroundNetName(net.Name) { - return refs[0] - } return "" } diff --git a/internal/report/report.go b/internal/report/report.go index 6338bfa..f747d3e 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -42,6 +42,7 @@ type RuleResult struct { Requirement string `json:"requirement,omitempty"` Provenance *contracts.Provenance `json:"provenance,omitempty"` Fix string `json:"fix,omitempty"` + WhyThisMatters string `json:"why_this_matters,omitempty"` Inference *InferenceProvenance `json:"inference,omitempty"` } diff --git a/internal/version/version.go b/internal/version/version.go index 48344b8..bbd317e 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -14,7 +14,7 @@ type Info struct { // Get returns version info derived from Go build metadata when available. func Get() Info { - info := Info{Version: "v0.6.0-dev"} + info := Info{Version: "v0.7.0-dev"} buildInfo, ok := debug.ReadBuildInfo() if ok && buildInfo != nil { moduleVersion := strings.TrimSpace(buildInfo.Main.Version)