[object Object]

← back to Cli Printing Press

fix(browsersniff): accept v2-shape traffic-analysis.json on load (#474) (#478)

63c9618cfc9d61745189c84b96df96bfe188601d · 2026-05-01 20:53:09 -0700 · Trevin Chow

Hand-authored or v2-binary-generated traffic-analysis.json files failed to
load on v3.x with five sequential schema errors, blocking generate until
the JSON was rewritten by hand. The food52 reprint hit all five in a row
and had to migrate them manually.

Add UnmarshalJSON to bridge the v2/v3 shape changes:

- ProtectionObservation.Notes: accept "..." (v2) or ["..."] (v3)
- RequestSequence.Notes: same shape compat as ProtectionObservation
- AnalysisWarning: accept "string" (v2, materialized as scope_note) or
  object (v3)
- AuthAnalysis: accept candidate_types: [...] (v2, each materialized as
  {type: <s>, confidence: 1.0}) alongside candidates: [...] (v3); v3
  wins when both are present
- TrafficAnalysis.GenerationHints: accept {key: bool} map (v2,
  flattened to a sorted slice of true keys) or []string (v3)
- TrafficAnalysis.Version: normalize "1.0" (v2) to "1" (v3) so the
  ReadTrafficAnalysis version gate accepts both without relaxing

Mirrors the EvidenceRef sentinel pattern already established in this file.
Version-rejection still fires for genuinely unknown versions ("2", "9",
"draft") — only the "1.0" alias is normalized.

Coverage:
- Per-type table-driven tests for each shape change with v2 input,
  v3 input, missing-field, and round-trip cases.
- TestReadTrafficAnalysis_V2ShapeFile end-to-end test that writes a
  fully v2-shape file (matching the food52 prior reprint) to disk and
  asserts the loader presents the v3 in-memory shape.
- TestReadTrafficAnalysis_VersionRejection confirms unknown versions
  still fail.

Closes #474.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 63c9618cfc9d61745189c84b96df96bfe188601d
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 1 20:53:09 2026 -0700

    fix(browsersniff): accept v2-shape traffic-analysis.json on load (#474) (#478)
    
    Hand-authored or v2-binary-generated traffic-analysis.json files failed to
    load on v3.x with five sequential schema errors, blocking generate until
    the JSON was rewritten by hand. The food52 reprint hit all five in a row
    and had to migrate them manually.
    
    Add UnmarshalJSON to bridge the v2/v3 shape changes:
    
    - ProtectionObservation.Notes: accept "..." (v2) or ["..."] (v3)
    - RequestSequence.Notes: same shape compat as ProtectionObservation
    - AnalysisWarning: accept "string" (v2, materialized as scope_note) or
      object (v3)
    - AuthAnalysis: accept candidate_types: [...] (v2, each materialized as
      {type: <s>, confidence: 1.0}) alongside candidates: [...] (v3); v3
      wins when both are present
    - TrafficAnalysis.GenerationHints: accept {key: bool} map (v2,
      flattened to a sorted slice of true keys) or []string (v3)
    - TrafficAnalysis.Version: normalize "1.0" (v2) to "1" (v3) so the
      ReadTrafficAnalysis version gate accepts both without relaxing
    
    Mirrors the EvidenceRef sentinel pattern already established in this file.
    Version-rejection still fires for genuinely unknown versions ("2", "9",
    "draft") — only the "1.0" alias is normalized.
    
    Coverage:
    - Per-type table-driven tests for each shape change with v2 input,
      v3 input, missing-field, and round-trip cases.
    - TestReadTrafficAnalysis_V2ShapeFile end-to-end test that writes a
      fully v2-shape file (matching the food52 prior reprint) to disk and
      asserts the loader presents the v3 in-memory shape.
    - TestReadTrafficAnalysis_VersionRejection confirms unknown versions
      still fail.
    
    Closes #474.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/browsersniff/analysis.go                | 207 ++++++++++++++++++
 internal/browsersniff/analysis_v2_compat_test.go | 254 +++++++++++++++++++++++
 2 files changed, 461 insertions(+)

diff --git a/internal/browsersniff/analysis.go b/internal/browsersniff/analysis.go
index 7cde424a..22c3bed2 100644
--- a/internal/browsersniff/analysis.go
+++ b/internal/browsersniff/analysis.go
@@ -31,6 +31,106 @@ type TrafficAnalysis struct {
 	Warnings          []AnalysisWarning       `json:"warnings,omitempty"`
 }
 
+// UnmarshalJSON normalizes two v2 shapes that v3 no longer emits but that
+// hand-authored or v2-binary-generated traffic analyses still carry:
+//
+//   - `version: "1.0"` → normalized to "1" so the version check in
+//     ReadTrafficAnalysis matches without rejecting otherwise-loadable input.
+//   - `generation_hints: {key: bool}` (object) → flattened to a sorted slice
+//     of true keys, matching the v3 derivation.
+//
+// See issue #474 for the broader compat story.
+func (t *TrafficAnalysis) UnmarshalJSON(data []byte) error {
+	type alias TrafficAnalysis
+	var legacy struct {
+		*alias
+		GenerationHints json.RawMessage `json:"generation_hints,omitempty"`
+	}
+	legacy.alias = (*alias)(t)
+	if err := json.Unmarshal(data, &legacy); err != nil {
+		return err
+	}
+	t.Version = normalizeTrafficAnalysisVersion(t.Version)
+	if len(legacy.GenerationHints) == 0 {
+		t.GenerationHints = nil
+		return nil
+	}
+	hints, err := unmarshalGenerationHints(legacy.GenerationHints)
+	if err != nil {
+		return fmt.Errorf("generation hints: %w", err)
+	}
+	t.GenerationHints = hints
+	return nil
+}
+
+// normalizeTrafficAnalysisVersion accepts the legacy "1.0" form (v2 binaries
+// emitted "1.0"; v3 emits "1") so the consumer-side version check doesn't
+// have to know about minor-version trivia.
+func normalizeTrafficAnalysisVersion(v string) string {
+	switch strings.TrimSpace(v) {
+	case "1.0":
+		return "1"
+	default:
+		return v
+	}
+}
+
+// unmarshalStringOrStringSlice accepts a JSON value that is either a string
+// (returned as a single-element slice) or a string slice. Used by Notes
+// fields to bridge the v2 "string" / v3 "[]string" shape change.
+func unmarshalStringOrStringSlice(data []byte) ([]string, error) {
+	if len(data) == 0 {
+		return nil, nil
+	}
+	if data[0] == '"' {
+		var s string
+		if err := json.Unmarshal(data, &s); err != nil {
+			return nil, fmt.Errorf("string form: %w", err)
+		}
+		if s == "" {
+			return nil, nil
+		}
+		return []string{s}, nil
+	}
+	var slice []string
+	if err := json.Unmarshal(data, &slice); err != nil {
+		return nil, fmt.Errorf("string-slice form: %w", err)
+	}
+	return slice, nil
+}
+
+// unmarshalGenerationHints accepts both the v3 `[]string` form and the v2
+// `map[string]bool` form. The map form (where each true entry was a derived
+// hint key) flattens to a sorted slice — matching what deriveGenerationHints
+// produces today.
+func unmarshalGenerationHints(data []byte) ([]string, error) {
+	if len(data) == 0 {
+		return nil, nil
+	}
+	if data[0] == '[' {
+		var slice []string
+		if err := json.Unmarshal(data, &slice); err != nil {
+			return nil, fmt.Errorf("slice form: %w", err)
+		}
+		return slice, nil
+	}
+	var legacyMap map[string]bool
+	if err := json.Unmarshal(data, &legacyMap); err != nil {
+		return nil, fmt.Errorf("legacy map form: %w", err)
+	}
+	if len(legacyMap) == 0 {
+		return nil, nil
+	}
+	out := make([]string, 0, len(legacyMap))
+	for k, v := range legacyMap {
+		if v {
+			out = append(out, k)
+		}
+	}
+	sort.Strings(out)
+	return out, nil
+}
+
 type TrafficAnalysisSummary struct {
 	TargetURL        string         `json:"target_url,omitempty"`
 	CapturedAt       string         `json:"captured_at,omitempty"`
@@ -124,6 +224,31 @@ type AuthAnalysis struct {
 	Candidates []AuthCandidate `json:"candidates,omitempty"`
 }
 
+// UnmarshalJSON accepts v2-shape `auth.candidate_types: ["api_key", "none"]`
+// (a flat list of type strings) alongside v3-shape `auth.candidates: [{...}]`
+// (objects with type/confidence/evidence). Each legacy string is materialized
+// as `{type: <s>, confidence: 1.0}`. See issue #474.
+func (a *AuthAnalysis) UnmarshalJSON(data []byte) error {
+	var legacy struct {
+		Candidates     []AuthCandidate `json:"candidates,omitempty"`
+		CandidateTypes []string        `json:"candidate_types,omitempty"`
+	}
+	if err := json.Unmarshal(data, &legacy); err != nil {
+		return err
+	}
+	a.Candidates = legacy.Candidates
+	if len(a.Candidates) == 0 && len(legacy.CandidateTypes) > 0 {
+		a.Candidates = make([]AuthCandidate, 0, len(legacy.CandidateTypes))
+		for _, t := range legacy.CandidateTypes {
+			a.Candidates = append(a.Candidates, AuthCandidate{
+				Type:       t,
+				Confidence: 1.0,
+			})
+		}
+	}
+	return nil
+}
+
 type AuthCandidate struct {
 	Type        string        `json:"type"`
 	Confidence  float64       `json:"confidence"`
@@ -148,6 +273,32 @@ type ProtectionObservation struct {
 	Notes      []string      `json:"notes,omitempty"`
 }
 
+// UnmarshalJSON accepts v2-shape `notes: "..."` (single string) alongside
+// v3-shape `notes: ["...", ...]`. Hand-authored or v2-generated traffic
+// analyses with string notes were a real shape; rejecting them outright
+// forced manual conversion (see issue #474).
+func (p *ProtectionObservation) UnmarshalJSON(data []byte) error {
+	type alias ProtectionObservation
+	var legacy struct {
+		*alias
+		Notes json.RawMessage `json:"notes,omitempty"`
+	}
+	legacy.alias = (*alias)(p)
+	if err := json.Unmarshal(data, &legacy); err != nil {
+		return err
+	}
+	if len(legacy.Notes) > 0 {
+		notes, err := unmarshalStringOrStringSlice(legacy.Notes)
+		if err != nil {
+			return fmt.Errorf("protection notes: %w", err)
+		}
+		p.Notes = notes
+	} else {
+		p.Notes = nil
+	}
+	return nil
+}
+
 type EndpointCluster struct {
 	Host          string        `json:"host,omitempty"`
 	Method        string        `json:"method"`
@@ -180,6 +331,31 @@ type RequestSequence struct {
 	Notes      []string      `json:"notes,omitempty"`
 }
 
+// UnmarshalJSON accepts v2-shape `notes: "..."` (single string) alongside
+// v3-shape `notes: ["...", ...]`. See ProtectionObservation.UnmarshalJSON
+// and issue #474 for the broader compat rationale.
+func (r *RequestSequence) UnmarshalJSON(data []byte) error {
+	type alias RequestSequence
+	var legacy struct {
+		*alias
+		Notes json.RawMessage `json:"notes,omitempty"`
+	}
+	legacy.alias = (*alias)(r)
+	if err := json.Unmarshal(data, &legacy); err != nil {
+		return err
+	}
+	if len(legacy.Notes) > 0 {
+		notes, err := unmarshalStringOrStringSlice(legacy.Notes)
+		if err != nil {
+			return fmt.Errorf("request sequence notes: %w", err)
+		}
+		r.Notes = notes
+	} else {
+		r.Notes = nil
+	}
+	return nil
+}
+
 type PaginationSignal struct {
 	Location   string        `json:"location"`
 	Name       string        `json:"name"`
@@ -202,6 +378,37 @@ type AnalysisWarning struct {
 	Evidence   []EvidenceRef `json:"evidence,omitempty"`
 }
 
+// UnmarshalJSON accepts v2-shape `warnings: ["...", "..."]` (flat strings)
+// alongside v3-shape `warnings: [{type, message, confidence, evidence}]`
+// (objects). Legacy strings are materialized as
+// `{type: "scope_note", message: <s>, confidence: 1.0, evidence: [<s>]}` —
+// the same shape the v2-to-v3 migration script used. See issue #474.
+func (w *AnalysisWarning) UnmarshalJSON(data []byte) error {
+	if len(data) > 0 && data[0] == '"' {
+		var s string
+		if err := json.Unmarshal(data, &s); err != nil {
+			return fmt.Errorf("analysis warning string: %w", err)
+		}
+		*w = AnalysisWarning{
+			Type:       "scope_note",
+			Message:    s,
+			Confidence: 1.0,
+			Evidence: []EvidenceRef{{
+				EntryIndex: EvidenceRefStringSentinel,
+				Reason:     s,
+			}},
+		}
+		return nil
+	}
+	type alias AnalysisWarning
+	var a alias
+	if err := json.Unmarshal(data, &a); err != nil {
+		return fmt.Errorf("analysis warning object: %w", err)
+	}
+	*w = AnalysisWarning(a)
+	return nil
+}
+
 func AnalyzeTraffic(capture *EnrichedCapture) (*TrafficAnalysis, error) {
 	if capture == nil {
 		return nil, fmt.Errorf("capture is required")
diff --git a/internal/browsersniff/analysis_v2_compat_test.go b/internal/browsersniff/analysis_v2_compat_test.go
new file mode 100644
index 00000000..00068085
--- /dev/null
+++ b/internal/browsersniff/analysis_v2_compat_test.go
@@ -0,0 +1,254 @@
+package browsersniff
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestProtectionObservation_NotesV2Compat covers the food52-reprint repro
+// from issue #474: prior traffic-analysis files emit `notes: "..."` (a single
+// string) where v3 expects `notes: ["..."]` (a slice).
+func TestProtectionObservation_NotesV2Compat(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name string
+		json string
+		want []string
+	}{
+		{
+			name: "v2 single string",
+			json: `{"label":"vercel_bot_mitigation","confidence":1.0,"notes":"x-vercel-mitigated: challenge"}`,
+			want: []string{"x-vercel-mitigated: challenge"},
+		},
+		{
+			name: "v3 slice",
+			json: `{"label":"vercel_bot_mitigation","confidence":1.0,"notes":["a","b"]}`,
+			want: []string{"a", "b"},
+		},
+		{
+			name: "missing notes",
+			json: `{"label":"x","confidence":0.5}`,
+			want: nil,
+		},
+		{
+			name: "empty string notes — treated as no notes",
+			json: `{"label":"x","confidence":0.5,"notes":""}`,
+			want: nil,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var p ProtectionObservation
+			require.NoError(t, json.Unmarshal([]byte(tc.json), &p))
+			assert.Equal(t, tc.want, p.Notes)
+
+			// Round-trip MarshalJSON → UnmarshalJSON: should remain the v3 shape.
+			rt, err := json.Marshal(&p)
+			require.NoError(t, err)
+			var p2 ProtectionObservation
+			require.NoError(t, json.Unmarshal(rt, &p2))
+			assert.Equal(t, p.Notes, p2.Notes)
+		})
+	}
+}
+
+// TestRequestSequence_NotesV2Compat mirrors the protection-observation case
+// for the second Notes []string site.
+func TestRequestSequence_NotesV2Compat(t *testing.T) {
+	t.Parallel()
+
+	var rs RequestSequence
+	require.NoError(t, json.Unmarshal([]byte(`{"label":"x","confidence":1.0,"notes":"single legacy note"}`), &rs))
+	assert.Equal(t, []string{"single legacy note"}, rs.Notes)
+
+	var rs2 RequestSequence
+	require.NoError(t, json.Unmarshal([]byte(`{"label":"x","confidence":1.0,"notes":["a","b","c"]}`), &rs2))
+	assert.Equal(t, []string{"a", "b", "c"}, rs2.Notes)
+}
+
+// TestAnalysisWarning_StringV2Compat covers the v2-shape `warnings: [<str>]`
+// where v3 expects `warnings: [{type, message, confidence, evidence}]`.
+func TestAnalysisWarning_StringV2Compat(t *testing.T) {
+	t.Parallel()
+
+	t.Run("v2 string materializes to scope_note", func(t *testing.T) {
+		var w AnalysisWarning
+		require.NoError(t, json.Unmarshal([]byte(`"Hotline /hotline returns only siteSettings"`), &w))
+		assert.Equal(t, "scope_note", w.Type)
+		assert.Equal(t, "Hotline /hotline returns only siteSettings", w.Message)
+		assert.Equal(t, 1.0, w.Confidence)
+		require.Len(t, w.Evidence, 1)
+		assert.Equal(t, EvidenceRefStringSentinel, w.Evidence[0].EntryIndex)
+		assert.Equal(t, "Hotline /hotline returns only siteSettings", w.Evidence[0].Reason)
+	})
+
+	t.Run("v3 object preserved as-is", func(t *testing.T) {
+		var w AnalysisWarning
+		require.NoError(t, json.Unmarshal([]byte(`{"type":"weak_schema_evidence","message":"only one fixture","confidence":0.6}`), &w))
+		assert.Equal(t, "weak_schema_evidence", w.Type)
+		assert.Equal(t, "only one fixture", w.Message)
+		assert.Equal(t, 0.6, w.Confidence)
+		assert.Empty(t, w.Evidence)
+	})
+
+	t.Run("slice of mixed shapes round-trips", func(t *testing.T) {
+		input := `[{"type":"a","message":"obj","confidence":0.5},"legacy string"]`
+		var ws []AnalysisWarning
+		require.NoError(t, json.Unmarshal([]byte(input), &ws))
+		require.Len(t, ws, 2)
+		assert.Equal(t, "a", ws[0].Type)
+		assert.Equal(t, "scope_note", ws[1].Type)
+		assert.Equal(t, "legacy string", ws[1].Message)
+	})
+}
+
+// TestAuthAnalysis_CandidateTypesV2Compat covers the v2-shape
+// `auth.candidate_types: ["api_key", "none"]` materializing into v3's
+// `auth.candidates: [{type:..., confidence:1.0}]`.
+func TestAuthAnalysis_CandidateTypesV2Compat(t *testing.T) {
+	t.Parallel()
+
+	t.Run("v2 candidate_types materializes to candidates", func(t *testing.T) {
+		var a AuthAnalysis
+		require.NoError(t, json.Unmarshal([]byte(`{"candidate_types":["none"]}`), &a))
+		require.Len(t, a.Candidates, 1)
+		assert.Equal(t, "none", a.Candidates[0].Type)
+		assert.Equal(t, 1.0, a.Candidates[0].Confidence)
+	})
+
+	t.Run("v3 candidates wins when both shapes present", func(t *testing.T) {
+		input := `{"candidates":[{"type":"api_key","confidence":0.9}],"candidate_types":["fallback_should_not_show"]}`
+		var a AuthAnalysis
+		require.NoError(t, json.Unmarshal([]byte(input), &a))
+		require.Len(t, a.Candidates, 1)
+		assert.Equal(t, "api_key", a.Candidates[0].Type)
+		assert.Equal(t, 0.9, a.Candidates[0].Confidence)
+	})
+
+	t.Run("missing both fields is empty", func(t *testing.T) {
+		var a AuthAnalysis
+		require.NoError(t, json.Unmarshal([]byte(`{}`), &a))
+		assert.Empty(t, a.Candidates)
+	})
+}
+
+// TestTrafficAnalysis_GenerationHintsMapCompat covers `generation_hints` shape
+// drift: v2 emitted an object map (key→bool); v3 emits a flat sorted slice
+// derived from a fixed vocabulary (deriveGenerationHints).
+func TestTrafficAnalysis_GenerationHintsMapCompat(t *testing.T) {
+	t.Parallel()
+
+	t.Run("v2 map flattens to sorted slice of true keys", func(t *testing.T) {
+		input := `{"version":"1","summary":{},"protocols":[],"auth":{},"endpoint_clusters":[],"generation_hints":{"requires_browser_compatible_http":true,"browser_http_transport":true,"client_pattern":false}}`
+		var ta TrafficAnalysis
+		require.NoError(t, json.Unmarshal([]byte(input), &ta))
+		assert.Equal(t, []string{"browser_http_transport", "requires_browser_compatible_http"}, ta.GenerationHints)
+	})
+
+	t.Run("v3 slice preserved as-is", func(t *testing.T) {
+		input := `{"version":"1","summary":{},"protocols":[],"auth":{},"endpoint_clusters":[],"generation_hints":["browser_http_transport"]}`
+		var ta TrafficAnalysis
+		require.NoError(t, json.Unmarshal([]byte(input), &ta))
+		assert.Equal(t, []string{"browser_http_transport"}, ta.GenerationHints)
+	})
+}
+
+// TestTrafficAnalysis_VersionNormalization confirms that v2's "1.0" loads
+// alongside v3's "1" without forcing the consumer to relax the version check.
+func TestTrafficAnalysis_VersionNormalization(t *testing.T) {
+	t.Parallel()
+
+	cases := map[string]string{
+		"1":   "1",
+		"1.0": "1", // v2 binaries emitted "1.0"; normalize on load
+	}
+	for in, want := range cases {
+		t.Run("version "+in, func(t *testing.T) {
+			input := `{"version":"` + in + `","summary":{},"protocols":[],"auth":{},"endpoint_clusters":[]}`
+			var ta TrafficAnalysis
+			require.NoError(t, json.Unmarshal([]byte(input), &ta))
+			assert.Equal(t, want, ta.Version)
+		})
+	}
+}
+
+// TestReadTrafficAnalysis_V2ShapeFile is the end-to-end test: write a fully
+// v2-shape file to disk, ReadTrafficAnalysis loads it without error and
+// presents the v3 in-memory shape. This is the failure mode that made the
+// food52 reprint patch its prior traffic-analysis.json by hand five times.
+func TestReadTrafficAnalysis_V2ShapeFile(t *testing.T) {
+	t.Parallel()
+
+	v2Body := `{
+		"version": "1.0",
+		"summary": {"target_url": "https://example.com"},
+		"protocols": [
+			{"label": "ssr_embedded_data", "confidence": 1.0, "notes": "legacy notes string on protocol — should be ignored"}
+		],
+		"auth": {
+			"candidate_types": ["none"]
+		},
+		"protections": [
+			{"label": "vercel_bot_mitigation", "confidence": 1.0, "notes": "x-vercel-mitigated: challenge"}
+		],
+		"endpoint_clusters": [],
+		"generation_hints": {
+			"browser_http_transport": true,
+			"requires_runtime_key_discovery": true,
+			"deprecated_hint": false
+		},
+		"warnings": [
+			"Hotline returns only siteSettings",
+			"Shop deferred to v2"
+		]
+	}`
+
+	dir := t.TempDir()
+	fp := filepath.Join(dir, "traffic-analysis.json")
+	require.NoError(t, os.WriteFile(fp, []byte(v2Body), 0o600))
+
+	analysis, err := ReadTrafficAnalysis(fp)
+	require.NoError(t, err)
+	require.NotNil(t, analysis)
+
+	assert.Equal(t, "1", analysis.Version, "v2 1.0 should normalize to 1")
+	require.Len(t, analysis.Protections, 1)
+	assert.Equal(t, []string{"x-vercel-mitigated: challenge"}, analysis.Protections[0].Notes)
+	require.Len(t, analysis.Auth.Candidates, 1)
+	assert.Equal(t, "none", analysis.Auth.Candidates[0].Type)
+	assert.Equal(t, []string{"browser_http_transport", "requires_runtime_key_discovery"}, analysis.GenerationHints)
+	require.Len(t, analysis.Warnings, 2)
+	for _, w := range analysis.Warnings {
+		assert.Equal(t, "scope_note", w.Type)
+		assert.NotEmpty(t, w.Message)
+	}
+}
+
+// TestReadTrafficAnalysis_VersionRejection confirms the version gate still
+// rejects unknown versions — the compat layer normalizes "1.0" but doesn't
+// silently accept "2" or "9".
+func TestReadTrafficAnalysis_VersionRejection(t *testing.T) {
+	t.Parallel()
+
+	cases := []string{"2", "9", "0.5", "draft"}
+	for _, v := range cases {
+		t.Run("version "+v, func(t *testing.T) {
+			body := `{"version":"` + v + `","summary":{},"protocols":[],"auth":{},"endpoint_clusters":[]}`
+			dir := t.TempDir()
+			fp := filepath.Join(dir, "ta.json")
+			require.NoError(t, os.WriteFile(fp, []byte(body), 0o600))
+
+			_, err := ReadTrafficAnalysis(fp)
+			require.Error(t, err)
+			assert.True(t, strings.Contains(err.Error(), "unsupported traffic analysis version"),
+				"want version-rejection error, got: %v", err)
+		})
+	}
+}

← 6d45d228 fix(regenmerge): correct owner rewrite + skip injection on p  ·  back to Cli Printing Press  ·  chore(main): release 3.3.0 (#469) 691ee25a →