Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions .fusa-reqs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`"<language>/<ruleId>"` 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
Expand Down
84 changes: 68 additions & 16 deletions adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,11 @@
//
// 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) {
Expand All @@ -118,28 +121,45 @@
_ = 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
Expand All @@ -151,6 +171,14 @@
// "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 {
Expand All @@ -163,18 +191,22 @@
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 {
Expand All @@ -185,12 +217,15 @@
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,
Expand All @@ -207,6 +242,23 @@
}
}

// 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 {
Expand Down
59 changes: 57 additions & 2 deletions adapter/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

//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)
}
Expand All @@ -44,7 +44,7 @@
}

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")
}
}
Expand All @@ -61,6 +61,61 @@
}
}

//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()
Expand Down
Loading
Loading