From 7e11329a5d5b0df890e3d34b5b776488d425357d Mon Sep 17 00:00:00 2001 From: SoundMatt Date: Tue, 28 Jul 2026 19:46:49 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20v1.145.0=20=E2=80=94=20second=20FuSaOps?= =?UTF-8?q?=20self-audit=20(8=20bugs)=20+=20spec=20v1.15.1=20clarification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A follow-up deep audit of FuSaOps itself found 8 real defects the prior self-audit fix missed: fmea.json's standard field bypassing the CanonicalStandardID helper, capabilities.json's non-canonical iec62443 id, hara show --format json missing the common header, 5 duplicate requirement ids with no detection anywhere, adapter.Check() discarding exit codes and the §3.2 error envelope, uncategorized findings not normalizing to "other", the aggregate dropping disposition/standard/clause (re-gating on findings already waived upstream), and aggregate renderers never emitting the spec-mandated qualified "/" form. Also resolves SoundMatt/FuSaOps#99: the spec text said schemaVersion was MAJOR.MINOR only, but two independent tools already emit the full MAJOR.MINOR.PATCH — spec v1.15.1 reconciles the text to match. See CHANGELOG.md for the full per-issue breakdown. Signed-off-by: SoundMatt --- .fusa-reqs.json | 25 ++++++++-- CHANGELOG.md | 55 +++++++++++++++++++++ adapter/adapter.go | 84 ++++++++++++++++++++++++++------- adapter/adapter_test.go | 59 ++++++++++++++++++++++- badge/badge.go | 8 ++-- cmd/fusaops/cmd_capabilities.go | 2 +- cmd/fusaops/cmd_req.go | 9 +++- docs/x-fusa-spec.md | 52 +++++++++++++++----- fleet/fleet.go | 2 +- fmea.json | 10 ++-- fmea/fmea.go | 2 +- fusaops.go | 51 +++++++++++++++----- fusaops_test.go | 28 +++++++++++ hara/hara.go | 34 ++++++++++++- hara/hara_test.go | 22 +++++++++ history/history.go | 2 +- report/csv.go | 2 +- report/html.go | 4 +- report/junit.go | 6 +-- report/markdown.go | 6 +-- report/report.go | 16 ++++++- report/report_gap_test.go | 4 +- report/report_test.go | 28 +++++++++++ report/text.go | 2 +- req/req.go | 22 +++++++++ req/req_test.go | 29 ++++++++++++ 26 files changed, 487 insertions(+), 77 deletions(-) diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 93b0ad3..c794fc4 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -15,7 +15,7 @@ "rationale": "Enables tooling and CI to verify which spec version FuSaOps implements without parsing docs." }, { - "id": "REQ-FO-CORE008", + "id": "REQ-FO-CORE010", "title": "CanonicalStandardID helper", "description": "FuSaOps shall export a CanonicalStandardID function mapping a human-readable standard display string to its canonical lowercase id per x-FuSa spec §2.4.1, for use wherever an artifact emits a 'standard' field.", "priority": "MUST", @@ -49,6 +49,13 @@ "priority": "MUST", "rationale": "Enables bidirectional synchronisation of the FuSaOps requirement registry with external safety tools." }, + { + "id": "REQ-FO-REQ004", + "title": "Duplicate requirement id validation", + "description": "req.FindDuplicateIDs shall detect any id appearing more than once in .fusa-reqs.json; fusaops req shall surface each as an ERROR-severity finding (category requirement) and exit non-zero, rather than silently merging or dropping the duplicate.", + "priority": "MUST", + "rationale": "x-FuSa spec §1.2.2: a requirement id MUST be unique within the file; a tool MUST validate this whenever it reads the file." + }, { "id": "REQ-FO-CLI052", "title": "fusaops req subcommand", @@ -1793,7 +1800,7 @@ "rationale": "Engineers need a direct path from a finding in a report to the suppress add command without manual fingerprint computation." }, { - "id": "REQ-FO-FLT005", + "id": "REQ-FO-FLT008", "title": "fleet.Render --format html", "description": "The fleet.Render function shall support format 'html', producing a self-contained HTML page with a per-repo status table (name, status badge, errors, warnings, infos, total), a fleet-level summary footer, and an overall PASS/WARN/FAIL badge. The page shall use no external CSS or JS. The fleet --output flag shall write the HTML to a file.", "priority": "MUST", @@ -1842,7 +1849,7 @@ "rationale": "Allows CI to roll the baseline forward from a single check command without a separate save step." }, { - "id": "REQ-FO-HST003", + "id": "REQ-FO-HST005", "title": "history.Prune function", "description": "The history package shall export a Prune(dir string, keep int) (int, error) function that loads the .fusaops-history.jsonl in dir, retains only the keep most-recent entries, writes the result back, and returns the number of entries removed. A missing file shall return 0, nil. If keep <= 0, MaxSnapshots shall be used.", "priority": "MUST", @@ -1947,14 +1954,14 @@ "rationale": "Mirrors gofusa metrics for tracking project safety health KPIs over CI pipeline runs." }, { - "id": "REQ-FO-BADGE001", + "id": "REQ-FO-BADGE004", "title": "SVG badge types and status derivation", "description": "FuSaOps shall define Badge and Status types; New(errors, warnings, version) shall return StatusFail when errors > 0, StatusWarn when warnings > 0 and no errors, and StatusPass otherwise.", "priority": "MUST", "rationale": "Enables CI pipelines and README files to display a single colour-coded health indicator for the aggregated multi-language safety report." }, { - "id": "REQ-FO-BADGE002", + "id": "REQ-FO-BADGE005", "title": "SVG badge rendering", "description": "FuSaOps shall provide Render(w, badge) that writes a self-contained Shields.io-style SVG badge with label 'fusaops' and a message reflecting the pass/warn/fail status.", "priority": "MUST", @@ -3070,6 +3077,14 @@ "rationale": "x-FuSa spec §1.2.5: a safety goal's fssrRefs must be real, resolvable requirements, not just present.", "standard": "ISO 26262-8 Clause 6" }, + { + "id": "REQ-FO-HARA008", + "title": "hara show --format json carries the §3.1 common header", + "description": "hara.Render's json format shall wrap the HARA content in a Report struct carrying schemaVersion/kind/tool/toolVersion/language/generatedAt, matching the other five artifact-producing commands (fmea/tara/safety-case/sas/sci); .fusa-hara.json itself (Save/Load) remains header-free, since it is an input file per §1.2.5, not a report document.", + "priority": "MUST", + "rationale": "x-FuSa spec §3.1/§9.2: every command's JSON output SHOULD carry the common header so a consumer can route generically; hara show was the one artifact command that skipped it.", + "standard": "ISO 26262-3 Clause 6" + }, { "id": "REQ-FO-CLI084", "title": "Quality-gate wiring: runQualityGate + attestationValid + per-command QualFields extraction and attestation carry-forward across fmea/hara/tara/safety-case/sas", diff --git a/CHANGELOG.md b/CHANGELOG.md index d5603a4..a2a00aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [1.145.0] — 2026-07-29 + +### docs: x-FuSa spec v1.15.1 — schemaVersion/specVersion clarified as MAJOR.MINOR.PATCH (SoundMatt/FuSaOps#99) + +### fix: 8 real bugs from a second FuSaOps self-audit (SoundMatt/FuSaOps#91-98) + +A follow-up deep audit of FuSaOps itself (run right after the prior 10-bug +self-audit fix shipped) found 8 more real defects, plus one genuine spec +ambiguity (#99, resolved above): + +- **#91**: `fmea.json`'s `standard` field was a raw display string + (`"IEC 61508:2010 / ISO 26262:2018 Part 8-7"`) instead of going through the + existing `CanonicalStandardID` helper every sibling package already used — + now emits `"iso26262"`. +- **#92**: `capabilities.json`'s `standards` array listed the bare command + name `"iec62443"` instead of the canonical `"iec62443-4-1"`/`"iec62443-4-2"` + ids. +- **#93**: `hara show --format json` was the one artifact command (of six) + that never wrapped its output in the §3.1 common header. New `hara.Report`/ + `hara.BuildReport` add it — `.fusa-hara.json` itself (Save/Load) is + unchanged, since it's an input file per §1.2.5, not a report document. +- **#94**: 5 duplicate requirement ids in `.fusa-reqs.json` (10 requirements + silently sharing an id with an unrelated one), and no code anywhere + detected this class of defect. Retagged the 5 newer entries to unique ids; + added `req.FindDuplicateIDs`, wired into `fusaops req` as an ERROR-severity + finding (category `requirement`) that exits non-zero, per §1.2.2's MUST. +- **#95**: `adapter.Check()` discarded a tool's exit code entirely and never + decoded the §3.2 `error` envelope, so a crashed (exit 3) tool run was + silently treated as a clean check. `defaultRunner` now surfaces exit 3 + distinctly (exit 1's "found issues" semantics are untouched); `Check()` + decodes `error` and returns it, which the orchestrator already records as a + skipped component — the same treatment an uninstalled tool gets. +- **#96**: Decoded finding `category` was copied verbatim instead of + normalising an unrecognised value to `"other"`, unlike `severity`'s + existing fail-safe handling. New `normaliseCategory` closes the gap. +- **#97**: The aggregate `Finding`/`toolFinding` decoder dropped + `disposition`, `standard`, and `clause` entirely. The `disposition` gap was + behavioral, not just informational: `report.AggregateReport.HasErrors()` + re-gated on ERROR findings an upstream tool had already dispositioned + `accepted`/`deferred`, contradicting §4.1's MUST that a dispositioned + finding must not by itself cause exit 1. New `Finding.Gates()` plus + `HasErrors()` now skip dispositioned findings for gating (still counted in + `Summary.Errors` for display). +- **#98**: The aggregate's renderers (csv/html/junit/markdown/text) emitted + the bare, tool-local `ruleId` instead of the §1.5.1-mandated qualified + `"/"` form the spec names FuSaOps' aggregate specifically + as required to use. New `Finding.QualifiedRuleID()`, used everywhere except + `sarif.go` (each SARIF `run` is already scoped to one tool via + `tool.driver.name`, so a bare ruleId there is unambiguous the same way a + single tool's own document is). + +New `.fusa-reqs.json` entries: `REQ-FO-HARA008` (hara common header), +`REQ-FO-REQ004` (duplicate-id validation), `REQ-FO-CORE011`/`REQ-FO-CORE012` +(`Gates`/`QualifiedRuleID`). + ## [1.144.0] — 2026-07-28 ### fix: FuSaOps' own hara/tara/safetycase/sci/fmea artifacts against spec v1.15.0 — 10 bugs from a fresh self-audit diff --git a/adapter/adapter.go b/adapter/adapter.go index 6c3566b..1efffb6 100644 --- a/adapter/adapter.go +++ b/adapter/adapter.go @@ -105,8 +105,11 @@ func (a *cmdAdapter) Detect(root string) (bool, error) { // // A non-zero exit status from the tool (which the x-FuSa tools use to signal // ERROR-severity findings) is not treated as a failure: the report file is -// parsed regardless. An error is only returned if the report cannot be -// produced or decoded. +// parsed regardless. Exit 3 (§2.3 runtime/internal error) is different: the +// tool could not complete analysis, so Check returns an error (surfacing the +// report's §3.2 error.message when present) even if a partial findings list +// was decoded — the caller (orchestrator) already records a Check error as a +// skipped component, the same treatment an uninstalled tool gets. // //fusa:req REQ-FO-ADP005 func (a *cmdAdapter) Check(ctx context.Context, root string) ([]fusaops.Finding, error) { @@ -118,28 +121,45 @@ func (a *cmdAdapter) Check(ctx context.Context, root string) ([]fusaops.Finding, _ = tmp.Close() defer func() { _ = os.Remove(out) }() - // The runner intentionally ignores the tool's exit code; we read the file. - _, _ = a.run(ctx, root, a.tool, "check", "--format", "json", "--output", out) + _, runErr := a.run(ctx, root, a.tool, "check", "--format", "json", "--output", out) - data, err := os.ReadFile(out) - if err != nil { - return nil, fmt.Errorf("adapter %s: read report: %w", a.name, err) + data, readErr := os.ReadFile(out) + if readErr != nil { + if runErr != nil { + return nil, fmt.Errorf("adapter %s: %w", a.name, runErr) + } + return nil, fmt.Errorf("adapter %s: read report: %w", a.name, readErr) } - findings, err := parseToolReport(data, a.language, a.name) + findings, tr, err := parseToolReport(data, a.language, a.name) if err != nil { return nil, fmt.Errorf("adapter %s: %w", a.name, err) } + if runErr != nil { + if tr.Error != nil { + return findings, fmt.Errorf("adapter %s: runtime error (%s): %s", a.name, tr.Error.Code, tr.Error.Message) + } + return findings, fmt.Errorf("adapter %s: %w", a.name, runErr) + } + if tr.Error != nil { + return findings, fmt.Errorf("adapter %s: runtime error (%s): %s", a.name, tr.Error.Code, tr.Error.Message) + } return findings, nil } -// defaultRunner executes the command in dir and returns combined output, -// swallowing a non-zero exit status (treated as "checks found issues"). +// defaultRunner executes the command in dir and returns combined output. +// A non-zero exit status is swallowed (treated as "checks found issues", the +// normal §2.3 exit-1 gate-failure case) EXCEPT exit 3, which is a §2.3 +// runtime/internal error — the tool could not complete analysis, so it is +// surfaced as a real error rather than silently treated as a clean run. func defaultRunner(ctx context.Context, dir, name string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { - if _, ok := err.(*exec.ExitError); ok { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 3 { + return out, fmt.Errorf("%s exited 3 (runtime/internal error): %w", name, exitErr) + } return out, nil // non-zero exit is expected when findings exist } return out, err @@ -151,6 +171,14 @@ func defaultRunner(ctx context.Context, dir, name string, args ...string) ([]byt // "check --format json". Only the fields FuSaOps needs are decoded. type toolReport struct { Findings []toolFinding `json:"findings"` + Error *toolError `json:"error,omitempty"` +} + +// toolError mirrors the §3.2 structured runtime-error envelope, present only +// when the tool paired a partial document with exit 3. +type toolError struct { + Code string `json:"code"` + Message string `json:"message"` } type toolFinding struct { @@ -163,18 +191,22 @@ type toolFinding struct { Column int `json:"column"` } `json:"location"` Category string `json:"category"` + Disposition string `json:"disposition"` + Standard string `json:"standard"` + Clause string `json:"clause"` Remediation string `json:"remediation"` Fingerprint string `json:"fingerprint"` } // parseToolReport decodes a tool's JSON report into FuSaOps findings, tagging -// each with the originating language and tool name. +// each with the originating language and tool name. It also returns the +// decoded toolReport itself so the caller can inspect the §3.2 error field. // //fusa:req REQ-FO-ADP006 -func parseToolReport(data []byte, lang fusaops.Language, tool string) ([]fusaops.Finding, error) { +func parseToolReport(data []byte, lang fusaops.Language, tool string) ([]fusaops.Finding, toolReport, error) { var tr toolReport if err := json.Unmarshal(data, &tr); err != nil { - return nil, fmt.Errorf("parse tool report: %w", err) + return nil, tr, fmt.Errorf("parse tool report: %w", err) } findings := make([]fusaops.Finding, 0, len(tr.Findings)) for _, f := range tr.Findings { @@ -185,12 +217,15 @@ func parseToolReport(data []byte, lang fusaops.Language, tool string) ([]fusaops Severity: normaliseSeverity(f.Severity), Message: f.Message, Location: fusaops.Location{File: f.Location.File, Line: f.Location.Line, Column: f.Location.Column}, - Category: f.Category, + Category: normaliseCategory(f.Category), + Disposition: f.Disposition, + Standard: f.Standard, + Clause: f.Clause, Remediation: f.Remediation, Fingerprint: f.Fingerprint, }) } - return findings, nil + return findings, tr, nil } // normaliseSeverity maps a tool's severity string onto a FuSaOps Severity, @@ -207,6 +242,23 @@ func normaliseSeverity(s string) fusaops.Severity { } } +// validCategories is the x-FuSa spec §4 closed, extensible category enum. +var validCategories = map[string]bool{ + "lint": true, "style": true, "safety": true, "security": true, + "coverage": true, "requirement": true, "concurrency": true, + "supply-chain": true, "config": true, "other": true, +} + +// normaliseCategory maps any category value outside the §4 closed enum to +// "other", per the spec's consumer MUST — mirroring normaliseSeverity's +// fail-safe treatment of an unrecognised value. +func normaliseCategory(c string) string { + if validCategories[c] { + return c + } + return "other" +} + // skipDir reports whether a directory should be skipped during detection. func skipDir(name string) bool { switch name { diff --git a/adapter/adapter_test.go b/adapter/adapter_test.go index 1397e65..23016a3 100644 --- a/adapter/adapter_test.go +++ b/adapter/adapter_test.go @@ -23,7 +23,7 @@ const sampleReport = `{ //fusa:test REQ-FO-ADP006 func TestParseToolReport(t *testing.T) { - findings, err := parseToolReport([]byte(sampleReport), fusaops.LangGo, "go-FuSa") + findings, _, err := parseToolReport([]byte(sampleReport), fusaops.LangGo, "go-FuSa") if err != nil { t.Fatalf("parse: %v", err) } @@ -44,7 +44,7 @@ func TestParseToolReport(t *testing.T) { } func TestParseToolReportInvalid(t *testing.T) { - if _, err := parseToolReport([]byte("{not json"), fusaops.LangGo, "t"); err == nil { + if _, _, err := parseToolReport([]byte("{not json"), fusaops.LangGo, "t"); err == nil { t.Error("expected error on invalid JSON") } } @@ -61,6 +61,61 @@ func TestNormaliseSeverity(t *testing.T) { } } +//fusa:test REQ-FO-ADP006 +func TestNormaliseCategory(t *testing.T) { + cases := map[string]string{ + "lint": "lint", "security": "security", "supply-chain": "supply-chain", + "other": "other", "": "other", "bogus": "other", + } + for in, want := range cases { + if got := normaliseCategory(in); got != want { + t.Errorf("normaliseCategory(%q): got %q, want %q", in, got, want) + } + } +} + +//fusa:test REQ-FO-ADP005 +func TestCheckExit3SurfacesAsError(t *testing.T) { + reportJSON := `{"findings":[{"ruleId":"X","severity":"ERROR","message":"partial"}],"error":{"code":"internal","message":"crashed mid-scan"}}` + a := &cmdAdapter{ + name: "test-tool", tool: "testtool", language: fusaops.LangGo, + run: func(ctx context.Context, dir, name string, args ...string) ([]byte, error) { + // Emulate the runner writing a partial report then reporting the + // tool's exit 3, mirroring defaultRunner's behavior on exit 3. + for i, a := range args { + if a == "--output" && i+1 < len(args) { + _ = os.WriteFile(args[i+1], []byte(reportJSON), 0o600) + } + } + return nil, errors.New("testtool exited 3 (runtime/internal error)") + }, + } + _, err := a.Check(context.Background(), t.TempDir()) + if err == nil { + t.Fatal("Check: want error on exit-3 runtime failure, got nil") + } +} + +//fusa:test REQ-FO-ADP005 +func TestCheckErrorFieldWithoutRunError(t *testing.T) { + reportJSON := `{"findings":[],"error":{"code":"unsupported","message":"nothing to analyze"}}` + a := &cmdAdapter{ + name: "test-tool", tool: "testtool", language: fusaops.LangGo, + run: func(ctx context.Context, dir, name string, args ...string) ([]byte, error) { + for i, a := range args { + if a == "--output" && i+1 < len(args) { + _ = os.WriteFile(args[i+1], []byte(reportJSON), 0o600) + } + } + return nil, nil + }, + } + _, err := a.Check(context.Background(), t.TempDir()) + if err == nil { + t.Fatal("Check: want error when report's error field is present even with nil run error") + } +} + //fusa:test REQ-FO-ADP004 func TestDetect(t *testing.T) { dir := t.TempDir() diff --git a/badge/badge.go b/badge/badge.go index 949c1c5..8eea729 100644 --- a/badge/badge.go +++ b/badge/badge.go @@ -8,7 +8,7 @@ import ( // Status represents the overall check result. // -//fusa:req REQ-FO-BADGE001 +//fusa:req REQ-FO-BADGE004 type Status int const ( @@ -19,7 +19,7 @@ const ( // Badge holds the data needed to render a badge. // -//fusa:req REQ-FO-BADGE001 +//fusa:req REQ-FO-BADGE004 type Badge struct { Status Status Errors int @@ -29,7 +29,7 @@ type Badge struct { // New returns a Badge derived from finding counts. // -//fusa:req REQ-FO-BADGE001 +//fusa:req REQ-FO-BADGE004 func New(errors, warnings int, version string) Badge { var s Status switch { @@ -45,7 +45,7 @@ func New(errors, warnings int, version string) Badge { // Render writes the badge as a self-contained SVG to w. // -//fusa:req REQ-FO-BADGE002 +//fusa:req REQ-FO-BADGE005 func Render(w io.Writer, b Badge) error { label := "fusaops" msg, color := statusText(b) diff --git a/cmd/fusaops/cmd_capabilities.go b/cmd/fusaops/cmd_capabilities.go index 0188066..d8565d0 100644 --- a/cmd/fusaops/cmd_capabilities.go +++ b/cmd/fusaops/cmd_capabilities.go @@ -102,7 +102,7 @@ func runCapabilities(args []string, stdout, stderr io.Writer) int { "vv": {"text", "json"}, }, Standards: []string{ - "iso26262", "iec61508", "do178c", "iso21434", "unece-r155", "iec62443", "slsa", + "iso26262", "iec61508", "do178c", "iso21434", "unece-r155", "iec62443-4-1", "iec62443-4-2", "slsa", }, } diff --git a/cmd/fusaops/cmd_req.go b/cmd/fusaops/cmd_req.go index d476f5f..b83a4a2 100644 --- a/cmd/fusaops/cmd_req.go +++ b/cmd/fusaops/cmd_req.go @@ -48,6 +48,13 @@ func runReqShow(ids []string, dir string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "fusaops req: %v\n", err) return 1 } + exit := 0 + if dupes := req.FindDuplicateIDs(entries); len(dupes) > 0 { + for _, id := range dupes { + fmt.Fprintf(stderr, "ERROR [REQ-DUP001] requirement category: id %q appears more than once in %s — ids MUST be unique (x-FuSa spec §1.2.2)\n", id, req.ReqsFile) + } + exit = 1 + } filter := make(map[string]bool) for _, id := range ids { filter[id] = true @@ -82,7 +89,7 @@ func runReqShow(ids []string, dir string, stdout, stderr io.Writer) int { } return 1 } - return 0 + return exit } //fusa:req REQ-FO-CLI052 diff --git a/docs/x-fusa-spec.md b/docs/x-fusa-spec.md index 8fe9d8e..4294767 100644 --- a/docs/x-fusa-spec.md +++ b/docs/x-fusa-spec.md @@ -1,6 +1,6 @@ # x-FuSa Tool Specification -**Spec version:** 1.15.0 · **Status:** Normative · **Owner:** FuSaOps +**Spec version:** 1.15.1 · **Status:** Normative · **Owner:** FuSaOps This is the **master contract** every x-FuSa tool (go-FuSa, c-FuSa, cpp-FuSa, and future tools) implements. It defines the CLI surface, the machine-readable output @@ -793,11 +793,19 @@ components — cross-component identity is the `fingerprint` (§4.2). A tool SHO emit the `--dir` value verbatim (resolved to absolute). **`schemaVersion` semantics (MUST).** It is the **spec** version the document -conforms to, `MAJOR.MINOR`. A consumer MUST accept any document whose MAJOR -equals a MAJOR it supports; a MINOR bump is additive and **never** invalidates an -older document (a `1.0` document stays conformant under a `1.1` reader). A tool -emits the highest spec MINOR it fully implements. FuSaOps uses `schemaVersion` as -its parser discriminator. +conforms to, emitted as the tool's full `MAJOR.MINOR.PATCH` (e.g. `"1.15.0"`) — +a tool simply emits its own `SpecVersion` constant verbatim, since a PATCH +release only clarifies existing text and never changes wire shape. A consumer +MUST compare only the first two dot-separated components (`MAJOR.MINOR`) for +compatibility: any document whose MAJOR equals a MAJOR the consumer supports is +accepted regardless of MINOR/PATCH; a MINOR bump is additive and **never** +invalidates an older document (a `1.0` document stays conformant under a `1.1` +reader), and a PATCH difference is never itself a compatibility signal. A tool +emits the highest spec version it fully implements. FuSaOps uses the +`MAJOR.MINOR` prefix of `schemaVersion` as its parser discriminator. (Two-part +`"1.9"`-style values elsewhere in this document are historical worked examples +from when the spec was still MAJOR.MINOR-only — MAJOR.MINOR.PATCH is the +current, correct form for both `schemaVersion` and `specVersion`.) --- @@ -1759,13 +1767,15 @@ Previously tracked MUST gaps now closed: ## 12. Versioning -This spec is semver-versioned. `schemaVersion` in every document is the -`MAJOR.MINOR` it conforms to (§3). Additive, backward-compatible changes bump -MINOR and never invalidate existing documents; a breaking change to a MUST bumps -MAJOR and is coordinated across all tools and FuSaOps in lock-step. Tools and -FuSaOps MUST accept any equal-MAJOR document. When FuSaOps begins consuming a -§9.2/§9.3 command (adding its canonical schema, §13), that is an additive MINOR -bump. +This spec is semver-versioned. `schemaVersion` in every document is the full +`MAJOR.MINOR.PATCH` version it conforms to (§3.2), but compatibility is judged +on the `MAJOR.MINOR` prefix only — a PATCH release is a pure clarification with +no wire-shape change. Additive, backward-compatible changes bump MINOR and +never invalidate existing documents; a breaking change to a MUST bumps MAJOR +and is coordinated across all tools and FuSaOps in lock-step. Tools and FuSaOps +MUST accept any equal-MAJOR document, regardless of MINOR/PATCH. When FuSaOps +begins consuming a §9.2/§9.3 command (adding its canonical schema, §13), that +is an additive MINOR bump. --- @@ -1805,6 +1815,22 @@ tool-defined row. ## 14. Changelog +### 1.15.1 — 2026-07-29 (schemaVersion/specVersion format clarified to MAJOR.MINOR.PATCH, filed as SoundMatt/FuSaOps#99) + +§3.2/§12's normative text said `schemaVersion` (and, by extension, `specVersion`) +was `MAJOR.MINOR` only, but two independently-implemented, already-conformant +tools (FuSaOps and py-FuSa) both emit the full `MAJOR.MINOR.PATCH` spec version +verbatim — and have done so since the spec itself started shipping PATCH +releases (`1.14.0` → `1.14.1` → `1.15.0`). Two independent implementations +converging on the same spec-text-contradicting behavior is a strong signal the +*prose*, not the tools, was stale. Resolution: §3.2's "schemaVersion semantics" +and §12 now bless `MAJOR.MINOR.PATCH` as the documented, correct format for +both fields — a tool emits its `SpecVersion` constant verbatim, no truncation +needed — while keeping the compatibility rule itself unchanged: a consumer +still judges compatibility on the `MAJOR.MINOR` prefix only, never the PATCH +component. No tool needs to change behavior; this is a pure documentation +correction, hence a PATCH bump. + ### 1.15.0 — 2026-07-28 (attestation carry-forward MUST + two implementer-guidance additions, from a same-day 4-tool deep audit) A deep audit of the four tools that had already shipped v1.13.0/v1.14.0/ diff --git a/fleet/fleet.go b/fleet/fleet.go index b43b5e6..6fa9a03 100644 --- a/fleet/fleet.go +++ b/fleet/fleet.go @@ -263,7 +263,7 @@ tfoot td{font-weight:600;border-top:2px solid #e2e8f0;background:#f8fafc} // renderHTML writes a self-contained HTML fleet report to w. // -//fusa:req REQ-FO-FLT005 +//fusa:req REQ-FO-FLT008 func renderHTML(w io.Writer, fr *FleetReport) error { return fleetHTMLTmpl.Execute(w, fr) } diff --git a/fmea.json b/fmea.json index 89108b7..f7b9ea7 100644 --- a/fmea.json +++ b/fmea.json @@ -1,12 +1,12 @@ { - "schemaVersion": "1.15.0", + "schemaVersion": "1.15.1", "kind": "fmea-report", "language": "go", - "generatedAt": "2026-07-28T21:17:46.78645Z", + "generatedAt": "2026-07-29T02:45:59.121719Z", "projectRoot": ".", "tool": "fusaops", - "toolVersion": "1.144.0", - "standard": "IEC 61508:2010 / ISO 26262:2018 Part 8-7", + "toolVersion": "1.145.0", + "standard": "iso26262", "ratingScale": "custom-1-10", "entries": [ { @@ -210,5 +210,5 @@ "coveragePct": 19.5, "componentInventoryMethod": "count of all packages in CLAUDE.md's core orchestration-pipeline table (11) and layered compliance/evidence/workflow table (30), 41 total; componentsAnalyzed counts distinct FMEA entries, not distinct packages named (some entries span multiple packages, e.g. \"auditpack / sign packages\")" }, - "hash": "sha256:52449b2f766eea3ba01b28a9a5cf311855b766ef773545eedf033595ed4d033c" + "hash": "sha256:ad57ee1c0abac5b9244b27d3df0301bd9a64f2a0089764fe526bb027a7fb9f31" } diff --git a/fmea/fmea.go b/fmea/fmea.go index be30ba3..1cc4797 100644 --- a/fmea/fmea.go +++ b/fmea/fmea.go @@ -329,7 +329,7 @@ func Build(root string) (*FMEA, error) { ProjectRoot: root, Tool: "fusaops", ToolVersion: fusaops.Version, - Standard: "IEC 61508:2010 / ISO 26262:2018 Part 8-7", + Standard: fusaops.CanonicalStandardID("ISO 26262"), // IEC 61508:2010 / ISO 26262:2018 Part 8-7 RatingScale: RatingScale, } diff --git a/fusaops.go b/fusaops.go index eb5396c..1c730b5 100644 --- a/fusaops.go +++ b/fusaops.go @@ -20,12 +20,12 @@ import ( ) // Version is the current release of FuSaOps. -const Version = "1.144.0" +const Version = "1.145.0" // SpecVersion is the x-FuSa specification version this release targets. // //fusa:req REQ-FO-CORE007 -const SpecVersion = "1.15.0" +const SpecVersion = "1.15.1" // Sentinel errors. Callers should use errors.Is for comparison. // @@ -106,15 +106,42 @@ func (l Language) String() string { return string(l) } // //fusa:req REQ-FO-CORE004 type Finding struct { - Language Language `json:"language"` - Tool string `json:"tool"` - RuleID string `json:"ruleId"` - Severity Severity `json:"severity"` - Message string `json:"message"` - Location Location `json:"location"` - Category string `json:"category,omitempty"` - Remediation string `json:"remediation,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` + Language Language `json:"language"` + Tool string `json:"tool"` + RuleID string `json:"ruleId"` + Severity Severity `json:"severity"` + Message string `json:"message"` + Location Location `json:"location"` + Category string `json:"category,omitempty"` + // Disposition carries an upstream tool's own §4.1 waiver ("accepted" or + // "deferred") through the aggregate unchanged. A dispositioned finding + // MUST remain in the JSON but MUST NOT by itself gate the aggregate exit + // code — see Severity-independent handling in report.Summary. + Disposition string `json:"disposition,omitempty"` + Standard string `json:"standard,omitempty"` + Clause string `json:"clause,omitempty"` + Remediation string `json:"remediation,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +// Gates reports whether f should count toward a gate-failure exit code. A +// finding already dispositioned "accepted" or "deferred" by its originating +// tool MUST NOT by itself cause exit 1 (x-FuSa spec §4.1). +// +//fusa:req REQ-FO-CORE011 +func (f Finding) Gates() bool { + return f.Disposition != "accepted" && f.Disposition != "deferred" +} + +// QualifiedRuleID returns the cross-language identity "/" +// (e.g. "go/LINT001"). Any reference that can span languages — the FuSaOps +// aggregate by name — MUST use this qualified form rather than the bare, +// only-tool-local RuleID, since two unrelated tools' rules can share a +// literal id (x-FuSa spec §1.5.1). +// +//fusa:req REQ-FO-CORE012 +func (f Finding) QualifiedRuleID() string { + return string(f.Language) + "/" + f.RuleID } var digitRunRE = regexp.MustCompile(`[0-9]+`) @@ -140,7 +167,7 @@ var canonicalStandardIDs = map[string]string{ // remains distinct from the format string, unlike some strings. When display // is not a known standard, it is returned unchanged. // -//fusa:req REQ-FO-CORE008 +//fusa:req REQ-FO-CORE010 func CanonicalStandardID(display string) string { if id, ok := canonicalStandardIDs[display]; ok { return id diff --git a/fusaops_test.go b/fusaops_test.go index 6c7f975..897ef94 100644 --- a/fusaops_test.go +++ b/fusaops_test.go @@ -161,3 +161,31 @@ func TestComputeFingerprint(t *testing.T) { t.Error("different file must produce different fingerprint") } } + +//fusa:test REQ-FO-CORE011 +func TestFindingGates(t *testing.T) { + cases := []struct { + disposition string + want bool + }{ + {"", true}, + {"accepted", false}, + {"deferred", false}, + {"rejected", true}, + {"open", true}, + } + for _, c := range cases { + f := Finding{Disposition: c.disposition} + if got := f.Gates(); got != c.want { + t.Errorf("Gates() with disposition %q: got %v, want %v", c.disposition, got, c.want) + } + } +} + +//fusa:test REQ-FO-CORE012 +func TestFindingQualifiedRuleID(t *testing.T) { + f := Finding{Language: LangGo, RuleID: "LINT001"} + if got := f.QualifiedRuleID(); got != "go/LINT001" { + t.Errorf("QualifiedRuleID() = %q, want %q", got, "go/LINT001") + } +} diff --git a/hara/hara.go b/hara/hara.go index edb9fcc..5990741 100644 --- a/hara/hara.go +++ b/hara/hara.go @@ -125,6 +125,38 @@ type HARA struct { Attestation *fusaops.Attestation `json:"attestation,omitempty"` } +// Report is the §3.1-enveloped `hara show --format json` command-output +// shape — distinct from HARA itself, which Save/Load persist verbatim to +// .fusa-hara.json. Per §1.2.5, .fusa-hara.json is an input file like +// .fusa-reqs.json and is NOT subject to the common header; only the +// command's rendered JSON output is. +// +//fusa:req REQ-FO-HARA008 +type Report struct { + SchemaVersion string `json:"schemaVersion"` + Kind string `json:"kind"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + Language string `json:"language"` + GeneratedAt time.Time `json:"generatedAt"` + HARA +} + +// BuildReport wraps h in the §3.1 common header for command JSON output. +// +//fusa:req REQ-FO-HARA008 +func BuildReport(h *HARA) Report { + return Report{ + SchemaVersion: fusaops.SpecVersion, + Kind: "hara-report", + Tool: "fusaops", + ToolVersion: fusaops.Version, + Language: "go", + GeneratedAt: time.Now().UTC(), + HARA: *h, + } +} + // AttestationContentHash computes the hash a §1.6.2 attestation must match // to be considered non-stale: h's substantive content, excluding Attestation // itself (CreatedAt is included — unlike the generated-evidence packages, @@ -377,7 +409,7 @@ func Render(w io.Writer, h *HARA, format string) error { case "json": enc := json.NewEncoder(w) enc.SetIndent("", " ") - return enc.Encode(h) + return enc.Encode(BuildReport(h)) case "text", "markdown", "": return renderText(w, h) default: diff --git a/hara/hara_test.go b/hara/hara_test.go index 5d934be..0f6c2f5 100644 --- a/hara/hara_test.go +++ b/hara/hara_test.go @@ -382,6 +382,28 @@ func TestRenderJSON(t *testing.T) { } } +//fusa:test REQ-FO-HARA008 +func TestRenderJSONCarriesCommonHeader(t *testing.T) { + h := &hara.HARA{Project: "Proj", Standard: "iso26262"} + var buf bytes.Buffer + if err := hara.Render(&buf, h, "json"); err != nil { + t.Fatalf("Render json: %v", err) + } + var got hara.Report + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("JSON unmarshal: %v", err) + } + if got.Kind != "hara-report" { + t.Errorf("Kind = %q, want hara-report", got.Kind) + } + if got.SchemaVersion == "" || got.Tool == "" || got.ToolVersion == "" || got.Language == "" { + t.Errorf("common header fields incomplete: %+v", got) + } + if got.Project != "Proj" { + t.Errorf("embedded HARA.Project = %q, want Proj", got.Project) + } +} + //fusa:test REQ-FO-HARA003 func TestRenderDefault(t *testing.T) { h := &hara.HARA{Project: "Proj"} diff --git a/history/history.go b/history/history.go index 550d54b..9dc68a9 100644 --- a/history/history.go +++ b/history/history.go @@ -144,7 +144,7 @@ func loadAll(path string) ([]Snapshot, error) { // most keep entries (newest). Returns the number of entries removed. A missing // file is not an error; returns 0, nil. If keep <= 0, MaxSnapshots is used. // -//fusa:req REQ-FO-HST003 +//fusa:req REQ-FO-HST005 func Prune(dir string, keep int) (int, error) { if keep <= 0 { keep = MaxSnapshots diff --git a/report/csv.go b/report/csv.go index bddf1ae..e66058f 100644 --- a/report/csv.go +++ b/report/csv.go @@ -50,7 +50,7 @@ func csvRow(f fusaops.Finding) []string { return []string{ string(f.Language), f.Tool, - f.RuleID, + f.QualifiedRuleID(), string(f.Severity), f.Message, f.Location.File, diff --git a/report/html.go b/report/html.go index 0c61eab..ad32cc5 100644 --- a/report/html.go +++ b/report/html.go @@ -200,7 +200,7 @@ const dashboardTemplate = ` {{.Severity}} {{$lang}} - {{.RuleID}} + {{.QualifiedRuleID}} {{.Category}} {{.Message}}{{if .Remediation}}
→ {{.Remediation}}{{end}}{{if and $showFP .Fingerprint}}
{{.Fingerprint}}{{end}} {{.Location.File}}{{if .Location.Line}}:{{.Location.Line}}{{end}} @@ -280,7 +280,7 @@ const dashboardTemplate = ` {{range .SuppressedFindings}} {{.Severity}} - {{.RuleID}} + {{.QualifiedRuleID}} {{.Message}} {{.Location.File}}{{if .Location.Line}}:{{.Location.Line}}{{end}} diff --git a/report/junit.go b/report/junit.go index edc5e2d..5c6a31b 100644 --- a/report/junit.go +++ b/report/junit.go @@ -52,12 +52,12 @@ type junitSkipped struct { // junitCaseName formats the testcase name for a finding. func junitCaseName(f fusaops.Finding) string { - name := f.RuleID + name := f.QualifiedRuleID() if f.Location.File != "" { if f.Location.Line > 0 { - name = fmt.Sprintf("%s (%s:%d)", f.RuleID, f.Location.File, f.Location.Line) + name = fmt.Sprintf("%s (%s:%d)", f.QualifiedRuleID(), f.Location.File, f.Location.Line) } else { - name = fmt.Sprintf("%s (%s)", f.RuleID, f.Location.File) + name = fmt.Sprintf("%s (%s)", f.QualifiedRuleID(), f.Location.File) } } return name diff --git a/report/markdown.go b/report/markdown.go index a110940..df2a27e 100644 --- a/report/markdown.go +++ b/report/markdown.go @@ -76,14 +76,14 @@ func renderMarkdown(w io.Writer, r *AggregateReport, opts RenderOptions) error { fmt.Fprintf(w, "| Severity | Rule | Message | Location | Fingerprint |\n|---|---|---|---|---|\n") for _, f := range c.Findings { fmt.Fprintf(w, "| %s | `%s` | %s | %s | `%s` |\n", - markdownSeverityIcon(f.Severity), markdownEscape(f.RuleID), + markdownSeverityIcon(f.Severity), markdownEscape(f.QualifiedRuleID()), markdownEscape(f.Message), markdownLoc(f), markdownEscape(f.Fingerprint)) } } else { fmt.Fprintf(w, "| Severity | Rule | Message | Location |\n|---|---|---|---|\n") for _, f := range c.Findings { fmt.Fprintf(w, "| %s | `%s` | %s | %s |\n", - markdownSeverityIcon(f.Severity), markdownEscape(f.RuleID), + markdownSeverityIcon(f.Severity), markdownEscape(f.QualifiedRuleID()), markdownEscape(f.Message), markdownLoc(f)) } } @@ -98,7 +98,7 @@ func renderMarkdown(w io.Writer, r *AggregateReport, opts RenderOptions) error { fmt.Fprintf(w, "| Severity | Rule | Message | Location |\n|---|---|---|---|\n") for _, f := range c.SuppressedFindings { fmt.Fprintf(w, "| %s | `%s` | %s | %s |\n", - markdownSeverityIcon(f.Severity), markdownEscape(f.RuleID), + markdownSeverityIcon(f.Severity), markdownEscape(f.QualifiedRuleID()), markdownEscape(f.Message), markdownLoc(f)) } fmt.Fprintf(w, "\n\n\n") diff --git a/report/report.go b/report/report.go index 0e7a244..7bacf13 100644 --- a/report/report.go +++ b/report/report.go @@ -249,10 +249,22 @@ func New(root, project string, components []Component) *AggregateReport { return r } -// HasErrors reports whether any component produced an ERROR-severity finding. +// HasErrors reports whether any component produced an ERROR-severity finding +// that still gates (x-FuSa spec §4.1: a finding an upstream tool already +// dispositioned "accepted" or "deferred" MUST NOT by itself cause exit 1, +// even though it remains counted in Summary.Errors for display). // //fusa:req REQ-FO-RPT006 -func (r *AggregateReport) HasErrors() bool { return r.Summary.Errors > 0 } +func (r *AggregateReport) HasErrors() bool { + for _, c := range r.Components { + for _, f := range c.Findings { + if f.Severity == fusaops.SeverityError && f.Gates() { + return true + } + } + } + return false +} // Render writes r to w in the requested format. // diff --git a/report/report_gap_test.go b/report/report_gap_test.go index 40f3cbf..a1dfb24 100644 --- a/report/report_gap_test.go +++ b/report/report_gap_test.go @@ -190,9 +190,9 @@ func TestHTMLSevClassInfo(t *testing.T) { // //fusa:test REQ-FO-RPT013 func TestJUnitCaseNameFileNoLine(t *testing.T) { - f := fusaops.Finding{RuleID: "E001", Location: fusaops.Location{File: "main.go", Line: 0}} + f := fusaops.Finding{Language: fusaops.LangGo, RuleID: "E001", Location: fusaops.Location{File: "main.go", Line: 0}} got := junitCaseName(f) - want := "E001 (main.go)" + want := "go/E001 (main.go)" if got != want { t.Errorf("junitCaseName: got %q, want %q", got, want) } diff --git a/report/report_test.go b/report/report_test.go index 0074dfc..0bc42eb 100644 --- a/report/report_test.go +++ b/report/report_test.go @@ -62,6 +62,34 @@ func TestNewComputesSummaries(t *testing.T) { } } +//fusa:test REQ-FO-RPT006 +func TestHasErrorsRespectsDisposition(t *testing.T) { + comps := []Component{ + {Tool: "gofusa", Findings: []fusaops.Finding{ + {RuleID: "E1", Severity: fusaops.SeverityError, Disposition: "accepted"}, + }}, + } + r := New("/root", "demo", comps) + if r.HasErrors() { + t.Error("HasErrors should be false when the only ERROR finding is dispositioned accepted") + } + // Summary still counts it for display purposes. + if r.Summary.Errors != 1 { + t.Errorf("Summary.Errors = %d, want 1 (still counted for display)", r.Summary.Errors) + } + + comps2 := []Component{ + {Tool: "gofusa", Findings: []fusaops.Finding{ + {RuleID: "E1", Severity: fusaops.SeverityError, Disposition: "deferred"}, + {RuleID: "E2", Severity: fusaops.SeverityError}, + }}, + } + r2 := New("/root", "demo", comps2) + if !r2.HasErrors() { + t.Error("HasErrors should be true when a non-dispositioned ERROR finding remains") + } +} + //fusa:test REQ-FO-RPT002 func TestStatus(t *testing.T) { cases := []struct { diff --git a/report/text.go b/report/text.go index d709124..2255884 100644 --- a/report/text.go +++ b/report/text.go @@ -69,7 +69,7 @@ func renderText(w io.Writer, r *AggregateReport, opts RenderOptions) error { } func printTextFindingOpts(w io.Writer, f fusaops.Finding, prefix string, showFP bool) { - fmt.Fprintf(w, " %s[%s] %-10s", prefix, f.Severity, f.RuleID) + fmt.Fprintf(w, " %s[%s] %-10s", prefix, f.Severity, f.QualifiedRuleID()) if f.Category != "" { fmt.Fprintf(w, " [%s]", f.Category) } diff --git a/req/req.go b/req/req.go index c73f16a..5b191e4 100644 --- a/req/req.go +++ b/req/req.go @@ -13,6 +13,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" ) @@ -52,6 +53,27 @@ func LoadRegistry(dir string) ([]Entry, error) { return payload.Requirements, nil } +// FindDuplicateIDs returns every id that appears more than once in entries, +// sorted for deterministic output. Per x-FuSa spec §1.2.2, an id MUST be +// unique within .fusa-reqs.json; a tool MUST validate this whenever it reads +// the file and MUST NOT silently merge or drop duplicates. +// +//fusa:req REQ-FO-REQ004 +func FindDuplicateIDs(entries []Entry) []string { + counts := make(map[string]int, len(entries)) + for _, e := range entries { + counts[e.ID]++ + } + var dupes []string + for id, n := range counts { + if n > 1 { + dupes = append(dupes, id) + } + } + sort.Strings(dupes) + return dupes +} + // SaveRegistry writes entries as .fusa-reqs.json in dir. // //fusa:req REQ-FO-REQ001 diff --git a/req/req_test.go b/req/req_test.go index a0df1fd..1d3fd61 100644 --- a/req/req_test.go +++ b/req/req_test.go @@ -523,6 +523,35 @@ func TestParseDOORSEmptyID(t *testing.T) { } } +//fusa:test REQ-FO-REQ004 +func TestFindDuplicateIDs(t *testing.T) { + entries := []Entry{ + {ID: "REQ-A", Title: "one"}, + {ID: "REQ-B", Title: "two"}, + {ID: "REQ-A", Title: "three"}, + {ID: "REQ-C", Title: "four"}, + {ID: "REQ-B", Title: "five"}, + } + got := FindDuplicateIDs(entries) + want := []string{"REQ-A", "REQ-B"} + if len(got) != len(want) { + t.Fatalf("FindDuplicateIDs = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("FindDuplicateIDs[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +//fusa:test REQ-FO-REQ004 +func TestFindDuplicateIDsNoneFound(t *testing.T) { + entries := []Entry{{ID: "REQ-A"}, {ID: "REQ-B"}} + if got := FindDuplicateIDs(entries); len(got) != 0 { + t.Errorf("FindDuplicateIDs = %v, want none", got) + } +} + // TestParseCSVEmptyIDRow verifies ParseCSV skips rows where the id field is // empty, covering the "id == "" → continue" branch. //