diff --git a/internal/validate/v02.go b/internal/validate/v02.go index 4ed856f..7b133ff 100644 --- a/internal/validate/v02.go +++ b/internal/validate/v02.go @@ -106,9 +106,47 @@ var ( citationsHeading = regexp.MustCompile(`(?mi)^#{1,6}\s+Citations\s*$`) // footnoteRef matches inline footnote references [^label] (not definitions). footnoteRef = regexp.MustCompile(`\[\^([^\]\s]+)\](?::)?`) - isoDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + // footnoteMark matches any footnote marker [^label], reference or + // definition; footnoteLabels classifies each occurrence by position. + footnoteMark = regexp.MustCompile(`\[\^([^\]\s]+)\]`) + isoDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) ) +// footnoteLabels splits a body's footnote markers into references and +// definitions, each deduplicated in first-occurrence order so findings are +// emitted deterministically. A definition is `[^label]:` at the start of a +// line; every other `[^label]` occurrence, including one that happens to be +// followed by a literal colon mid-line, is a reference (OKF §5.1). +func footnoteLabels(body string) (refs, defs []string) { + seenRef := make(map[string]bool) + seenDef := make(map[string]bool) + for _, m := range footnoteMark.FindAllStringSubmatchIndex(body, -1) { + start, end, labelStart, labelEnd := m[0], m[1], m[2], m[3] + label := body[labelStart:labelEnd] + atLineStart := start == 0 || body[start-1] == '\n' + followedByColon := end < len(body) && body[end] == ':' + if atLineStart && followedByColon { + if !seenDef[label] { + seenDef[label] = true + defs = append(defs, label) + } + } else if !seenRef[label] { + seenRef[label] = true + refs = append(refs, label) + } + } + return refs, defs +} + +// labelSet converts a label list to a membership set. +func labelSet(labels []string) map[string]bool { + set := make(map[string]bool, len(labels)) + for _, l := range labels { + set[l] = true + } + return set +} + // validateSources checks the §5.1 provenance family: resource is REQUIRED // within an entry, usage_count needs a framing usage_window, and body // footnote labels must join into sources[].id. @@ -129,6 +167,25 @@ func validateSources(r *Report, c *concept.Concept) { } } + // Footnote definitions: a reference with no definition renders as a + // dangling marker, and a definition with no reference renders as + // nothing, silently dropping a source that reads as cited. Both are + // warnings regardless of whether the label also joins into sources[].id. + refs, defs := footnoteLabels(c.Body) + refSet, defSet := labelSet(refs), labelSet(defs) + for _, label := range refs { + if !defSet[label] { + r.add(c.ID, SeverityWarning, fmt.Sprintf( + "body: footnote [^%s] is referenced but never defined - renders as a dangling marker (OKF §5.1)", label)) + } + } + for _, label := range defs { + if !refSet[label] { + r.add(c.ID, SeverityWarning, fmt.Sprintf( + "body: footnote [^%s] is defined but never referenced - renders as nothing, so a source that reads as cited is absent from the output (OKF §5.1)", label)) + } + } + // Per-claim attribution: footnote labels are join keys into sources[].id. // Only meaningful when the concept declares source ids at all. if len(ids) == 0 { diff --git a/internal/validate/v02_test.go b/internal/validate/v02_test.go index db36fa6..fea1196 100644 --- a/internal/validate/v02_test.go +++ b/internal/validate/v02_test.go @@ -160,6 +160,64 @@ func TestValidate_FootnoteLabelMatchingSourceIDOK(t *testing.T) { mustNotFinding(t, Validate(b), "rev-policy") } +// A footnote referenced in the body but never defined renders as a dangling +// marker (§5.1): warn, don't fail validate. +func TestValidate_FootnoteReferencedButNotDefinedWarns(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\n---\n\nA claim.[^orphan]\n", + }) + r := Validate(b) + mustFinding(t, r, SeverityWarning, "footnote [^orphan] is referenced but never defined") + if r.HasErrors() { + t.Fatalf("unexpected errors: %+v", r.Findings) + } +} + +// A footnote defined but never referenced renders as nothing (§5.1): +// the source silently drops out of the rendered document. Warn, don't fail. +func TestValidate_FootnoteDefinedButNotReferencedWarns(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\n---\n\nprose with no footnote marks.\n\n[^unused]: something\n", + }) + r := Validate(b) + mustFinding(t, r, SeverityWarning, "footnote [^unused] is defined but never referenced") + if r.HasErrors() { + t.Fatalf("unexpected errors: %+v", r.Findings) + } +} + +// A label that is defined and IS present in sources[].id, but never +// referenced in the body, still warns as unreferenced: the sources[].id join +// check and the definition/reference check are independent concerns. +func TestValidate_FootnoteDefinedInSourcesButNotReferencedStillWarns(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\nsources:\n - id: rev-policy\n resource: https://example.com\n---\n\nprose with no footnote marks.\n\n[^rev-policy]: Revenue recognition policy\n", + }) + mustFinding(t, Validate(b), SeverityWarning, "footnote [^rev-policy] is defined but never referenced") +} + +// A label both defined and referenced produces neither new warning, whether +// or not it also joins into sources[].id. +func TestValidate_FootnoteDefinedAndReferencedNoWarning(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\n---\n\nA claim.[^n1]\n\n[^n1]: a note\n", + }) + r := Validate(b) + mustNotFinding(t, r, "is referenced but never defined") + mustNotFinding(t, r, "is defined but never referenced") +} + +// A body with no footnote marks at all produces no footnote definition +// findings. +func TestValidate_NoFootnotesNoDefinitionFindings(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": okConcept, + }) + r := Validate(b) + mustNotFinding(t, r, "is referenced but never defined") + mustNotFinding(t, r, "is defined but never referenced") +} + // --- §10 attested computations --- func TestValidate_AttestedComputationRequiresRuntime(t *testing.T) {