diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 735656a..9a15a9e 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -2993,6 +2993,73 @@ "title": "SCI artifacts[] projection (file/hash/version) per x-FuSa spec 9.3", "priority": "MUST", "standard": "DO-178C \u00a711.16" + }, + { + "id": "REQ-FO-CORE009", + "title": "Attestation struct + AttestationValid + ContentHash helper", + "description": "FuSaOps shall provide an Attestation type (status/implementationAuthor/independentReviewer/reviewedAt/contentHash) and an AttestationValid function checking non-staleness and independent-reviewer identity, per x-FuSa spec section 1.6.2.", + "priority": "MUST", + "rationale": "A DCO-style attestation mechanism lets an artifact-quality WARNING (FUSA-STUB002) stay advisory by default without opening a stub-content loophole -- a named independent reviewer can vouch for content a similarity heuristic is unsure about." + }, + { + "id": "REQ-QB001", + "title": "qualitybar.DetectPlaceholder (FUSA-STUB001, x-FuSa spec section 1.6.1 Rule A)", + "priority": "MUST", + "standard": "x-FuSa spec 1.6.1" + }, + { + "id": "REQ-QB002", + "title": "qualitybar.DetectBlankFallback (FUSA-STUB002, x-FuSa spec section 1.6.1 Rule B)", + "priority": "MUST", + "standard": "x-FuSa spec 1.6.1" + }, + { + "id": "REQ-FO-FMEA007", + "title": "fmea.AttestationContentHash excludes Hash/Attestation/GeneratedAt per x-FuSa spec 1.6.2", + "priority": "MUST", + "standard": "IEC 61508:2010 / ISO 26262:2018 Part 8-7" + }, + { + "id": "REQ-FO-FMEA008", + "title": "FMEA Summary coverage metrics: componentsAnalyzed/componentsInProject/coveragePct/componentInventoryMethod per x-FuSa spec 9.2", + "priority": "MUST", + "standard": "IEC 61508:2010 / ISO 26262:2018 Part 8-7" + }, + { + "id": "REQ-FO-TARA007", + "title": "tara.AttestationContentHash excludes Hash/Attestation/GeneratedAt per x-FuSa spec 1.6.2", + "priority": "MUST", + "standard": "ISO 21434:2021 Clause 15" + }, + { + "id": "REQ-FO-TARA008", + "title": "TARA Summary coverage metrics: assetsAnalyzed/assetsInProject/coveragePct/assetInventoryMethod per x-FuSa spec 9.2", + "priority": "MUST", + "standard": "ISO 21434:2021 Clause 15" + }, + { + "id": "REQ-FO-SC007", + "title": "safetycase.AttestationContentHash excludes Hash/Attestation/GeneratedAt per x-FuSa spec 1.6.2", + "priority": "MUST", + "standard": "ISO 26262, DO-178C, IEC 61508, ISO 21434" + }, + { + "id": "REQ-FO-SAS007", + "title": "sas.AttestationContentHash excludes Hash/Attestation/GeneratedAt per x-FuSa spec 1.6.2", + "priority": "MUST", + "standard": "DO-178C \u00a711.20" + }, + { + "id": "REQ-FO-HARA006", + "title": "hara.AttestationContentHash excludes only Attestation itself (CreatedAt is stable input content, not a regeneration timestamp)", + "priority": "MUST", + "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", + "priority": "MUST", + "standard": "x-FuSa spec 1.6.1/1.6.2" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 57435bc..c474798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,47 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [1.141.0] — 2026-07-28 + +### feat: attestation, FUSA-STUB001/002 detection heuristics, and coverage-gate wiring (x-FuSa spec §1.6.1/§1.6.2) + +Closes out the enforcement mechanism from spec v1.14.0 against FuSaOps' own +artifacts — the schema conformance work landed in v1.140.0; this is the +detection/attestation/gating layer on top of it. + +- New root-package `Attestation` type + `AttestationValid`/`ContentHash` + helpers (`attestation.go`) — a DCO-style provenance record (independent + reviewer distinct from implementation author, hash-pinned to the exact + content reviewed) reusing the same independence concept as the `vv` + package. +- New `qualitybar` package implementing both §1.6.1 detection rules: + `DetectPlaceholder` (`FUSA-STUB001`, always `ERROR`, disposition- + suppressible only) and `DetectBlankFallback` (`FUSA-STUB002`, a + distinct-value-ratio `WARNING` across ≥10 entries, advisory by default, + suppressible only by a valid attestation). Both reuse `fusaops.Finding` + and `fusaops.ComputeFingerprint` — no new finding shape. +- `fmea`/`tara`/`safetycase`/`sas`/`hara` each gain an `Attestation` field + (nil by default — FuSaOps never fabricates a "reviewed" status for its + own auto-generated content) and an `AttestationContentHash` function. +- `cmd_{fmea,tara,safety_case,sas,hara}.go` now: run both detection rules + after building/loading, gate the exit code (`FUSA-STUB001` always; + `FUSA-STUB002` only under the new `--require-attestation`/`--strict` + flags), and — since `Build` always assembles a fresh document — carry a + prior run's attestation forward across regeneration rather than silently + discarding it. Staleness is automatic: a carried-forward attestation + whose hash no longer matches the regenerated content is correctly + treated as invalid. +- `fmea`/`tara` `Summary` gains `coveragePct` + a stated + `component`/`assetInventoryMethod` denominator methodology, plus + `--min-coverage N` on `cmd_fmea.go`/`cmd_tara.go`. FMEA's own coverage is + an honest 19.5% (8 of 41 tracked packages); TARA's is 80% (8 of its own + 10 known evidence artefacts) — surfaced plainly, not inflated. +- Regenerated `fmea.json`/`tara.json`/`safety-case.json` against the new + code. +- 11 new requirements registered (`REQ-FO-CORE009`, `REQ-QB001`, + `REQ-QB002`, `REQ-FO-FMEA007/008`, `REQ-FO-TARA007/008`, `REQ-FO-SC007`, + `REQ-FO-SAS007`, `REQ-FO-HARA006`, `REQ-FO-CLI084`). + ## [1.140.0] — 2026-07-28 ### feat: bring FuSaOps' own hara/fmea/tara/safetycase/sas/sci into x-FuSa spec v1.14.0 conformance diff --git a/attestation.go b/attestation.go new file mode 100644 index 0000000..179f959 --- /dev/null +++ b/attestation.go @@ -0,0 +1,74 @@ +package fusaops + +import ( + "crypto/sha256" + "fmt" +) + +// AttestationStatus records whether an evidence artifact's qualitative +// content has been reviewed by an independent human, per x-FuSa spec §1.6.2. +// +//fusa:req REQ-FO-CORE009 +type AttestationStatus string + +const ( + // AttestationHeuristic is the fail-safe default: no human has vouched for + // this content. Absent status MUST be treated as this value. + AttestationHeuristic AttestationStatus = "heuristic" + // AttestationReviewed asserts an independent reviewer examined the + // content at ContentHash and found it genuine, not templated/placeholder. + AttestationReviewed AttestationStatus = "reviewed" +) + +// Attestation is a DCO-style, per-artifact provenance record. It lets a +// consumer distinguish auto-scaffolded content from human-reviewed content +// without requiring a detection heuristic to be perfectly precise: rather +// than proving the content is good, it requires a named, independent human +// to assert that they checked, with an accountability trail attached. The +// independence model (ImplementationAuthor vs IndependentReviewer) mirrors +// the one already used by the vv package (ISO 26262-2:2018 §6.4). +// +//fusa:req REQ-FO-CORE009 +type Attestation struct { + Status AttestationStatus `json:"status"` + ImplementationAuthor string `json:"implementationAuthor,omitempty"` + IndependentReviewer string `json:"independentReviewer,omitempty"` + ReviewedAt string `json:"reviewedAt,omitempty"` // RFC 3339 + ContentHash string `json:"contentHash,omitempty"` +} + +// AttestationValid reports whether a is a non-stale, genuinely independent +// "reviewed" attestation matching currentContentHash. Every failure mode — +// absent attestation, status "heuristic", a self-attestation (reviewer == +// author), or a stale hash — returns false (fail-safe): the caller MUST then +// treat the content as unreviewed. +// +//fusa:req REQ-FO-CORE009 +func AttestationValid(a Attestation, currentContentHash string) bool { + if a.Status != AttestationReviewed { + return false + } + if a.IndependentReviewer == "" || a.IndependentReviewer == a.ImplementationAuthor { + return false + } + if a.ContentHash == "" || currentContentHash == "" { + return false + } + return a.ContentHash == currentContentHash +} + +// ContentHash computes the sha256:-prefixed RFC 8785 canonical hash of data, +// suitable for populating Attestation.ContentHash or for comparison in +// AttestationValid. Callers pass the JSON-marshaled artifact content with +// the Attestation field itself (and any generatedAt-style timestamp) already +// excluded, so a later edit to that content invalidates a prior attestation. +// +//fusa:req REQ-FO-CORE009 +func ContentHash(data []byte) string { + canon, err := Canonicalize(data) + if err != nil { + return "" + } + sum := sha256.Sum256(canon) + return fmt.Sprintf("sha256:%x", sum) +} diff --git a/attestation_test.go b/attestation_test.go new file mode 100644 index 0000000..42acf2b --- /dev/null +++ b/attestation_test.go @@ -0,0 +1,100 @@ +package fusaops + +import "testing" + +//fusa:test REQ-FO-CORE009 +func TestAttestationValidTrue(t *testing.T) { + hash := ContentHash([]byte(`{"a":1}`)) + a := Attestation{ + Status: AttestationReviewed, + ImplementationAuthor: "auto", + IndependentReviewer: "Jane Doe ", + ReviewedAt: "2026-07-28T00:00:00Z", + ContentHash: hash, + } + if !AttestationValid(a, hash) { + t.Error("expected valid attestation") + } +} + +//fusa:test REQ-FO-CORE009 +func TestAttestationValidHeuristicStatus(t *testing.T) { + hash := ContentHash([]byte(`{"a":1}`)) + a := Attestation{Status: AttestationHeuristic, ContentHash: hash} + if AttestationValid(a, hash) { + t.Error("heuristic status must never be valid") + } +} + +//fusa:test REQ-FO-CORE009 +func TestAttestationValidAbsentStatus(t *testing.T) { + hash := ContentHash([]byte(`{"a":1}`)) + a := Attestation{ContentHash: hash} + if AttestationValid(a, hash) { + t.Error("absent status must default to invalid (fail-safe)") + } +} + +//fusa:test REQ-FO-CORE009 +func TestAttestationValidSelfAttestation(t *testing.T) { + hash := ContentHash([]byte(`{"a":1}`)) + a := Attestation{ + Status: AttestationReviewed, + ImplementationAuthor: "Jane Doe ", + IndependentReviewer: "Jane Doe ", + ContentHash: hash, + } + if AttestationValid(a, hash) { + t.Error("a reviewer identical to the author must not satisfy independence") + } +} + +//fusa:test REQ-FO-CORE009 +func TestAttestationValidEmptyReviewer(t *testing.T) { + hash := ContentHash([]byte(`{"a":1}`)) + a := Attestation{Status: AttestationReviewed, ContentHash: hash} + if AttestationValid(a, hash) { + t.Error("an empty IndependentReviewer must not satisfy independence") + } +} + +//fusa:test REQ-FO-CORE009 +func TestAttestationValidStaleHash(t *testing.T) { + oldHash := ContentHash([]byte(`{"a":1}`)) + newHash := ContentHash([]byte(`{"a":2}`)) + a := Attestation{ + Status: AttestationReviewed, + IndependentReviewer: "Jane Doe ", + ContentHash: oldHash, + } + if AttestationValid(a, newHash) { + t.Error("a stale content hash must invalidate the attestation") + } +} + +//fusa:test REQ-FO-CORE009 +func TestAttestationValidEmptyHashes(t *testing.T) { + a := Attestation{Status: AttestationReviewed, IndependentReviewer: "Jane Doe"} + if AttestationValid(a, "") { + t.Error("empty content hashes must not be considered valid") + } +} + +//fusa:test REQ-FO-CORE009 +func TestContentHashDeterministic(t *testing.T) { + h1 := ContentHash([]byte(`{"a":1,"b":2}`)) + h2 := ContentHash([]byte(`{"b":2,"a":1}`)) + if h1 != h2 { + t.Errorf("ContentHash should be key-order independent: %q != %q", h1, h2) + } + if h1 == "" { + t.Error("ContentHash must not be empty for valid JSON") + } +} + +//fusa:test REQ-FO-CORE009 +func TestContentHashInvalidJSON(t *testing.T) { + if got := ContentHash([]byte("not json")); got != "" { + t.Errorf("ContentHash of invalid JSON should be empty, got %q", got) + } +} diff --git a/cmd/fusaops/cmd_fmea.go b/cmd/fusaops/cmd_fmea.go index edecdd1..404b1ea 100644 --- a/cmd/fusaops/cmd_fmea.go +++ b/cmd/fusaops/cmd_fmea.go @@ -7,7 +7,9 @@ import ( "os" "path/filepath" + fusaops "github.com/SoundMatt/FuSaOps" "github.com/SoundMatt/FuSaOps/fmea" + "github.com/SoundMatt/FuSaOps/qualitybar" ) // runFMEA generates a Design Failure Mode and Effects Analysis per IEC 61508 / ISO 26262 Part 8. @@ -30,13 +32,19 @@ func runFMEA(args []string, stdout, stderr io.Writer) int { } var ( - dir = fs.String("dir", "", "project root directory (default: current directory)") - output = fs.String("output", "", "path for the FMEA report (default: /.fusaops-fmea.json)") - format = fs.String("format", "text", "output format: text, json") + dir = fs.String("dir", "", "project root directory (default: current directory)") + output = fs.String("output", "", "path for the FMEA report (default: /.fusaops-fmea.json)") + format = fs.String("format", "text", "output format: text, json") + minCoverage = fs.Int("min-coverage", -1, "fail when coveragePct < N (0-100)") + strict = fs.Bool("strict", false, "implies --require-attestation") + requireAttestation = fs.Bool("require-attestation", false, "gate exit code on an unsuppressed FUSA-STUB002 finding") ) if err := fs.Parse(args); err != nil { return 2 } + if *strict { + *requireAttestation = true + } projectRoot := *dir if projectRoot == "" { @@ -48,21 +56,31 @@ func runFMEA(args []string, stdout, stderr io.Writer) int { } } + outPath := *output + if outPath == "" { + outPath = filepath.Join(projectRoot, fmea.ReportFile) + } + + // Carry forward any prior attestation (from a previously-saved report at + // outPath) — Build always assembles a fresh document, so without this a + // hand-added attestation would be silently discarded on the next run. + var priorAttestation *fusaops.Attestation + if prior, loadErr := fmea.Load(outPath); loadErr == nil { + priorAttestation = prior.Attestation + } + f, err := fmea.Build(projectRoot) if err != nil { fmt.Fprintf(stderr, "fusaops fmea: build: %v\n", err) return 1 } + f.Attestation = priorAttestation if renderErr := fmea.Render(stdout, f, *format); renderErr != nil { fmt.Fprintf(stderr, "fusaops fmea: render: %v\n", renderErr) return 2 } - outPath := *output - if outPath == "" { - outPath = filepath.Join(projectRoot, fmea.ReportFile) - } if saveErr := fmea.Save(outPath, f); saveErr != nil { fmt.Fprintf(stderr, "fusaops fmea: save: %v\n", saveErr) return 1 @@ -70,8 +88,36 @@ func runFMEA(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "\nFMEA written to %s\n", outPath) fmt.Fprintf(stdout, "Integrity hash: %s\n", f.Hash) + exit := 0 if f.HasHighRPN() { - return 1 + exit = 1 + } + + valid := attestationValid(f.Attestation, fmea.AttestationContentHash(f)) + qualFields := fmeaQualFields(f) + if code := runQualityGate(stderr, fmea.ReportFile, qualFields, valid, *requireAttestation); code != 0 { + exit = code + } + + if *minCoverage >= 0 && f.Summary.CoveragePct < float64(*minCoverage) { + fmt.Fprintf(stderr, "fusaops fmea: coverage %.1f%% < required %d%%\n", f.Summary.CoveragePct, *minCoverage) + exit = 1 + } + + return exit +} + +// fmeaQualFields extracts f's qualitative text fields for §1.6.1 detection. +// +//fusa:req REQ-FO-CLI084 +func fmeaQualFields(f *fmea.FMEA) []qualitybar.QualField { + var out []qualitybar.QualField + for _, e := range f.Entries { + out = append(out, + qualitybar.QualField{EntryID: e.ID, Field: "failureMode", Value: e.Mode}, + qualitybar.QualField{EntryID: e.ID, Field: "effect", Value: e.Effect}, + qualitybar.QualField{EntryID: e.ID, Field: "cause", Value: e.Cause}, + ) } - return 0 + return out } diff --git a/cmd/fusaops/cmd_hara.go b/cmd/fusaops/cmd_hara.go index f922d30..dfefc43 100644 --- a/cmd/fusaops/cmd_hara.go +++ b/cmd/fusaops/cmd_hara.go @@ -9,6 +9,7 @@ import ( "time" "github.com/SoundMatt/FuSaOps/hara" + "github.com/SoundMatt/FuSaOps/qualitybar" ) // runHara dispatches the hara subcommand (show|init|asil). @@ -67,9 +68,14 @@ func runHaraShow(args []string, projectRoot string, stdout, stderr io.Writer) in fs.SetOutput(stderr) format := fs.String("format", "text", "output format: text, json, markdown") output := fs.String("output", "", "write output to file (default: stdout)") + strict := fs.Bool("strict", false, "implies --require-attestation") + requireAttestation := fs.Bool("require-attestation", false, "gate exit code on an unsuppressed FUSA-STUB002 finding") if err := fs.Parse(args); err != nil { return 2 } + if *strict { + *requireAttestation = true + } h, err := hara.Load(projectRoot) if err != nil { @@ -93,11 +99,32 @@ func runHaraShow(args []string, projectRoot string, stdout, stderr io.Writer) in return 2 } + exit := 0 findings := hara.Validate(h) if len(findings) > 0 && *output != "" { fmt.Fprintf(stderr, "fusaops hara: %d gap(s) found — run 'fusaops hara show' for details\n", len(findings)) } - return 0 + + valid := attestationValid(h.Attestation, hara.AttestationContentHash(h)) + if code := runQualityGate(stderr, hara.HARAFile, haraQualFields(h), valid, *requireAttestation); code != 0 { + exit = code + } + + return exit +} + +// haraQualFields extracts h's qualitative text fields for §1.6.1 detection. +// +//fusa:req REQ-FO-CLI084 +func haraQualFields(h *hara.HARA) []qualitybar.QualField { + var out []qualitybar.QualField + for _, hz := range h.Hazards { + out = append(out, qualitybar.QualField{EntryID: hz.ID, Field: "hazard", Value: hz.Description}) + } + for _, g := range h.SafetyGoals { + out = append(out, qualitybar.QualField{EntryID: g.ID, Field: "safetyGoal", Value: g.Description}) + } + return out } func runHaraInit(args []string, projectRoot string, stdout, stderr io.Writer) int { diff --git a/cmd/fusaops/cmd_pr2_test.go b/cmd/fusaops/cmd_pr2_test.go new file mode 100644 index 0000000..ce21e7e --- /dev/null +++ b/cmd/fusaops/cmd_pr2_test.go @@ -0,0 +1,151 @@ +package main + +// Tests covering the x-FuSa spec §1.6.2 attestation carry-forward and +// §1.6.1 quality-gate wiring added to the fmea/tara/hara/safety-case/sas +// commands. + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + fusaops "github.com/SoundMatt/FuSaOps" + "github.com/SoundMatt/FuSaOps/fmea" + "github.com/SoundMatt/FuSaOps/hara" +) + +// TestFMEAAttestationCarriesForwardWhenValid verifies a hand-added, correctly +// hashed attestation survives a re-run of `fusaops fmea` (which otherwise +// always rebuilds the document from scratch) and is reported valid. +// +//fusa:test REQ-FO-CLI084 +func TestFMEAAttestationCarriesForwardWhenValid(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "fmea.json") + var stdout, stderr bytes.Buffer + + if code := runFMEA([]string{"--dir", dir, "--format", "json", "--output", outPath}, &stdout, &stderr); code > 1 { + t.Fatalf("first build: unexpected exit %d, stderr: %s", code, stderr.String()) + } + + f, err := fmea.Load(outPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + hash := fmea.AttestationContentHash(f) + f.Attestation = &fusaops.Attestation{ + Status: fusaops.AttestationReviewed, + ImplementationAuthor: "auto", + IndependentReviewer: "Jane Doe ", + ReviewedAt: time.Now().UTC().Format(time.RFC3339), + ContentHash: hash, + } + if saveErr := fmea.Save(outPath, f); saveErr != nil { + t.Fatalf("Save: %v", saveErr) + } + + stdout.Reset() + stderr.Reset() + if code := runFMEA([]string{"--dir", dir, "--format", "json", "--output", outPath}, &stdout, &stderr); code > 1 { + t.Fatalf("second build: unexpected exit %d, stderr: %s", code, stderr.String()) + } + + reloaded, err := fmea.Load(outPath) + if err != nil { + t.Fatalf("Load after second build: %v", err) + } + if reloaded.Attestation == nil { + t.Fatal("attestation was lost across a rebuild") + } + if !fusaops.AttestationValid(*reloaded.Attestation, fmea.AttestationContentHash(reloaded)) { + t.Error("carried-forward attestation should still be valid (content unchanged)") + } +} + +// TestFMEANoAttestationOmittedFromJSON verifies a fresh build (no prior file) +// omits the attestation field entirely rather than emitting a zero-value +// object — FuSaOps' own artifacts are never fabricated as "reviewed". +// +//fusa:test REQ-FO-CLI084 +func TestFMEANoAttestationOmittedFromJSON(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "fmea.json") + var stdout, stderr bytes.Buffer + if code := runFMEA([]string{"--dir", dir, "--format", "json", "--output", outPath}, &stdout, &stderr); code > 1 { + t.Fatalf("unexpected exit %d, stderr: %s", code, stderr.String()) + } + + var raw map[string]interface{} + data, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, present := raw["attestation"]; present { + t.Error("attestation key should be omitted (omitempty) when no attestation exists") + } +} + +// TestFMEARequireAttestationFlagParses verifies --require-attestation and +// --strict are accepted flags (wiring smoke test — the 8 hardcoded entries +// are below the >=10 threshold for FUSA-STUB002 to trigger either way). +// +//fusa:test REQ-FO-CLI084 +func TestFMEARequireAttestationFlagParses(t *testing.T) { + dir := t.TempDir() + var stdout, stderr bytes.Buffer + code := runFMEA([]string{"--dir", dir, "--require-attestation", "--format", "json"}, &stdout, &stderr) + if code > 1 { + t.Errorf("unexpected exit %d with --require-attestation, stderr: %s", code, stderr.String()) + } +} + +// TestHaraAttestationCarriesForwardWhenValid mirrors the FMEA case for the +// Load/Save-based hara command (no Build/regeneration step). +// +//fusa:test REQ-FO-CLI084 +func TestHaraAttestationCarriesForwardWhenValid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, hara.HARAFile) + h := &hara.HARA{Project: "test", Standard: "ISO 26262", CreatedAt: time.Now().UTC()} + if err := hara.Save(path, h); err != nil { + t.Fatal(err) + } + + loaded, err := hara.Load(dir) + if err != nil { + t.Fatal(err) + } + hash := hara.AttestationContentHash(loaded) + loaded.Attestation = &fusaops.Attestation{ + Status: fusaops.AttestationReviewed, + ImplementationAuthor: "auto", + IndependentReviewer: "Jane Doe ", + ReviewedAt: time.Now().UTC().Format(time.RFC3339), + ContentHash: hash, + } + if err := hara.Save(path, loaded); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if code := runHara([]string{"--dir", dir, "show", "--format", "json"}, &stdout, &stderr); code > 1 { + t.Fatalf("unexpected exit %d, stderr: %s", code, stderr.String()) + } + + var got hara.HARA + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("unmarshal show output: %v", err) + } + if got.Attestation == nil { + t.Fatal("attestation missing from show output") + } + if !fusaops.AttestationValid(*got.Attestation, hara.AttestationContentHash(&got)) { + t.Error("attestation should be valid (content unchanged)") + } +} diff --git a/cmd/fusaops/cmd_qualitybar.go b/cmd/fusaops/cmd_qualitybar.go new file mode 100644 index 0000000..3964ecf --- /dev/null +++ b/cmd/fusaops/cmd_qualitybar.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "io" + + fusaops "github.com/SoundMatt/FuSaOps" + "github.com/SoundMatt/FuSaOps/qualitybar" +) + +// runQualityGate runs both x-FuSa spec §1.6.1 detection rules over fields and +// prints any findings to stderr. It returns 1 (the caller should propagate +// this into the command's exit code) when: +// - any FUSA-STUB001 (placeholder text) finding exists — always gating, +// never attestation-suppressible; or +// - any FUSA-STUB002 (blanket qualitative fallback) finding exists, +// attestationValid is false, and requireAttestation is set. +// +// A valid attestation suppresses FUSA-STUB002 entirely (with a note printed +// instead of the findings) regardless of requireAttestation. +// +//fusa:req REQ-FO-CLI084 +func runQualityGate(stderr io.Writer, artifactFile string, fields []qualitybar.QualField, attestationValid bool, requireAttestation bool) int { + exit := 0 + + placeholderFindings := qualitybar.DetectPlaceholder(artifactFile, fields) + for _, f := range placeholderFindings { + fmt.Fprintf(stderr, "[%s] %s: %s\n", f.Severity, f.RuleID, f.Message) + } + if len(placeholderFindings) > 0 { + exit = 1 + } + + fallbackFindings := qualitybar.DetectBlankFallback(artifactFile, fields) + if len(fallbackFindings) > 0 { + if attestationValid { + fmt.Fprintf(stderr, "note: %d FUSA-STUB002 finding(s) suppressed by a valid attestation\n", len(fallbackFindings)) + } else { + for _, f := range fallbackFindings { + fmt.Fprintf(stderr, "[%s] %s: %s\n", f.Severity, f.RuleID, f.Message) + } + if requireAttestation { + exit = 1 + } + } + } + + return exit +} + +// attestationValid reports whether a is a non-stale, genuinely independent +// "reviewed" attestation for content hashed to currentHash. A nil a (no +// attestation present) is always invalid. +// +//fusa:req REQ-FO-CLI084 +func attestationValid(a *fusaops.Attestation, currentHash string) bool { + if a == nil { + return false + } + return fusaops.AttestationValid(*a, currentHash) +} diff --git a/cmd/fusaops/cmd_safety_case.go b/cmd/fusaops/cmd_safety_case.go index 43cc660..8e8c2f2 100644 --- a/cmd/fusaops/cmd_safety_case.go +++ b/cmd/fusaops/cmd_safety_case.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" + fusaops "github.com/SoundMatt/FuSaOps" + "github.com/SoundMatt/FuSaOps/qualitybar" "github.com/SoundMatt/FuSaOps/safetycase" ) @@ -28,14 +30,19 @@ func runSafetyCase(args []string, stdout, stderr io.Writer) int { } var ( - dir = fs.String("dir", "", "project root directory (default: current directory)") - output = fs.String("output", "", "path for the safety case report (default: /.fusaops-safety-case.json)") - format = fs.String("format", "text", "output format: text, json") - standard = fs.String("standard", "ISO 26262", "target standard: ISO 26262, DO-178C, IEC 61508, ISO 21434") + dir = fs.String("dir", "", "project root directory (default: current directory)") + output = fs.String("output", "", "path for the safety case report (default: /.fusaops-safety-case.json)") + format = fs.String("format", "text", "output format: text, json") + standard = fs.String("standard", "ISO 26262", "target standard: ISO 26262, DO-178C, IEC 61508, ISO 21434") + strict = fs.Bool("strict", false, "implies --require-attestation") + requireAttestation = fs.Bool("require-attestation", false, "gate exit code on an unsuppressed FUSA-STUB002 finding") ) if err := fs.Parse(args); err != nil { return 2 } + if *strict { + *requireAttestation = true + } std := safetycase.Standard(*standard) valid := false @@ -61,21 +68,28 @@ func runSafetyCase(args []string, stdout, stderr io.Writer) int { } } + outPath := *output + if outPath == "" { + outPath = filepath.Join(projectRoot, safetycase.ReportFile) + } + + var priorAttestation *fusaops.Attestation + if prior, loadErr := safetycase.Load(outPath); loadErr == nil { + priorAttestation = prior.Attestation + } + sc, err := safetycase.Build(projectRoot, std) if err != nil { fmt.Fprintf(stderr, "fusaops safety-case: build: %v\n", err) return 1 } + sc.Attestation = priorAttestation if renderErr := safetycase.Render(stdout, sc, *format); renderErr != nil { fmt.Fprintf(stderr, "fusaops safety-case: render: %v\n", renderErr) return 2 } - outPath := *output - if outPath == "" { - outPath = filepath.Join(projectRoot, safetycase.ReportFile) - } if saveErr := safetycase.Save(outPath, sc); saveErr != nil { fmt.Fprintf(stderr, "fusaops safety-case: save: %v\n", saveErr) return 1 @@ -83,8 +97,27 @@ func runSafetyCase(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "\nSafety case written to %s\n", outPath) fmt.Fprintf(stdout, "Integrity hash: %s\n", sc.Hash) + exit := 0 if sc.HasGaps() { - return 1 + exit = 1 + } + + attestOK := attestationValid(sc.Attestation, safetycase.AttestationContentHash(sc)) + if code := runQualityGate(stderr, safetycase.ReportFile, safetyCaseQualFields(sc), attestOK, *requireAttestation); code != 0 { + exit = code + } + + return exit +} + +// safetyCaseQualFields extracts sc's qualitative text fields (each GSN +// node's Text) for §1.6.1 detection. +// +//fusa:req REQ-FO-CLI084 +func safetyCaseQualFields(sc *safetycase.SafetyCase) []qualitybar.QualField { + var out []qualitybar.QualField + for _, n := range sc.Nodes { + out = append(out, qualitybar.QualField{EntryID: n.ID, Field: string(n.Type), Value: n.Text}) } - return 0 + return out } diff --git a/cmd/fusaops/cmd_sas.go b/cmd/fusaops/cmd_sas.go index ce63ea3..74b8b53 100644 --- a/cmd/fusaops/cmd_sas.go +++ b/cmd/fusaops/cmd_sas.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" + fusaops "github.com/SoundMatt/FuSaOps" + "github.com/SoundMatt/FuSaOps/qualitybar" "github.com/SoundMatt/FuSaOps/sas" ) @@ -27,14 +29,19 @@ func runSAS(args []string, stdout, stderr io.Writer) int { } var ( - dir = fs.String("dir", "", "project root directory (default: current directory)") - output = fs.String("output", "", "path for the SAS report (default: /.fusaops-sas.json)") - format = fs.String("format", "text", "output format: text, json") - level = fs.String("level", "DAL-C", "software level: DAL-A through DAL-E") + dir = fs.String("dir", "", "project root directory (default: current directory)") + output = fs.String("output", "", "path for the SAS report (default: /.fusaops-sas.json)") + format = fs.String("format", "text", "output format: text, json") + level = fs.String("level", "DAL-C", "software level: DAL-A through DAL-E") + strict = fs.Bool("strict", false, "implies --require-attestation") + requireAttestation = fs.Bool("require-attestation", false, "gate exit code on an unsuppressed FUSA-STUB002 finding") ) if err := fs.Parse(args); err != nil { return 2 } + if *strict { + *requireAttestation = true + } projectRoot := *dir if projectRoot == "" { @@ -46,21 +53,28 @@ func runSAS(args []string, stdout, stderr io.Writer) int { } } + outPath := *output + if outPath == "" { + outPath = filepath.Join(projectRoot, sas.ReportFile) + } + + var priorAttestation *fusaops.Attestation + if prior, loadErr := sas.Load(outPath); loadErr == nil { + priorAttestation = prior.Attestation + } + s, err := sas.Build(projectRoot, *level) if err != nil { fmt.Fprintf(stderr, "fusaops sas: build: %v\n", err) return 1 } + s.Attestation = priorAttestation if renderErr := sas.Render(stdout, s, *format); renderErr != nil { fmt.Fprintf(stderr, "fusaops sas: render: %v\n", renderErr) return 2 } - outPath := *output - if outPath == "" { - outPath = filepath.Join(projectRoot, sas.ReportFile) - } if saveErr := sas.Save(outPath, s); saveErr != nil { fmt.Fprintf(stderr, "fusaops sas: save: %v\n", saveErr) return 1 @@ -68,8 +82,27 @@ func runSAS(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "\nSAS written to %s\n", outPath) fmt.Fprintf(stdout, "Integrity hash: %s\n", s.Hash) + exit := 0 if s.HasGaps() { - return 1 + exit = 1 + } + + attestOK := attestationValid(s.Attestation, sas.AttestationContentHash(s)) + if code := runQualityGate(stderr, sas.ReportFile, sasQualFields(s), attestOK, *requireAttestation); code != 0 { + exit = code + } + + return exit +} + +// sasQualFields extracts s's qualitative text fields (each checklist item's +// Item text) for §1.6.1 detection. +// +//fusa:req REQ-FO-CLI084 +func sasQualFields(s *sas.SAS) []qualitybar.QualField { + var out []qualitybar.QualField + for _, c := range s.Checklist { + out = append(out, qualitybar.QualField{EntryID: c.Clause, Field: "item", Value: c.Item}) } - return 0 + return out } diff --git a/cmd/fusaops/cmd_tara.go b/cmd/fusaops/cmd_tara.go index f6780e2..0d70ad9 100644 --- a/cmd/fusaops/cmd_tara.go +++ b/cmd/fusaops/cmd_tara.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" + fusaops "github.com/SoundMatt/FuSaOps" + "github.com/SoundMatt/FuSaOps/qualitybar" "github.com/SoundMatt/FuSaOps/tara" ) @@ -28,13 +30,19 @@ func runTARA(args []string, stdout, stderr io.Writer) int { } var ( - dir = fs.String("dir", "", "project root directory (default: current directory)") - output = fs.String("output", "", "path for the TARA report (default: /.fusaops-tara.json)") - format = fs.String("format", "text", "output format: text, json") + dir = fs.String("dir", "", "project root directory (default: current directory)") + output = fs.String("output", "", "path for the TARA report (default: /.fusaops-tara.json)") + format = fs.String("format", "text", "output format: text, json") + minCoverage = fs.Int("min-coverage", -1, "fail when coveragePct < N (0-100)") + strict = fs.Bool("strict", false, "implies --require-attestation") + requireAttestation = fs.Bool("require-attestation", false, "gate exit code on an unsuppressed FUSA-STUB002 finding") ) if err := fs.Parse(args); err != nil { return 2 } + if *strict { + *requireAttestation = true + } projectRoot := *dir if projectRoot == "" { @@ -46,21 +54,28 @@ func runTARA(args []string, stdout, stderr io.Writer) int { } } + outPath := *output + if outPath == "" { + outPath = filepath.Join(projectRoot, tara.ReportFile) + } + + var priorAttestation *fusaops.Attestation + if prior, loadErr := tara.Load(outPath); loadErr == nil { + priorAttestation = prior.Attestation + } + t, err := tara.Build(projectRoot) if err != nil { fmt.Fprintf(stderr, "fusaops tara: build: %v\n", err) return 1 } + t.Attestation = priorAttestation if renderErr := tara.Render(stdout, t, *format); renderErr != nil { fmt.Fprintf(stderr, "fusaops tara: render: %v\n", renderErr) return 2 } - outPath := *output - if outPath == "" { - outPath = filepath.Join(projectRoot, tara.ReportFile) - } if saveErr := tara.Save(outPath, t); saveErr != nil { fmt.Fprintf(stderr, "fusaops tara: save: %v\n", saveErr) return 1 @@ -68,8 +83,35 @@ func runTARA(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "\nTARA written to %s\n", outPath) fmt.Fprintf(stdout, "Integrity hash: %s\n", t.Hash) + exit := 0 if t.HasCritical() { - return 1 + exit = 1 + } + + valid := attestationValid(t.Attestation, tara.AttestationContentHash(t)) + qualFields := taraQualFields(t) + if code := runQualityGate(stderr, tara.ReportFile, qualFields, valid, *requireAttestation); code != 0 { + exit = code + } + + if *minCoverage >= 0 && t.Summary.CoveragePct < float64(*minCoverage) { + fmt.Fprintf(stderr, "fusaops tara: coverage %.1f%% < required %d%%\n", t.Summary.CoveragePct, *minCoverage) + exit = 1 + } + + return exit +} + +// taraQualFields extracts t's qualitative text fields for §1.6.1 detection. +// +//fusa:req REQ-FO-CLI084 +func taraQualFields(t *tara.TARA) []qualitybar.QualField { + var out []qualitybar.QualField + for _, s := range t.Threats { + out = append(out, + qualitybar.QualField{EntryID: s.ID, Field: "threat", Value: s.Threat}, + qualitybar.QualField{EntryID: s.ID, Field: "damageScenario", Value: s.DamageScenario}, + ) } - return 0 + return out } diff --git a/fmea.json b/fmea.json index f2d3c6a..cb7eeea 100644 --- a/fmea.json +++ b/fmea.json @@ -1,8 +1,8 @@ { - "generatedAt": "2026-07-28T18:47:06.891193Z", + "generatedAt": "2026-07-28T19:19:37.850745Z", "projectRoot": ".", "tool": "fusaops", - "toolVersion": "1.140.0", + "toolVersion": "1.141.0", "standard": "IEC 61508:2010 / ISO 26262:2018 Part 8-7", "ratingScale": "custom-1-10", "entries": [ @@ -193,7 +193,11 @@ ], "summary": { "total": 8, - "highPriority": 6 + "highPriority": 6, + "componentsAnalyzed": 8, + "componentsInProject": 41, + "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:33fc5ca071aca851d874052cd2c68ebbe925be759aeb130ea85afa10ff52fac2" + "hash": "sha256:1bb7b76663c10f4c2e5a1681a16a143fe255f8633953ed75f466547f628b116b" } diff --git a/fmea/fmea.go b/fmea/fmea.go index 7bc114f..402410b 100644 --- a/fmea/fmea.go +++ b/fmea/fmea.go @@ -14,6 +14,7 @@ import ( "encoding/json" "fmt" "io" + "math" "os" "time" @@ -31,6 +32,21 @@ const ReportFile = ".fusaops-fmea.json" //fusa:req REQ-FO-FMEA001 const HighRPNThreshold = 100 +// ComponentsInProject is the total count of FuSaOps packages (the core +// orchestration-pipeline table plus the layered compliance/evidence/workflow +// table in CLAUDE.md) — the denominator behind Summary.CoveragePct. Update +// this alongside CLAUDE.md's package tables if either grows. +// +//fusa:req REQ-FO-FMEA008 +const ComponentsInProject = 41 + +// ComponentInventoryMethod documents how ComponentsInProject was counted, so +// Summary.CoveragePct is auditable rather than an unexplained number (x-FuSa +// spec §9.2's coveragePct rationale). +// +//fusa:req REQ-FO-FMEA008 +const 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\")" + // RatingScale identifies the severity/occurrence/detection rating table in // use. FuSaOps predates AIAG-VDA 2019 adoption and uses its own 1-10 scale, // so it is named rather than claiming a standard it does not implement. @@ -68,24 +84,34 @@ type FailureMode struct { // Summary rolls up the FMEA's totals and analysis-coverage metrics. // //fusa:req REQ-FO-FMEA006 +//fusa:req REQ-FO-FMEA008 type Summary struct { Total int `json:"total"` HighPriority int `json:"highPriority"` + // ComponentsInProject is the stated denominator behind CoveragePct — see + // ComponentInventoryMethod for how it's counted. This is what stops an + // FMEA from covering only a few convenient functions while looking + // thorough by entry count alone (x-FuSa spec §9.2). + ComponentsAnalyzed int `json:"componentsAnalyzed"` + ComponentsInProject int `json:"componentsInProject"` + CoveragePct float64 `json:"coveragePct"` + ComponentInventoryMethod string `json:"componentInventoryMethod"` } // FMEA is the top-level dFMEA document. // //fusa:req REQ-FO-FMEA001 type FMEA struct { - GeneratedAt time.Time `json:"generatedAt"` - ProjectRoot string `json:"projectRoot"` - Tool string `json:"tool"` - ToolVersion string `json:"toolVersion"` - Standard string `json:"standard"` - RatingScale string `json:"ratingScale"` - Entries []FailureMode `json:"entries"` - Summary Summary `json:"summary"` - Hash string `json:"hash"` + GeneratedAt time.Time `json:"generatedAt"` + ProjectRoot string `json:"projectRoot"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + Standard string `json:"standard"` + RatingScale string `json:"ratingScale"` + Entries []FailureMode `json:"entries"` + Summary Summary `json:"summary"` + Attestation *fusaops.Attestation `json:"attestation,omitempty"` + Hash string `json:"hash"` } // HasHighRPN returns true when any failure mode exceeds HighRPNThreshold. @@ -316,10 +342,25 @@ func Build(root string) (*FMEA, error) { } } + f.Summary.ComponentsAnalyzed = f.Summary.Total + f.Summary.ComponentsInProject = ComponentsInProject + f.Summary.CoveragePct = coveragePct(f.Summary.ComponentsAnalyzed, f.Summary.ComponentsInProject) + f.Summary.ComponentInventoryMethod = ComponentInventoryMethod + f.Hash = computeHash(f) return f, nil } +// coveragePct returns 100*analyzed/total rounded to one decimal, or 100 when +// total is 0 (no denominator means nothing is uncovered). +func coveragePct(analyzed, total int) float64 { + if total == 0 { + return 100 + } + pct := 100 * float64(analyzed) / float64(total) + return math.Round(pct*10) / 10 +} + func computeHash(f *FMEA) string { tmp := *f tmp.Hash = "" @@ -335,6 +376,23 @@ func computeHash(f *FMEA) string { return fmt.Sprintf("sha256:%x", sum) } +// AttestationContentHash computes the hash a §1.6.2 attestation must match +// to be considered non-stale: f's substantive content, excluding Hash, +// Attestation, and GeneratedAt — the fields an attestation is not about. +// +//fusa:req REQ-FO-FMEA007 +func AttestationContentHash(f *FMEA) string { + tmp := *f + tmp.Hash = "" + tmp.Attestation = nil + tmp.GeneratedAt = time.Time{} + data, err := json.Marshal(tmp) + if err != nil { + return "" + } + return fusaops.ContentHash(data) +} + // Save writes the FMEA to path as indented JSON. // //fusa:req REQ-FO-FMEA003 diff --git a/fmea/fmea_test.go b/fmea/fmea_test.go index d8c4293..710823d 100644 --- a/fmea/fmea_test.go +++ b/fmea/fmea_test.go @@ -134,6 +134,55 @@ func TestBuildHashHasAlgoPrefix(t *testing.T) { } } +// TestAttestationContentHashStableAcrossRebuild verifies +// AttestationContentHash is deterministic across two Build calls (same +// content should hash the same, since it excludes GeneratedAt). +// +//fusa:test REQ-FO-FMEA007 +func TestAttestationContentHashStableAcrossRebuild(t *testing.T) { + dir := t.TempDir() + f1, err := fmea.Build(dir) + if err != nil { + t.Fatalf("Build: %v", err) + } + f2, err := fmea.Build(dir) + if err != nil { + t.Fatalf("Build: %v", err) + } + h1 := fmea.AttestationContentHash(f1) + h2 := fmea.AttestationContentHash(f2) + if h1 == "" { + t.Error("AttestationContentHash must not be empty") + } + if h1 != h2 { + t.Errorf("AttestationContentHash differs across identical builds (GeneratedAt not excluded?): %q != %q", h1, h2) + } +} + +// TestSummaryCoverageMetrics verifies Summary's coverage fields are +// populated and internally consistent. +// +//fusa:test REQ-FO-FMEA008 +func TestSummaryCoverageMetrics(t *testing.T) { + f, err := fmea.Build(t.TempDir()) + if err != nil { + t.Fatalf("Build: %v", err) + } + if f.Summary.ComponentsInProject != fmea.ComponentsInProject { + t.Errorf("ComponentsInProject = %d, want %d", f.Summary.ComponentsInProject, fmea.ComponentsInProject) + } + if f.Summary.ComponentsAnalyzed != f.Summary.Total { + t.Errorf("ComponentsAnalyzed = %d, want %d (Summary.Total)", f.Summary.ComponentsAnalyzed, f.Summary.Total) + } + want := 100 * float64(f.Summary.ComponentsAnalyzed) / float64(f.Summary.ComponentsInProject) + if diff := f.Summary.CoveragePct - want; diff > 0.05 || diff < -0.05 { + t.Errorf("CoveragePct = %v, want ~%v", f.Summary.CoveragePct, want) + } + if f.Summary.ComponentInventoryMethod == "" { + t.Error("ComponentInventoryMethod must be documented, not empty") + } +} + //fusa:test REQ-FO-FMEA002 func TestBuildHashChanges(t *testing.T) { f1, _ := fmea.Build(t.TempDir()) diff --git a/fusaops.go b/fusaops.go index 268728e..9e997e4 100644 --- a/fusaops.go +++ b/fusaops.go @@ -20,7 +20,7 @@ import ( ) // Version is the current release of FuSaOps. -const Version = "1.140.0" +const Version = "1.141.0" // SpecVersion is the x-FuSa specification version this release targets. // diff --git a/hara/hara.go b/hara/hara.go index 416af29..5b05b6a 100644 --- a/hara/hara.go +++ b/hara/hara.go @@ -13,6 +13,8 @@ import ( "os" "path/filepath" "time" + + fusaops "github.com/SoundMatt/FuSaOps" ) // HARAFile is the standard filename for the HARA data store. @@ -120,6 +122,24 @@ type HARA struct { Situations []OperationalSituation `json:"operationalSituations"` Hazards []Hazard `json:"hazards"` SafetyGoals []SafetyGoal `json:"safetyGoals"` + Attestation *fusaops.Attestation `json:"attestation,omitempty"` +} + +// 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, +// .fusa-hara.json is a human-authored input file whose CreatedAt is stable +// content, not a volatile regeneration timestamp). +// +//fusa:req REQ-FO-HARA006 +func AttestationContentHash(h *HARA) string { + tmp := *h + tmp.Attestation = nil + data, err := json.Marshal(tmp) + if err != nil { + return "" + } + return fusaops.ContentHash(data) } // ValidationFinding is a gap identified by Validate. diff --git a/hara/hara_test.go b/hara/hara_test.go index beac374..5d934be 100644 --- a/hara/hara_test.go +++ b/hara/hara_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + fusaops "github.com/SoundMatt/FuSaOps" "github.com/SoundMatt/FuSaOps/hara" ) @@ -248,6 +249,31 @@ func TestValidateNoFssrRefsOnGoal(t *testing.T) { } } +// TestAttestationContentHashStableAndExcludesAttestation verifies +// AttestationContentHash is deterministic for identical content and does +// not change when only the Attestation field itself is set (it must be +// excluded from its own input, or a chicken-and-egg staleness loop would +// result). +// +//fusa:test REQ-FO-HARA006 +func TestAttestationContentHashStableAndExcludesAttestation(t *testing.T) { + h := &hara.HARA{Project: "test", Standard: "ISO 26262"} + h1 := hara.AttestationContentHash(h) + h2 := hara.AttestationContentHash(h) + if h1 == "" { + t.Error("AttestationContentHash must not be empty") + } + if h1 != h2 { + t.Errorf("AttestationContentHash not deterministic: %q != %q", h1, h2) + } + + h.Attestation = &fusaops.Attestation{Status: fusaops.AttestationReviewed, ContentHash: h1} + h3 := hara.AttestationContentHash(h) + if h3 != h1 { + t.Errorf("AttestationContentHash changed after setting Attestation: %q != %q", h3, h1) + } +} + //fusa:test REQ-FO-HARA003 func TestLoadMissing(t *testing.T) { h, err := hara.Load(t.TempDir()) diff --git a/qualitybar/qualitybar.go b/qualitybar/qualitybar.go new file mode 100644 index 0000000..683e219 --- /dev/null +++ b/qualitybar/qualitybar.go @@ -0,0 +1,128 @@ +// Package qualitybar implements the x-FuSa spec §1.6.1 detection heuristics +// for evidence-artifact content quality: placeholder/template text (Rule A, +// FUSA-STUB001, always an ERROR) and blanket qualitative fallback (Rule B, +// FUSA-STUB002, an advisory WARNING suppressible by a valid §1.6.2 +// attestation). Both reuse fusaops.Finding and fusaops.ComputeFingerprint so +// they compose with disposition/suppression like any other check finding. +package qualitybar + +import ( + "fmt" + "regexp" + "sort" + "strings" + + fusaops "github.com/SoundMatt/FuSaOps" +) + +// QualField is one qualitative text field to scan, tagged with which entry +// and logical field it came from so a Finding can point back to it and Rule +// B can group same-field values across entries. +// +//fusa:req REQ-QB001 +type QualField struct { + EntryID string // e.g. "FM-001", "H-001", "TS-001" — the entry this belongs to + Field string // e.g. "failureMode", "hazard", "threat" — semantic field name + Value string +} + +var placeholderBracketRE = regexp.MustCompile(`\[[A-Za-z ][^\]]*\]`) + +// placeholderSubstrings mirrors the x-FuSa spec §1.6.1 Rule A deny-list. +var placeholderSubstrings = []string{ + "replace with", + "example hazard", + "tbd", + "lorem ipsum", + "fill in", +} + +// DetectPlaceholder implements Rule A: a deny-list scan for literal +// template/scaffold text. A match is always an ERROR finding — never +// suppressible via attestation, only via a per-finding disposition, because +// no attestation can make placeholder text real. +// +//fusa:req REQ-QB001 +func DetectPlaceholder(artifactFile string, fields []QualField) []fusaops.Finding { + var out []fusaops.Finding + for _, f := range fields { + if !looksLikePlaceholder(f.Value) { + continue + } + finding := fusaops.Finding{ + Tool: "fusaops", + RuleID: "FUSA-STUB001", + Severity: fusaops.SeverityError, + Message: fmt.Sprintf("%s.%s contains placeholder/template text: %q", f.EntryID, f.Field, f.Value), + Location: fusaops.Location{File: artifactFile}, + Category: "safety", + Remediation: "replace the placeholder text with real, item-specific analysis", + } + finding.Fingerprint = fusaops.ComputeFingerprint(finding) + out = append(out, finding) + } + return out +} + +func looksLikePlaceholder(v string) bool { + lower := strings.ToLower(v) + for _, s := range placeholderSubstrings { + if strings.Contains(lower, s) { + return true + } + } + return placeholderBracketRE.MatchString(v) +} + +// DetectBlankFallback implements Rule B: for a group of ≥10 entries sharing +// the same semantic field (grouped by QualField.Field), a distinct-value +// ratio below 0.1 (fewer than 1 distinct value per 10 entries) is flagged as +// a WARNING — advisory only, never gating on its own, because a similarity +// heuristic can misread genuinely repetitive-but-real content as templated. +// Suppress via a valid attestation (fusaops.AttestationValid) rather than a +// disposition, since the concern is about the artifact as a whole, not one +// entry. +// +//fusa:req REQ-QB002 +func DetectBlankFallback(artifactFile string, fields []QualField) []fusaops.Finding { + byField := map[string][]QualField{} + for _, f := range fields { + byField[f.Field] = append(byField[f.Field], f) + } + + fieldNames := make([]string, 0, len(byField)) + for name := range byField { + fieldNames = append(fieldNames, name) + } + sort.Strings(fieldNames) // deterministic finding order + + var out []fusaops.Finding + for _, fieldName := range fieldNames { + group := byField[fieldName] + if len(group) < 10 { + continue + } + distinct := map[string]bool{} + for _, f := range group { + distinct[f.Value] = true + } + ratio := float64(len(distinct)) / float64(len(group)) + if ratio >= 0.1 { + continue + } + finding := fusaops.Finding{ + Tool: "fusaops", + RuleID: "FUSA-STUB002", + Severity: fusaops.SeverityWarning, + Message: fmt.Sprintf( + "field %q shows only %d distinct value(s) across %d entries (ratio %.2f < 0.10) — looks templated, not per-item analysis", + fieldName, len(distinct), len(group), ratio), + Location: fusaops.Location{File: artifactFile}, + Category: "safety", + Remediation: "vary this field per entry's actual signature/behaviour, or attest (x-FuSa spec §1.6.2) that the repetition is genuine", + } + finding.Fingerprint = fusaops.ComputeFingerprint(finding) + out = append(out, finding) + } + return out +} diff --git a/qualitybar/qualitybar_test.go b/qualitybar/qualitybar_test.go new file mode 100644 index 0000000..74f7f94 --- /dev/null +++ b/qualitybar/qualitybar_test.go @@ -0,0 +1,115 @@ +package qualitybar_test + +import ( + "strings" + "testing" + + fusaops "github.com/SoundMatt/FuSaOps" + "github.com/SoundMatt/FuSaOps/qualitybar" +) + +//fusa:test REQ-QB001 +func TestDetectPlaceholderBracketText(t *testing.T) { + fields := []qualitybar.QualField{ + {EntryID: "H-001", Field: "hazard", Value: "[describe hazard]"}, + } + findings := qualitybar.DetectPlaceholder("test.json", fields) + if len(findings) != 1 { + t.Fatalf("len(findings) = %d, want 1", len(findings)) + } + f := findings[0] + if f.RuleID != "FUSA-STUB001" || f.Severity != fusaops.SeverityError { + t.Errorf("finding = %+v, want RuleID=FUSA-STUB001 Severity=ERROR", f) + } + if f.Fingerprint == "" { + t.Error("Fingerprint must be set") + } +} + +//fusa:test REQ-QB001 +func TestDetectPlaceholderSubstrings(t *testing.T) { + cases := []string{ + "Example hazard — replace with project-specific hazard", + "TBD", + "lorem ipsum dolor", + "please fill in this section", + } + for _, v := range cases { + fields := []qualitybar.QualField{{EntryID: "X-001", Field: "threat", Value: v}} + if got := qualitybar.DetectPlaceholder("test.json", fields); len(got) != 1 { + t.Errorf("DetectPlaceholder(%q): got %d findings, want 1", v, len(got)) + } + } +} + +//fusa:test REQ-QB001 +func TestDetectPlaceholderRealContentNotFlagged(t *testing.T) { + fields := []qualitybar.QualField{ + {EntryID: "FM-001", Field: "failureMode", Value: "Adapter binary not found on PATH"}, + } + if got := qualitybar.DetectPlaceholder("test.json", fields); len(got) != 0 { + t.Errorf("real content flagged as placeholder: %+v", got) + } +} + +//fusa:test REQ-QB002 +func TestDetectBlankFallbackTriggersOnTemplatedField(t *testing.T) { + var fields []qualitybar.QualField + for i := 0; i < 12; i++ { + fields = append(fields, qualitybar.QualField{ + EntryID: "FM-0", Field: "failureMode", Value: "Returns incorrect value", + }) + } + findings := qualitybar.DetectBlankFallback("test.json", fields) + if len(findings) != 1 { + t.Fatalf("len(findings) = %d, want 1", len(findings)) + } + f := findings[0] + if f.RuleID != "FUSA-STUB002" || f.Severity != fusaops.SeverityWarning { + t.Errorf("finding = %+v, want RuleID=FUSA-STUB002 Severity=WARNING", f) + } +} + +//fusa:test REQ-QB002 +func TestDetectBlankFallbackBelowThresholdNotFlagged(t *testing.T) { + // Only 8 entries — below the >=10 trigger threshold. + var fields []qualitybar.QualField + for i := 0; i < 8; i++ { + fields = append(fields, qualitybar.QualField{ + EntryID: "FM-0", Field: "failureMode", Value: "same text", + }) + } + if got := qualitybar.DetectBlankFallback("test.json", fields); len(got) != 0 { + t.Errorf("expected no findings below the 10-entry threshold, got %+v", got) + } +} + +//fusa:test REQ-QB002 +func TestDetectBlankFallbackDiverseContentNotFlagged(t *testing.T) { + var fields []qualitybar.QualField + for i := 0; i < 12; i++ { + fields = append(fields, qualitybar.QualField{ + EntryID: "FM-0", Field: "failureMode", + Value: strings.Repeat("x", i+1), // 12 distinct values + }) + } + if got := qualitybar.DetectBlankFallback("test.json", fields); len(got) != 0 { + t.Errorf("expected no findings for diverse content, got %+v", got) + } +} + +//fusa:test REQ-QB002 +func TestDetectBlankFallbackGroupsBySeparateFields(t *testing.T) { + var fields []qualitybar.QualField + for i := 0; i < 12; i++ { + fields = append(fields, qualitybar.QualField{EntryID: "FM-0", Field: "failureMode", Value: "same"}) + fields = append(fields, qualitybar.QualField{EntryID: "FM-0", Field: "effect", Value: strings.Repeat("y", i+1)}) + } + findings := qualitybar.DetectBlankFallback("test.json", fields) + if len(findings) != 1 { + t.Fatalf("len(findings) = %d, want 1 (only failureMode should trigger)", len(findings)) + } + if !strings.Contains(findings[0].Message, "failureMode") { + t.Errorf("expected finding about failureMode, got %q", findings[0].Message) + } +} diff --git a/safety-case.json b/safety-case.json index 666719a..b6aa41a 100644 --- a/safety-case.json +++ b/safety-case.json @@ -1,8 +1,8 @@ { - "generatedAt": "2026-07-28T18:47:06.907424Z", + "generatedAt": "2026-07-28T19:19:37.863874Z", "projectRoot": ".", "tool": "fusaops", - "toolVersion": "1.140.0", + "toolVersion": "1.141.0", "standard": "ISO 26262", "claims": [ { @@ -270,5 +270,5 @@ "goalsWithEvidence": 3, "undeveloped": 4 }, - "hash": "sha256:8a624d3327d600e5625d8f8a17c6308f417808b98a8140ba27f70ed6107bf7c7" + "hash": "sha256:19a5ac0d36c85571d8e44cdad29426e35ccba524e3eac8a6fe512960ecb03235" } diff --git a/safetycase/safetycase.go b/safetycase/safetycase.go index 1c945c8..b2d9761 100644 --- a/safetycase/safetycase.go +++ b/safetycase/safetycase.go @@ -137,18 +137,19 @@ type Completeness struct { // //fusa:req REQ-FO-SC001 type SafetyCase struct { - GeneratedAt time.Time `json:"generatedAt"` - ProjectRoot string `json:"projectRoot"` - Tool string `json:"tool"` - ToolVersion string `json:"toolVersion"` - Standard Standard `json:"standard"` - Claims []Claim `json:"claims"` - TotalClaims int `json:"totalClaims"` - PassedClaims int `json:"passedClaims"` - Nodes []GSNNode `json:"nodes"` - Edges []GSNEdge `json:"edges"` - Completeness Completeness `json:"completeness"` - Hash string `json:"hash"` + GeneratedAt time.Time `json:"generatedAt"` + ProjectRoot string `json:"projectRoot"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + Standard Standard `json:"standard"` + Claims []Claim `json:"claims"` + TotalClaims int `json:"totalClaims"` + PassedClaims int `json:"passedClaims"` + Nodes []GSNNode `json:"nodes"` + Edges []GSNEdge `json:"edges"` + Completeness Completeness `json:"completeness"` + Attestation *fusaops.Attestation `json:"attestation,omitempty"` + Hash string `json:"hash"` } // HasGaps returns true when at least one claim failed due to missing evidence. @@ -360,6 +361,23 @@ func computeHash(sc *SafetyCase) string { return fmt.Sprintf("sha256:%x", sum) } +// AttestationContentHash computes the hash a §1.6.2 attestation must match +// to be considered non-stale: sc's substantive content, excluding Hash, +// Attestation, and GeneratedAt — the fields an attestation is not about. +// +//fusa:req REQ-FO-SC007 +func AttestationContentHash(sc *SafetyCase) string { + tmp := *sc + tmp.Hash = "" + tmp.Attestation = nil + tmp.GeneratedAt = time.Time{} + data, err := json.Marshal(tmp) + if err != nil { + return "" + } + return fusaops.ContentHash(data) +} + // Save writes the safety case to path as indented JSON. // //fusa:req REQ-FO-SC003 diff --git a/safetycase/safetycase_test.go b/safetycase/safetycase_test.go index a1cae6b..031434d 100644 --- a/safetycase/safetycase_test.go +++ b/safetycase/safetycase_test.go @@ -145,6 +145,31 @@ func TestBuildHashHasAlgoPrefix(t *testing.T) { } } +// TestAttestationContentHashStableAcrossRebuild verifies +// AttestationContentHash is deterministic across two Build calls against the +// same project root (excludes GeneratedAt). +// +//fusa:test REQ-FO-SC007 +func TestAttestationContentHashStableAcrossRebuild(t *testing.T) { + dir := t.TempDir() + sc1, err := safetycase.Build(dir, safetycase.StandardISO26262) + if err != nil { + t.Fatalf("Build: %v", err) + } + sc2, err := safetycase.Build(dir, safetycase.StandardISO26262) + if err != nil { + t.Fatalf("Build: %v", err) + } + h1 := safetycase.AttestationContentHash(sc1) + h2 := safetycase.AttestationContentHash(sc2) + if h1 == "" { + t.Error("AttestationContentHash must not be empty") + } + if h1 != h2 { + t.Errorf("AttestationContentHash differs across identical builds: %q != %q", h1, h2) + } +} + // TestBuildGSNProjection verifies each Claim becomes a goal+strategy node // pair, present evidence becomes solution nodes, and a claim with no // evidence at all counts toward Completeness.Undeveloped rather than diff --git a/sas/sas.go b/sas/sas.go index 8100a95..f4203f4 100644 --- a/sas/sas.go +++ b/sas/sas.go @@ -70,17 +70,18 @@ type Summary struct { // //fusa:req REQ-FO-SAS001 type SAS struct { - GeneratedAt time.Time `json:"generatedAt"` - ProjectRoot string `json:"projectRoot"` - Tool string `json:"tool"` - ToolVersion string `json:"toolVersion"` - SoftwareLevel string `json:"softwareLevel"` // DAL-A through DAL-E - Activities []Activity `json:"activities"` - TotalActivities int `json:"totalActivities"` - CompleteActivities int `json:"completeActivities"` - Checklist []ChecklistItem `json:"checklist"` - Summary Summary `json:"summary"` - Hash string `json:"hash"` + GeneratedAt time.Time `json:"generatedAt"` + ProjectRoot string `json:"projectRoot"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + SoftwareLevel string `json:"softwareLevel"` // DAL-A through DAL-E + Activities []Activity `json:"activities"` + TotalActivities int `json:"totalActivities"` + CompleteActivities int `json:"completeActivities"` + Checklist []ChecklistItem `json:"checklist"` + Summary Summary `json:"summary"` + Attestation *fusaops.Attestation `json:"attestation,omitempty"` + Hash string `json:"hash"` } // HasGaps returns true when at least one required activity is incomplete. @@ -201,6 +202,23 @@ func computeHash(s *SAS) string { return fmt.Sprintf("sha256:%x", sum) } +// AttestationContentHash computes the hash a §1.6.2 attestation must match +// to be considered non-stale: s's substantive content, excluding Hash, +// Attestation, and GeneratedAt — the fields an attestation is not about. +// +//fusa:req REQ-FO-SAS007 +func AttestationContentHash(s *SAS) string { + tmp := *s + tmp.Hash = "" + tmp.Attestation = nil + tmp.GeneratedAt = time.Time{} + data, err := json.Marshal(tmp) + if err != nil { + return "" + } + return fusaops.ContentHash(data) +} + // Save writes the SAS to path as indented JSON. // //fusa:req REQ-FO-SAS003 diff --git a/sas/sas_test.go b/sas/sas_test.go index eb23f9b..d37210b 100644 --- a/sas/sas_test.go +++ b/sas/sas_test.go @@ -132,6 +132,31 @@ func TestBuildHashHasAlgoPrefix(t *testing.T) { } } +// TestAttestationContentHashStableAcrossRebuild verifies +// AttestationContentHash is deterministic across two Build calls against the +// same project root (excludes GeneratedAt). +// +//fusa:test REQ-FO-SAS007 +func TestAttestationContentHashStableAcrossRebuild(t *testing.T) { + dir := t.TempDir() + s1, err := sas.Build(dir, "DAL-C") + if err != nil { + t.Fatalf("Build: %v", err) + } + s2, err := sas.Build(dir, "DAL-C") + if err != nil { + t.Fatalf("Build: %v", err) + } + h1 := sas.AttestationContentHash(s1) + h2 := sas.AttestationContentHash(s2) + if h1 == "" { + t.Error("AttestationContentHash must not be empty") + } + if h1 != h2 { + t.Errorf("AttestationContentHash differs across identical builds: %q != %q", h1, h2) + } +} + // TestBuildChecklistProjection verifies Checklist/Summary mirror // Activities/TotalActivities/CompleteActivities per x-FuSa spec §9.3. // diff --git a/tara.json b/tara.json index 2d12709..a7a2ed9 100644 --- a/tara.json +++ b/tara.json @@ -1,8 +1,8 @@ { - "generatedAt": "2026-07-28T18:47:06.900168Z", + "generatedAt": "2026-07-28T19:19:37.857132Z", "projectRoot": ".", "tool": "fusaops", - "toolVersion": "1.140.0", + "toolVersion": "1.141.0", "standard": "ISO 21434:2021 Chapter 9", "threats": [ { @@ -185,7 +185,11 @@ "summary": { "total": 8, "critical": 2, - "high": 5 + "high": 5, + "assetsAnalyzed": 8, + "assetsInProject": 10, + "coveragePct": 80, + "assetInventoryMethod": "count of FuSaOps' own sci package's knownArtefacts evidence-artefact inventory (10 items, DOC-001..DOC-010); non-file assets (adapter tool binaries, the CI/CD pipeline itself, .fusaops.json config) are analyzed as threat scenarios here but excluded from this file-based denominator" }, - "hash": "sha256:2f181b63e0a2dc6df3c80445d3fdac6d4a17f67429c19b6934c7089f709004d5" + "hash": "sha256:b65ed1d23bb83e0e9cbebd24f76d61e879de66db6944da5d45bf637bffbd228f" } diff --git a/tara/tara.go b/tara/tara.go index 98cefa2..94fa792 100644 --- a/tara/tara.go +++ b/tara/tara.go @@ -13,6 +13,7 @@ import ( "encoding/json" "fmt" "io" + "math" "os" "time" @@ -113,20 +114,43 @@ type Summary struct { Total int `json:"total"` Critical int `json:"critical"` High int `json:"high"` + // AssetsInProject is the stated denominator behind CoveragePct — see + // AssetInventoryMethod for how it's counted (x-FuSa spec §9.2). + AssetsAnalyzed int `json:"assetsAnalyzed"` + AssetsInProject int `json:"assetsInProject"` + CoveragePct float64 `json:"coveragePct"` + AssetInventoryMethod string `json:"assetInventoryMethod"` } +// AssetsInProject is the count of FuSaOps' own known evidence-artefact +// inventory (the sci package's knownArtefacts, DOC-001..DOC-010) — the +// denominator behind Summary.CoveragePct. Non-file assets this TARA also +// analyzes (adapter tool binaries, the CI/CD pipeline itself, the +// .fusaops.json config file) are intentionally excluded from this +// file-based denominator; see AssetInventoryMethod. +// +//fusa:req REQ-FO-TARA008 +const AssetsInProject = 10 + +// AssetInventoryMethod documents how AssetsInProject was counted, so +// Summary.CoveragePct is auditable rather than an unexplained number. +// +//fusa:req REQ-FO-TARA008 +const AssetInventoryMethod = "count of FuSaOps' own sci package's knownArtefacts evidence-artefact inventory (10 items, DOC-001..DOC-010); non-file assets (adapter tool binaries, the CI/CD pipeline itself, .fusaops.json config) are analyzed as threat scenarios here but excluded from this file-based denominator" + // TARA is the top-level threat analysis document. // //fusa:req REQ-FO-TARA001 type TARA struct { - GeneratedAt time.Time `json:"generatedAt"` - ProjectRoot string `json:"projectRoot"` - Tool string `json:"tool"` - ToolVersion string `json:"toolVersion"` - Standard string `json:"standard"` - Threats []ThreatScenario `json:"threats"` - Summary Summary `json:"summary"` - Hash string `json:"hash"` + GeneratedAt time.Time `json:"generatedAt"` + ProjectRoot string `json:"projectRoot"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + Standard string `json:"standard"` + Threats []ThreatScenario `json:"threats"` + Summary Summary `json:"summary"` + Attestation *fusaops.Attestation `json:"attestation,omitempty"` + Hash string `json:"hash"` } // HasCritical returns true when any scenario carries a critical risk level. @@ -349,10 +373,25 @@ func Build(root string) (*TARA, error) { } } + t.Summary.AssetsAnalyzed = t.Summary.Total + t.Summary.AssetsInProject = AssetsInProject + t.Summary.CoveragePct = coveragePct(t.Summary.AssetsAnalyzed, t.Summary.AssetsInProject) + t.Summary.AssetInventoryMethod = AssetInventoryMethod + t.Hash = computeHash(t) return t, nil } +// coveragePct returns 100*analyzed/total rounded to one decimal, or 100 when +// total is 0 (no denominator means nothing is uncovered). +func coveragePct(analyzed, total int) float64 { + if total == 0 { + return 100 + } + pct := 100 * float64(analyzed) / float64(total) + return math.Round(pct*10) / 10 +} + func computeHash(t *TARA) string { tmp := *t tmp.Hash = "" @@ -368,6 +407,23 @@ func computeHash(t *TARA) string { return fmt.Sprintf("sha256:%x", sum) } +// AttestationContentHash computes the hash a §1.6.2 attestation must match +// to be considered non-stale: t's substantive content, excluding Hash, +// Attestation, and GeneratedAt — the fields an attestation is not about. +// +//fusa:req REQ-FO-TARA007 +func AttestationContentHash(t *TARA) string { + tmp := *t + tmp.Hash = "" + tmp.Attestation = nil + tmp.GeneratedAt = time.Time{} + data, err := json.Marshal(tmp) + if err != nil { + return "" + } + return fusaops.ContentHash(data) +} + // Save writes the TARA to path as indented JSON. // //fusa:req REQ-FO-TARA003 diff --git a/tara/tara_test.go b/tara/tara_test.go index 037f003..97722a6 100644 --- a/tara/tara_test.go +++ b/tara/tara_test.go @@ -177,6 +177,55 @@ func TestBuildHashHasAlgoPrefix(t *testing.T) { } } +// TestAttestationContentHashStableAcrossRebuild verifies +// AttestationContentHash is deterministic across two Build calls against the +// same project root (excludes GeneratedAt). +// +//fusa:test REQ-FO-TARA007 +func TestAttestationContentHashStableAcrossRebuild(t *testing.T) { + dir := t.TempDir() + t1, err := tara.Build(dir) + if err != nil { + t.Fatalf("Build: %v", err) + } + t2, err := tara.Build(dir) + if err != nil { + t.Fatalf("Build: %v", err) + } + h1 := tara.AttestationContentHash(t1) + h2 := tara.AttestationContentHash(t2) + if h1 == "" { + t.Error("AttestationContentHash must not be empty") + } + if h1 != h2 { + t.Errorf("AttestationContentHash differs across identical builds: %q != %q", h1, h2) + } +} + +// TestSummaryCoverageMetrics verifies Summary's coverage fields are +// populated and internally consistent. +// +//fusa:test REQ-FO-TARA008 +func TestSummaryCoverageMetrics(t *testing.T) { + tr, err := tara.Build(t.TempDir()) + if err != nil { + t.Fatalf("Build: %v", err) + } + if tr.Summary.AssetsInProject != tara.AssetsInProject { + t.Errorf("AssetsInProject = %d, want %d", tr.Summary.AssetsInProject, tara.AssetsInProject) + } + if tr.Summary.AssetsAnalyzed != tr.Summary.Total { + t.Errorf("AssetsAnalyzed = %d, want %d (Summary.Total)", tr.Summary.AssetsAnalyzed, tr.Summary.Total) + } + want := 100 * float64(tr.Summary.AssetsAnalyzed) / float64(tr.Summary.AssetsInProject) + if diff := tr.Summary.CoveragePct - want; diff > 0.05 || diff < -0.05 { + t.Errorf("CoveragePct = %v, want ~%v", tr.Summary.CoveragePct, want) + } + if tr.Summary.AssetInventoryMethod == "" { + t.Error("AssetInventoryMethod must be documented, not empty") + } +} + //fusa:test REQ-FO-TARA002 func TestBuildHashChanges(t *testing.T) { tr1, _ := tara.Build(t.TempDir())