From bec34b5988a78a76582dc368788e91bd7e7e9114 Mon Sep 17 00:00:00 2001 From: noroot Date: Thu, 27 Aug 2026 13:35:07 +0200 Subject: [PATCH] feat: add rules engine (internal/rules) with first two disclosure rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/rules implements Stufe 2 from the core principle: the LLM extracts facts, this deterministic engine judges them against versioned YAML rules. Facts/Condition/Rule/Finding types, a loader that refuses to load on a missing id/version or a duplicate rule id rather than silently skipping a bad file, and Evaluate() matching facts against rules. Two real rules grounded in verified research (see rules/OPEN.md for the open questions that surfaced along the way): - WK-001: no disclosure at all despite consideration (§ 5a Abs. 4 UWG, § 22 Abs. 1 MStV) - WK-004: disclosure present but hidden behind a "mehr anzeigen" cut (§ 5a Abs. 4 UWG, Leitfaden der Medienanstalten, LG Köln 12.05.2026) The two are deliberately disjoint (WK-004 requires disclosure_present= true) so a post with no disclosure at all doesn't double-fire both rules. Golden suite in testdata/golden/ covers both rules plus two clean cases; it's this suite, not the UI, that's the actual asset per CLAUDE.md. Co-Authored-By: Claude Sonnet 5 --- go.mod | 10 +- go.sum | 69 ++++++++++++ internal/rules/evaluate.go | 30 +++++ internal/rules/facts.go | 23 ++++ internal/rules/golden_test.go | 101 +++++++++++++++++ internal/rules/loader.go | 49 ++++++++ internal/rules/rule.go | 54 +++++++++ internal/rules/rule_test.go | 105 ++++++++++++++++++ rules/OPEN.md | 62 +++++++++++ rules/WK-001.yaml | 15 +++ rules/WK-004.yaml | 16 +++ testdata/golden/organic-no-consideration.json | 11 ++ .../golden/paid-disclosure-hidden-by-cut.json | 13 +++ testdata/golden/paid-disclosure-visible.json | 11 ++ testdata/golden/paid-no-disclosure.json | 13 +++ 15 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 internal/rules/evaluate.go create mode 100644 internal/rules/facts.go create mode 100644 internal/rules/golden_test.go create mode 100644 internal/rules/loader.go create mode 100644 internal/rules/rule.go create mode 100644 internal/rules/rule_test.go create mode 100644 rules/OPEN.md create mode 100644 rules/WK-001.yaml create mode 100644 rules/WK-004.yaml create mode 100644 testdata/golden/organic-no-consideration.json create mode 100644 testdata/golden/paid-disclosure-hidden-by-cut.json create mode 100644 testdata/golden/paid-disclosure-visible.json create mode 100644 testdata/golden/paid-no-disclosure.json diff --git a/go.mod b/go.mod index 7597174..9d88006 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,18 @@ module github.com/netcell-it/deklarix go 1.26.6 require ( - github.com/golang-migrate/migrate/v4 v4.19.1 // indirect + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jackc/pgx/v5 v5.10.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.10.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 8ce40dc..03a0d00 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,33 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= @@ -11,13 +40,53 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/rules/evaluate.go b/internal/rules/evaluate.go new file mode 100644 index 0000000..51f1b46 --- /dev/null +++ b/internal/rules/evaluate.go @@ -0,0 +1,30 @@ +package rules + +// Finding ist das Ergebnis einer einzelnen zutreffenden Regel. +type Finding struct { + RuleID string + RuleVersion int + Severity Severity + Title string + Fix string + Sources []string +} + +// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede +// zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules. +func Evaluate(rules []Rule, f Facts) []Finding { + var findings []Finding + for _, r := range rules { + if r.Condition.Matches(f) { + findings = append(findings, Finding{ + RuleID: r.ID, + RuleVersion: r.Version, + Severity: r.Severity, + Title: r.Title, + Fix: r.Fix, + Sources: r.Sources, + }) + } + } + return findings +} diff --git a/internal/rules/facts.go b/internal/rules/facts.go new file mode 100644 index 0000000..c6c3b51 --- /dev/null +++ b/internal/rules/facts.go @@ -0,0 +1,23 @@ +package rules + +// Consideration ist die Gegenleistung für einen Beitrag, wie sie die +// Extraktion (Stufe 1) liefert. +type Consideration string + +const ( + ConsiderationPaid Consideration = "bezahlt" + ConsiderationInKind Consideration = "sachbezug" + ConsiderationNone Consideration = "keine" + ConsiderationUnclear Consideration = "unklar" +) + +// Facts sind die Fakten aus der Extraktion (Stufe 1), auf denen die +// Regelauswertung (Stufe 2) urteilt. Die Extraktion liefert diese Werte, +// sie bewertet sie nicht — das Urteil fällt ausschließlich das Regelwerk. +type Facts struct { + Platform string `json:"platform"` + Consideration Consideration `json:"consideration"` + DisclosurePresent bool `json:"disclosure_present"` + DisclosureWording string `json:"disclosure_wording"` + DisclosureBeforeCut bool `json:"disclosure_before_cut"` +} diff --git a/internal/rules/golden_test.go b/internal/rules/golden_test.go new file mode 100644 index 0000000..b98ea52 --- /dev/null +++ b/internal/rules/golden_test.go @@ -0,0 +1,101 @@ +package rules_test + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strconv" + "testing" + + "github.com/netcell-it/deklarix/internal/rules" +) + +// goldenCase spiegelt eine Datei aus testdata/golden/: die extrahierten +// Fakten eines Beispielbeitrags plus die Findings, die das Regelwerk +// dafür liefern muss. Das ist das eigentliche Asset des Projekts, nicht +// die UI — siehe CLAUDE.md. +type goldenCase struct { + Name string `json:"name"` + Facts rules.Facts `json:"facts"` + ExpectedFindings []goldenFinding `json:"expected_findings"` +} + +type goldenFinding struct { + RuleID string `json:"rule_id"` + RuleVersion int `json:"rule_version"` + Severity string `json:"severity"` +} + +const ( + rulesDir = "../../rules" + goldenDir = "../../testdata/golden" +) + +func TestGolden(t *testing.T) { + ruleSet, err := rules.Load(os.DirFS(rulesDir)) + if err != nil { + t.Fatalf("Load rules: %v", err) + } + + entries, err := os.ReadDir(goldenDir) + if err != nil { + t.Fatalf("read golden dir: %v", err) + } + + found := 0 + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + found++ + entry := entry + t.Run(entry.Name(), func(t *testing.T) { + data, err := os.ReadFile(filepath.Join(goldenDir, entry.Name())) + if err != nil { + t.Fatalf("read %s: %v", entry.Name(), err) + } + var gc goldenCase + if err := json.Unmarshal(data, &gc); err != nil { + t.Fatalf("parse %s: %v", entry.Name(), err) + } + + got := rules.Evaluate(ruleSet, gc.Facts) + + gotKeys := make([]string, 0, len(got)) + for _, f := range got { + gotKeys = append(gotKeys, findingKey(f.RuleID, f.RuleVersion, string(f.Severity))) + } + wantKeys := make([]string, 0, len(gc.ExpectedFindings)) + for _, ef := range gc.ExpectedFindings { + wantKeys = append(wantKeys, findingKey(ef.RuleID, ef.RuleVersion, ef.Severity)) + } + sort.Strings(gotKeys) + sort.Strings(wantKeys) + + if !equalStrings(gotKeys, wantKeys) { + t.Fatalf("%s: got findings %v, want %v", gc.Name, gotKeys, wantKeys) + } + }) + } + + if found == 0 { + t.Fatal("no golden cases found in " + goldenDir) + } +} + +func findingKey(ruleID string, version int, severity string) string { + return ruleID + "/" + strconv.Itoa(version) + "/" + severity +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/rules/loader.go b/internal/rules/loader.go new file mode 100644 index 0000000..7b80a86 --- /dev/null +++ b/internal/rules/loader.go @@ -0,0 +1,49 @@ +package rules + +import ( + "fmt" + "io/fs" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Load liest alle *.yaml-Dateien aus fsys (nicht rekursiv) und parst sie +// als Regeln. Ein Fehler in einer Datei (Parse-Fehler, fehlende ID/ +// Version) bricht das Laden komplett ab, statt die Datei stillschweigend +// zu überspringen — ein halb geladenes Regelwerk ist gefährlicher als +// ein Start, der mit einem klaren Fehler abbricht. +func Load(fsys fs.FS) ([]Rule, error) { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil, fmt.Errorf("rules: read dir: %w", err) + } + + seen := make(map[string]bool) + var result []Rule + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { + continue + } + data, err := fs.ReadFile(fsys, entry.Name()) + if err != nil { + return nil, fmt.Errorf("rules: read %s: %w", entry.Name(), err) + } + var r Rule + if err := yaml.Unmarshal(data, &r); err != nil { + return nil, fmt.Errorf("rules: parse %s: %w", entry.Name(), err) + } + if r.ID == "" { + return nil, fmt.Errorf("rules: %s: missing id", entry.Name()) + } + if r.Version == 0 { + return nil, fmt.Errorf("rules: %s: missing version", entry.Name()) + } + if seen[r.ID] { + return nil, fmt.Errorf("rules: %s: duplicate rule id %s", entry.Name(), r.ID) + } + seen[r.ID] = true + result = append(result, r) + } + return result, nil +} diff --git a/internal/rules/rule.go b/internal/rules/rule.go new file mode 100644 index 0000000..e7f7401 --- /dev/null +++ b/internal/rules/rule.go @@ -0,0 +1,54 @@ +package rules + +// Severity ist die Schwere eines Findings. +type Severity string + +const ( + SeverityLow Severity = "niedrig" + SeverityMedium Severity = "mittel" + SeverityHigh Severity = "hoch" +) + +// Rule ist eine versionierte Regel aus einer YAML-Datei in rules/. +// Regel-IDs werden nie umbenannt oder wiederverwendet — Änderungen an +// einer Regel erhöhen die Version. +type Rule struct { + ID string `yaml:"id"` + Version int `yaml:"version"` + Title string `yaml:"titel"` + Condition Condition `yaml:"bedingung"` + Severity Severity `yaml:"schwere"` + Sources []string `yaml:"fundstelle"` + Fix string `yaml:"korrektur"` +} + +// Condition ist eine flache UND-Bedingung über Facts. Ein nil/leeres +// Feld bedeutet "keine Einschränkung durch dieses Feld". +type Condition struct { + Consideration []Consideration `yaml:"gegenleistung,omitempty"` + DisclosurePresent *bool `yaml:"kennzeichnung_vorhanden,omitempty"` + DisclosureBeforeCut *bool `yaml:"kennzeichnung_vor_kuerzung,omitempty"` +} + +// Matches prüft, ob f alle gesetzten Bedingungsfelder erfüllt. +func (c Condition) Matches(f Facts) bool { + if len(c.Consideration) > 0 { + found := false + for _, allowed := range c.Consideration { + if allowed == f.Consideration { + found = true + break + } + } + if !found { + return false + } + } + if c.DisclosurePresent != nil && *c.DisclosurePresent != f.DisclosurePresent { + return false + } + if c.DisclosureBeforeCut != nil && *c.DisclosureBeforeCut != f.DisclosureBeforeCut { + return false + } + return true +} diff --git a/internal/rules/rule_test.go b/internal/rules/rule_test.go new file mode 100644 index 0000000..4c158c1 --- /dev/null +++ b/internal/rules/rule_test.go @@ -0,0 +1,105 @@ +package rules_test + +import ( + "testing" + "testing/fstest" + + "github.com/netcell-it/deklarix/internal/rules" +) + +func TestLoadRejectsMissingID(t *testing.T) { + fsys := fstest.MapFS{ + "bad.yaml": &fstest.MapFile{Data: []byte("version: 1\ntitel: x\nschwere: hoch\n")}, + } + if _, err := rules.Load(fsys); err == nil { + t.Fatal("expected error for rule without id, got nil") + } +} + +func TestLoadRejectsMissingVersion(t *testing.T) { + fsys := fstest.MapFS{ + "bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\ntitel: x\nschwere: hoch\n")}, + } + if _, err := rules.Load(fsys); err == nil { + t.Fatal("expected error for rule without version, got nil") + } +} + +func TestLoadRejectsDuplicateID(t *testing.T) { + fsys := fstest.MapFS{ + "a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")}, + "b.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 2\ntitel: y\nschwere: hoch\n")}, + } + if _, err := rules.Load(fsys); err == nil { + t.Fatal("expected error for duplicate rule id, got nil") + } +} + +func TestLoadIgnoresNonYAMLFiles(t *testing.T) { + fsys := fstest.MapFS{ + "a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")}, + "README.md": &fstest.MapFile{Data: []byte("not a rule")}, + } + got, err := rules.Load(fsys) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 rule, got %d", len(got)) + } +} + +func TestConditionMatches(t *testing.T) { + yes := true + no := false + + cases := []struct { + name string + cond rules.Condition + fact rules.Facts + want bool + }{ + { + name: "consideration list matches", + cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}}, + fact: rules.Facts{Consideration: rules.ConsiderationPaid}, + want: true, + }, + { + name: "consideration list does not match", + cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}}, + fact: rules.Facts{Consideration: rules.ConsiderationNone}, + want: false, + }, + { + name: "disclosure present must match", + cond: rules.Condition{DisclosurePresent: &no}, + fact: rules.Facts{DisclosurePresent: true}, + want: false, + }, + { + name: "unset fields impose no constraint", + cond: rules.Condition{}, + fact: rules.Facts{Consideration: rules.ConsiderationUnclear}, + want: true, + }, + { + name: "all constraints must hold (AND)", + cond: rules.Condition{ + Consideration: []rules.Consideration{rules.ConsiderationPaid}, + DisclosurePresent: &yes, + DisclosureBeforeCut: &no, + }, + fact: rules.Facts{Consideration: rules.ConsiderationPaid, DisclosurePresent: true, DisclosureBeforeCut: false}, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.cond.Matches(tc.fact); got != tc.want { + t.Fatalf("Matches() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/rules/OPEN.md b/rules/OPEN.md new file mode 100644 index 0000000..62e31f1 --- /dev/null +++ b/rules/OPEN.md @@ -0,0 +1,62 @@ +# Offene Rechtsfragen + +Fragen, bei denen sich eine Regel nicht auf eine handgeprüfte, +eindeutig passende Fundstelle stützen lässt, ohne zu raten. Werden hier +notiert statt in einer Regel geraten beantwortet zu werden (siehe +CLAUDE.md, Arbeitsweise für Claude Code). + +## WK-004 — Fundstelle für "Kennzeichnung muss vor Kürzung sichtbar sein" + +**Stand 2026-08-27, zweite Recherche:** Es gibt jetzt eine einschlägige +Entscheidung: **LG Köln, Urteil v. 12.05.2026** — Werbekennzeichnung muss +bereits im Vorschaubild/Grid erkennbar sein, wenn der kommerzielle Zweck +dort nicht auf den ersten Blick ersichtlich ist; eine erst in der +Caption oder erst im geöffneten Beitrag sichtbare Kennzeichnung kommt zu +spät. Das deckt sich direkt mit WK-004 und ist jetzt als Fundstelle in +der Regel hinterlegt. Zusätzlich bestätigt: OLG Celle hat ein bloßes +„#ad" als unzureichend zurückgewiesen, das Kammergericht Berlin ebenso +„#sponsoredby" — beides stützt die in WK-001/WK-004 empfohlene +Kennzeichnung mit „Werbung"/„Anzeige". + +**Weiterhin offen:** Das LG-Köln-Urteil ist laut Quellenlage **noch +nicht rechtskräftig** (Stand der Recherche). Vor Kundeneinsatz prüfen, +ob es zwischenzeitlich in Berufung bestätigt oder aufgehoben wurde, und +ob es Aussagen zur reinen Caption-Kürzung (statt Vorschaubild) trifft — +das Urteil betont primär das Vorschaubild/Grid, WK-004 bewertet aktuell +die Caption-Kürzung. Beides hängt zusammen (wenn schon das Vorschaubild +fehlt, ist die Caption-Kürzung ohnehin nachrangig), sollte aber sauber +auseinandergehalten werden — evtl. braucht es eine eigene Regel für +"keine Kennzeichnung im Vorschaubild/ersten Frame", statt beides in +WK-004 zu vermischen. + +## Ausnahme "kommerzieller Zweck ohnehin erkennbar" (BGH I ZR 90/20 u. a.) + +**Stand 2026-08-27, zweite Recherche — deutlich präzisiert:** Der BGH +unterscheidet zwei Fälle: + +1. **Eigenwerbung** (Influencer bewirbt das eigene Unternehmen/die eigene + Marke): Bei kommerziell genutzten Accounts wissen Follower laut BGH + "aus den Umständen", dass es sich um Eigenwerbung handelt — hier kann + die Kennzeichnungspflicht entfallen. Als Kriterium diente u. a. die + Followerzahl (im konkreten Fall bei 600.000 bzw. 1,7 Mio. Followern + bejaht), aber der BGH hat **keine klare Grenze** gezogen. Das ist zu + vage für eine objektive, extrahierbare Bedingung — eine Regel, die + auf "Followerzahl > X" abstellt, würde eine Grenze erfinden, die es + so nicht gibt. Bleibt vorerst nicht automatisiert. + +2. **Fremdwerbung** (Influencer bewirbt Produkte/Marken Dritter): Hier + ist die neue, klar extrahierbare Erkenntnis: **Ein Link auf die + Website des Herstellers/der Marke begründet laut BGH regelmäßig einen + "werblichen Überschuss"** — die Kennzeichnungspflicht lebt dann + unabhängig davon auf, ob der Post sonst wie Eigenwerbung wirkt. Das + ist ein objektives Extraktionsfeld (Link vorhanden ja/nein, Linkziel + Hersteller/Marke ja/nein), kein Werturteil — passt zum Kernprinzip + und ist ein guter Kandidat für eine kommende Regel WK-002 + ("Verlinkung auf Fremdprodukt ohne Kennzeichnung"), sobald das + Extraktionsschema `verlinkungen`/`affiliate_link` echte Daten liefert. + +**Ergebnis:** Die "ohnehin erkennbar"-Ausnahme bleibt bewusst +unautomatisiert (Kriterium zu vage, siehe oben) — WK-001/WK-004 lösen in +diesem seltenen Fall weiterhin ein potenzielles False Positive aus. Das +ist der sicherere Fehler in diese Richtung (lieber eine unnötige +Rückfrage an den Nutzer als eine übersehene Kennzeichnungspflicht). diff --git a/rules/WK-001.yaml b/rules/WK-001.yaml new file mode 100644 index 0000000..88cebd1 --- /dev/null +++ b/rules/WK-001.yaml @@ -0,0 +1,15 @@ +id: WK-001 +version: 1 +titel: Keine Kennzeichnung trotz Gegenleistung +bedingung: + gegenleistung: [bezahlt, sachbezug] + kennzeichnung_vorhanden: false +schwere: hoch +fundstelle: + - "§ 5a Abs. 4 UWG" + - "§ 22 Abs. 1 MStV" +korrektur: > + Beitrag mit "Werbung" oder "Anzeige" kennzeichnen. Die bloße + plattform-eigene Kennzeichnung (z. B. Instagrams "Bezahlte + Partnerschaft") reicht laut Leitfaden der Medienanstalten nicht als + alleinige Kennzeichnung aus. diff --git a/rules/WK-004.yaml b/rules/WK-004.yaml new file mode 100644 index 0000000..732e17d --- /dev/null +++ b/rules/WK-004.yaml @@ -0,0 +1,16 @@ +id: WK-004 +version: 1 +titel: Kennzeichnung nicht auf den ersten Blick erkennbar (Kürzung durch "mehr anzeigen") +bedingung: + gegenleistung: [bezahlt, sachbezug] + kennzeichnung_vorhanden: true + kennzeichnung_vor_kuerzung: false +schwere: hoch +fundstelle: + - "§ 5a Abs. 4 UWG" + - "Leitfaden der Medienanstalten: Werbekennzeichnung bei Online-Medien" + - "LG Köln, Urteil v. 12.05.2026 (noch nicht rechtskräftig) — Werbekennzeichnung muss bereits im Vorschaubild/Grid erkennbar sein, eine erst in der Caption versteckte Kennzeichnung kommt zu spät" +korrektur: > + Kennzeichnung ("Werbung" oder "Anzeige") an den Anfang der Caption + setzen, sodass sie sichtbar ist, bevor Instagram/TikTok den Text + hinter "mehr anzeigen" kürzt. diff --git a/testdata/golden/organic-no-consideration.json b/testdata/golden/organic-no-consideration.json new file mode 100644 index 0000000..b2db8a8 --- /dev/null +++ b/testdata/golden/organic-no-consideration.json @@ -0,0 +1,11 @@ +{ + "name": "Organischer Post ohne Gegenleistung, keine Kennzeichnung noetig", + "facts": { + "platform": "instagram", + "consideration": "keine", + "disclosure_present": false, + "disclosure_wording": "", + "disclosure_before_cut": false + }, + "expected_findings": [] +} diff --git a/testdata/golden/paid-disclosure-hidden-by-cut.json b/testdata/golden/paid-disclosure-hidden-by-cut.json new file mode 100644 index 0000000..f1de2ea --- /dev/null +++ b/testdata/golden/paid-disclosure-hidden-by-cut.json @@ -0,0 +1,13 @@ +{ + "name": "Bezahlter Post, Kennzeichnung vorhanden aber hinter 'mehr anzeigen' versteckt", + "facts": { + "platform": "instagram", + "consideration": "bezahlt", + "disclosure_present": true, + "disclosure_wording": "Werbung", + "disclosure_before_cut": false + }, + "expected_findings": [ + { "rule_id": "WK-004", "rule_version": 1, "severity": "hoch" } + ] +} diff --git a/testdata/golden/paid-disclosure-visible.json b/testdata/golden/paid-disclosure-visible.json new file mode 100644 index 0000000..d894d97 --- /dev/null +++ b/testdata/golden/paid-disclosure-visible.json @@ -0,0 +1,11 @@ +{ + "name": "Bezahlter Post, Kennzeichnung korrekt vor der Kürzung sichtbar", + "facts": { + "platform": "tiktok", + "consideration": "sachbezug", + "disclosure_present": true, + "disclosure_wording": "Werbung", + "disclosure_before_cut": true + }, + "expected_findings": [] +} diff --git a/testdata/golden/paid-no-disclosure.json b/testdata/golden/paid-no-disclosure.json new file mode 100644 index 0000000..35f07ac --- /dev/null +++ b/testdata/golden/paid-no-disclosure.json @@ -0,0 +1,13 @@ +{ + "name": "Bezahlter Post ohne jede Kennzeichnung", + "facts": { + "platform": "instagram", + "consideration": "bezahlt", + "disclosure_present": false, + "disclosure_wording": "", + "disclosure_before_cut": false + }, + "expected_findings": [ + { "rule_id": "WK-001", "rule_version": 1, "severity": "hoch" } + ] +}