chore: add docs inline-HTML linter and backtick generated placeholders (#27399)

## What

Adds CI enforcement that fails when docs Markdown contains invalid
inline HTML
the docs site silently drops or mangles, and fixes the remaining
generated-doc
placeholders at their source.

This is the tooling half of the docs-HTML audit. The hand-written fixes
it
guards landed in #27298 (kept small and separate so it reviewed fast);
this PR
carries everything that touches code, CI, or generated output.

## Changes

**Linter (`scripts/docshtmlcheck`), wired into `make lint` via
`lint/docs-html`.**
Markdown-aware: parses each file with goldmark and inspects only
raw-HTML nodes,
so angle brackets in fenced code blocks, inline code, HTML comments, and
`<https://…>` / `<user@host>` autolinks are ignored. Flags swallowed
placeholders (`<region>`), void-element end tags (`</br>`), unregistered
or
incorrectly capitalized component tags (`<Image>`), and unclosed
container tags (a
`<div class="tabs">` that leaks its wrapper). The one intentional
renderer
component, `<children>`, is allowed but still balance-checked.

**Generator-source placeholder fixes (regenerated via `make gen`).**
- `codersdk/chats.go`: backtick `<server>__` in the
`ChatContextTool.Name` doc
  comment (it becomes the Swagger description, so it was swallowed in
  `reference/api/{chats,schemas}.md`).
- `codersdk/deployment.go`: backtick `<region>` in the AWS Bedrock
region flag
help (swallowed in `reference/cli/server.md`); also updates `coder
server
  --help` output and the golden files.

**Temporary allowlist.** `docs/reference/cli/agent-firewall.md`'s
`<host>` /
`<glob>` come from the external `github.com/coder/boundary` CLI help
(still
`v0.10.0` on `main`), so they are suppressed on that one file. The
suppression
is self-clearing: if an allowlisted tag stops appearing on a scanned
file, the
linter reports `stale-allowlist-entry` and fails until the dead entry is
removed, so a dead entry cannot silently mask a later regression of that
tag on
that page. (An entry whose file is deleted outright is never rescanned,
but a
missing file yields no findings, so nothing hides behind it either.)

## Review feedback addressed

This tool + generator work was reviewed by Coder Agents Review while it
was
bundled into #27298. Addressed here:

- **P1:** tokenize each raw-HTML node as a whole instead of per source
line, so
a tag whose attributes wrap across lines is no longer torn in half. This
fixes
both the missed multi-line unclosed `<div>` (a leaked wrapper that
passed with
exit 0) and the spurious `stray-end-tag` on valid multi-line tags. Each
token
  maps back to its own source line.
- Normalize allowlist lookup/report paths to a canonical repo-relative
form, so
the escape hatch no longer silently misses under absolute / `./` paths.
- Route generated-page findings to the generator source.
- Add `<search>` to the allowed set; reword the unknown-element message
to note
  that a real element can be added to `allowedElements`.
- Self-clearing allowlist guard (above); rename `optionalEndTag(s)` and
`kindUnclosed(Tag)`; adopt `slices`/`maps` idioms; move the lint banner
to the
  Makefile recipe; stop aliasing the input slice in `filterAllowed`.
- New tests: multi-line tokenization (both classes), interleaved
nesting, a
  pinned line number, `collectMarkdown`, and the stale-allowlist guard.

### Round 2 (Coder Agents Review on this PR)

A second `/coder-agents-review` pass on this PR raised 16 findings;
addressed in
`fix(docshtmlcheck): catch self-closing containers and capitalized
tags`:

- **P2:** self-closing container tags (`<div class="tabs"/>`) were
ignored by
the HTML5 parser and leaked their wrapper like the open spelling; the
balance
  check now tracks self-closing tokens too (CRF-1).
- **P2:** a capitalized component tag whose lowercase name is a real
element
(`<Table>`, `<Section>`) slipped through on the `allowedElements`
lookup. The
tokenizer lowercases tag names, so the check now reads the raw token and
  reports any capitalized name as a component reference (CRF-2).
- Narrowed the `:` / `@` autolink skip to a real URI scheme or a dotted
`local@domain`, so `<region:id>` and `<user@host>` stay checked (CRF-3).
- Stale-allowlist findings now report against the linter source with no
line,
and count separately from invalid-HTML issues in the footer (CRF-7,
CRF-11).
- Comment / README / Makefile wording synced to the honest
capitalized-tag
  behavior; added the deleted-file allowlist caveat and a note that
`allowedElements` is hand-maintained against the renderer (CRF-14,
CRF-17,
  CRF-9).
- Internal cleanups (`pop` -> `matchEndTag`, extracted
`unclosedFinding`) and
new tests: self-closing, capitalized open/close, colon/at placeholders,
a
non-first-token line assertion, `isGeneratedDoc`, and the stale message
  (CRF-12, CRF-13, CRF-1/2/3/4/5/16).

Two findings resolved without a code change:

- **CRF-8** (also wire `lint/docs-html` into `lint-light`): declined.
  `lint-light` is the Go-free fast path; `lint/docs-html` needs the Go
toolchain, so it stays in the full `make lint`, which CI runs. Adding it
would
  pull Go into the light path for no coverage gain.
- **CRF-9** (`allowedElements` <-> renderer coupling): documented with a
maintenance note in the `allowedElements` comment and tracked in
DOCS-597 for
  a cross-repo sync/check decision.

Deferred (note, no current trigger): raw-text element interiors
(`<script>` / `<style>`) are not scanned for nested tags. No docs page
relies
on this today; noted for follow-up.

## Merge order

#27298 (the hand-written fixes this PR guards) has merged, and this
branch is
rebased on `main`, so `make lint/docs-html` now reports 0 findings and
the
`lint` check passes. The two PRs are independent (disjoint files, no
stacking).

## Verification

- `go test ./scripts/docshtmlcheck/`, `go vet`, `gofmt -l`,
`golangci-lint run`: clean.
- `make lint/docs-html` (branch rebased on `main`): 0 findings.

## Linear

- DOCS-584:
https://linear.app/codercom/issue/DOCS-584/add-ci-check-that-fails-on-invalid-inline-html-in-docs
- DOCS-551:
https://linear.app/codercom/issue/DOCS-551/backtick-placeholder-syntax-in-generated-reference-docs-cli-help
- DOCS-597 (follow-up, from CRF-9):
https://linear.app/codercom/issue/DOCS-597/track-docshtmlcheck-allowedelements-drift-vs-docs-renderer-component

> This PR was created with AI assistance (Coder Agents).
This commit is contained in:
Nick Vigilante
2026-08-05 14:45:45 -04:00
committed by GitHub
parent db68c6c9fe
commit 9dcb75cd56
15 changed files with 1241 additions and 14 deletions
+72
View File
@@ -0,0 +1,72 @@
# docshtmlcheck
`docshtmlcheck` fails CI when Markdown under `docs/` contains invalid inline
HTML that the documentation site's renderer silently drops or mangles. It runs
as `make lint/docs-html` (part of `make lint`).
## What it catches
- **Swallowed angle-bracket placeholders.** An unwrapped placeholder such as
`<region>` or `<server>__` is parsed as an unknown HTML tag and stripped from
the rendered page, so readers see broken text. Wrap placeholders in backticks
so they render as inline code (see
[`docs/about/contributing/documentation.md`](../../docs/about/contributing/documentation.md#placeholders-in-angle-brackets)).
This also covers CLI `--help` strings and Swagger annotations, whose text is
generated into `docs/reference/**`.
- **Void-element end tags** such as `</br>`. Void elements like `<br>`, `<img>`,
and `<hr>` have no end tag.
- **Capitalized or unregistered component tags** such as `<Image>` or `<Table>`.
The docs renderer reads a capitalized tag as a component reference and drops
it unless the component is registered (only the lowercase `<children>`
directive is). Any name outside the standard HTML5 element set is reported the
same way.
- **Unclosed container tags**, for example a `<div class="tabs">` that is never
closed and leaks its wrapper over the rest of the page.
## How it works
Each file is parsed with [goldmark](https://github.com/yuin/goldmark) and only
raw-HTML nodes are inspected, so angle brackets inside fenced code blocks,
inline code spans, HTML comments, and `<https://…>` / `<user@host>` autolinks
are ignored. Each raw-HTML node is tokenized as a whole with
`golang.org/x/net/html`, so a tag whose attributes wrap across lines is not
torn in half. A tag whose raw name is capitalized is reported as a component
reference; otherwise any name outside the standard HTML5 element set (plus the
intentional `<children>` renderer component, which is still balance-checked) is
reported. Inline SVG and MathML are intentionally **not** in the allowed set (no
docs page uses them); add the element to `allowedElements` in `main.go` if that
changes. A finding on a generated page under `docs/reference/**` also prints a
note pointing at the generator source, since edits to the generated file do not
persist.
## Limitations
A few gaps are accepted because no docs page hits them today:
- A placeholder whose name is itself a real HTML element (`<input>`, `<time>`)
is indistinguishable from intended markup and passes. Such placeholders
almost always live in fenced code blocks, which are ignored.
- The interior of a raw-text element (`<script>`, `<style>`) is a single opaque
token to the HTML tokenizer, so a tag nested inside one is not scanned. An
unclosed `<script>`/`<style>` is still caught.
## Usage
```console
$ go run ./scripts/docshtmlcheck # scans docs/
$ go run ./scripts/docshtmlcheck path/to/file.md path/to/dir
```
## Allowlist
`allowedUnknownTags` in `main.go` is a deliberately narrow, per-file escape
hatch for placeholders whose source is outside this repository (so they cannot
be fixed by a source edit here). It currently holds a temporary entry for
`docs/reference/cli/agent-firewall.md` (`<host>`/`<glob>`, generated from the
external `github.com/coder/boundary` CLI help).
The escape hatch is **self-clearing**: if an allowlisted tag no longer appears
in its file (for example once the upstream fix and dependency bump land and the
generated page no longer emits the bare placeholders), the linter reports a
`stale-allowlist-entry` and fails until the dead entry is removed, so a
suppression can never silently mask a later regression of the same tag.
+649
View File
@@ -0,0 +1,649 @@
// Command docshtmlcheck fails when Markdown files under docs/ contain invalid
// inline HTML that the documentation site's HTML renderer silently drops or
// mangles.
//
// It exists to prevent regressions of two classes of bug that were fixed by a
// manual audit of the docs:
//
// - Swallowed angle-bracket placeholders. An unwrapped placeholder such as
// <region> or <server>__ is parsed as an unknown HTML tag and stripped
// from the rendered page, so readers see broken text. Placeholders must be
// wrapped in backticks (see docs/about/contributing/documentation.md).
// This also covers CLI --help strings and Swagger annotations, whose text
// is generated into docs/reference/**.
// - Structurally invalid or unregistered HTML: end tags for void elements
// (</br>); tag names outside the standard HTML5 element set; capitalized
// tags such as <Image> or <Table>, which the docs renderer reads as
// component references and drops when unregistered (only <children> is
// registered); and unclosed container tags (a <div class="tabs"> that is
// never closed and leaks its wrapper over the rest of the page).
//
// Detection is Markdown-aware: the file is parsed with goldmark and only raw
// HTML nodes are inspected, so angle brackets inside fenced code blocks, inline
// code spans, HTML comments, and <https://...> or <user@host> autolinks are
// ignored.
//
// Known limitations (accepted; no docs page hits either today): a placeholder
// whose name is itself a real element (<input>, <time>) is indistinguishable
// from intended markup and passes; and the interior of a raw-text element
// (<script>, <style>) is one opaque text token to the tokenizer, so a tag
// nested inside is not scanned, though an unclosed <script>/<style> is still
// caught.
//
// Usage:
//
// docshtmlcheck [path ...]
//
// With no arguments it scans docs/. Arguments may be files or directories.
package main
import (
"bytes"
"cmp"
"fmt"
"io"
"io/fs"
"maps"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
"golang.org/x/net/html"
)
// voidElements are HTML elements that never have an end tag. An end tag for any
// of these (e.g. </br>) is invalid.
var voidElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "link": true, "meta": true,
"param": true, "source": true, "track": true, "wbr": true,
}
// allowedElements is the set of tag names permitted in docs Markdown: the
// standard HTML5 element set plus "children", the one intentional renderer
// component (a child-page card grid with no HTML equivalent). Lookups are
// lowercase; a tag whose raw name is capitalized is treated as a component
// reference and reported by scanNode before this set is consulted, so only
// lowercase element names belong here. Any name outside the set is treated as
// a swallowed placeholder or an unregistered component and is reported. Inline
// SVG and MathML are intentionally out of scope (no docs page uses them); add
// the element here if that changes.
//
// Maintenance: this set is hand-maintained to mirror the tags the docs
// renderer actually accepts. It is not generated from the renderer, so a
// renderer change that adds or removes an accepted tag is not reflected here
// automatically and the two can drift until this map is updated by hand. Keep
// them in sync whenever the renderer's accepted set changes.
//
// Known gap: a placeholder whose name is itself a real element (<input>,
// <output>, <time>) is indistinguishable from intended markup and passes.
// Such placeholders almost always live in fenced code blocks, which are
// ignored, so the gap is narrow in practice.
var allowedElements = map[string]bool{
// Standard HTML5 elements.
"a": true, "abbr": true, "address": true, "area": true, "article": true,
"aside": true, "audio": true, "b": true, "base": true, "bdi": true,
"bdo": true, "blockquote": true, "body": true, "br": true, "button": true,
"canvas": true, "caption": true, "cite": true, "code": true, "col": true,
"colgroup": true, "data": true, "datalist": true, "dd": true, "del": true,
"details": true, "dfn": true, "dialog": true, "div": true, "dl": true,
"dt": true, "em": true, "embed": true, "fieldset": true, "figcaption": true,
"figure": true, "footer": true, "form": true, "h1": true, "h2": true,
"h3": true, "h4": true, "h5": true, "h6": true, "head": true, "header": true,
"hgroup": true, "hr": true, "html": true, "i": true, "iframe": true,
"img": true, "input": true, "ins": true, "kbd": true, "label": true,
"legend": true, "li": true, "link": true, "main": true, "map": true,
"mark": true, "menu": true, "meta": true, "meter": true, "nav": true,
"noscript": true, "object": true, "ol": true, "optgroup": true,
"option": true, "output": true, "p": true, "param": true, "picture": true,
"pre": true, "progress": true, "q": true, "rp": true, "rt": true,
"ruby": true, "s": true, "samp": true, "script": true, "search": true,
"section": true, "select": true, "slot": true, "small": true,
"source": true, "span": true, "strong": true, "style": true, "sub": true,
"summary": true, "sup": true, "table": true, "tbody": true, "td": true,
"template": true, "textarea": true, "tfoot": true, "th": true,
"thead": true, "time": true, "title": true, "tr": true, "track": true,
"u": true, "ul": true, "var": true, "video": true, "wbr": true,
// Intentional docs renderer component. Also listed in rendererComponents,
// which drives the self-closing balance rule in scanNode.
"children": true,
}
// rendererComponents are the non-HTML tags the docs renderer accepts as MDX
// components rather than standard HTML elements; they also appear in
// allowedElements. MDX honors the self-closing form on a component, so
// <children /> is complete and is not balance-tracked. A self-closing HTML
// container such as <div/> is different: the HTML5 parser ignores the flag and
// leaves it open, so it stays tracked and is still caught as an unclosed
// wrapper.
var rendererComponents = map[string]bool{
"children": true,
}
// optionalEndTags are elements whose end tag is optional in the HTML5 parsing
// algorithm (a following sibling or the parent's end implicitly closes them).
// Requiring them to be explicitly balanced would produce false positives on
// valid HTML, so they are excluded from the unclosed/mismatch balance check.
// They are still subject to the void-end-tag and unknown-element checks.
var optionalEndTags = map[string]bool{
"li": true, "dd": true, "dt": true, "p": true, "option": true,
"optgroup": true, "td": true, "th": true, "tr": true, "thead": true,
"tbody": true, "tfoot": true, "caption": true, "colgroup": true,
"rt": true, "rp": true,
}
// allowedUnknownTags suppresses specific unknown-element findings on specific
// files. This is a deliberately narrow escape hatch for placeholders whose
// source is outside this repository and therefore cannot be fixed by a source
// edit here.
//
// The escape hatch is self-clearing: filterAllowed emits a stale-allowlist-entry
// finding (failing the build) if an allowlisted tag no longer appears in its
// file, so a dead entry cannot silently mask a future regression of the same
// tag on that page. The guard is per-tag on a scanned file; an entry whose
// file is deleted outright is never rescanned and lingers as harmless dead
// config (a missing file yields no findings, so nothing hides behind it).
//
// Temporary: docs/reference/cli/agent-firewall.md renders <host> and <glob>
// from the --session-id-inject-target help text, which is defined in the
// external github.com/coder/boundary CLI, not in this repo. The stale-entry
// guard removes the need to track removal by hand: once the upstream fix and
// dependency bump land and the generated page no longer contains the bare
// placeholders, the build fails until this entry is deleted.
var allowedUnknownTags = map[string]map[string]bool{
"docs/reference/cli/agent-firewall.md": {"host": true, "glob": true},
}
// docshtmlcheckSource is where allowlist-maintenance findings are reported: the
// fix for a stale entry lives in this file's allowedUnknownTags, not in the
// scanned doc, so pointing at the doc would send the reader to the wrong file.
const docshtmlcheckSource = "scripts/docshtmlcheck/main.go"
type findingKind string
const (
kindUnknownElement findingKind = "unknown-element"
kindVoidEndTag findingKind = "void-end-tag"
kindUnclosedTag findingKind = "unclosed-tag"
kindStrayEndTag findingKind = "stray-end-tag"
kindStaleAllowlist findingKind = "stale-allowlist-entry"
)
type finding struct {
line int
kind findingKind
tag string
msg string
}
func main() {
roots := os.Args[1:]
if len(roots) == 0 {
roots = []string{"docs"}
}
os.Exit(run(roots, os.Stdout, os.Stderr))
}
// run scans roots, writes per-finding lines to stdout and the summary and
// errors to stderr, and returns the process exit code (0 clean, 1 findings,
// 2 I/O error). It is separated from main so tests can drive it with buffers
// and assert the reported locations, the generator-source note routing, and
// the summary footers.
func run(roots []string, stdout, stderr io.Writer) int {
files, err := collectMarkdown(roots)
if err != nil {
_, _ = fmt.Fprintf(stderr, "docshtmlcheck: %v\n", err)
return 2
}
var htmlIssues, staleIssues int
for _, path := range files {
src, err := os.ReadFile(path)
if err != nil {
_, _ = fmt.Fprintf(stderr, "docshtmlcheck: %v\n", err)
return 2
}
h, s := reportFindings(path, filterAllowed(path, checkSource(src)), stdout)
htmlIssues += h
staleIssues += s
}
if htmlIssues > 0 {
_, _ = fmt.Fprintf(stderr, "\ndocshtmlcheck: found %d invalid inline HTML issue(s).\n"+
"Wrap angle-bracket placeholders in backticks so they render as inline code\n"+
"(see docs/about/contributing/documentation.md), fix void-element end tags\n"+
"like </br>, use registered components for custom tags, and close container tags.\n", htmlIssues)
}
if staleIssues > 0 {
_, _ = fmt.Fprintf(stderr, "\ndocshtmlcheck: found %d stale allowlist entry(ies); "+
"remove them from allowedUnknownTags in %s.\n", staleIssues, docshtmlcheckSource)
}
if htmlIssues+staleIssues > 0 {
return 1
}
return 0
}
// reportFindings writes one file's findings to out and returns the count of
// invalid-HTML findings and stale-allowlist findings. A stale-allowlist finding
// is reported against the linter source (its fix lives in allowedUnknownTags,
// not the scanned doc) and with no line. When the file has a real content
// finding, is generated, and is not allowlisted, a trailing note points the
// author at the generator source instead of the throwaway output.
//
// The allowlist skip is file-scoped: a genuinely new invalid tag on an
// allowlisted generated page (for example a future boundary bump emitting
// <Foo> in agent-firewall.md) is still reported, but without the
// generator-source hint. That is acceptable for the single external page.
func reportFindings(path string, findings []finding, out io.Writer) (htmlIssues, staleIssues int) {
realFinding := false
for _, f := range findings {
loc := path
if f.kind == kindStaleAllowlist {
// The fix is in this tool's allowlist, not the scanned doc, so
// report the linter source rather than a misleading docs:1.
loc = docshtmlcheckSource
}
if f.line > 0 {
_, _ = fmt.Fprintf(out, "%s:%d: %s: %s\n", loc, f.line, f.kind, f.msg)
} else {
_, _ = fmt.Fprintf(out, "%s: %s: %s\n", loc, f.kind, f.msg)
}
if f.kind == kindStaleAllowlist {
staleIssues++
} else {
htmlIssues++
realFinding = true
}
}
if realFinding && isGeneratedDoc(path) && allowedUnknownTags[canonicalPath(path)] == nil {
_, _ = fmt.Fprintf(out, "%s: note: this page is generated by `make gen`; fix the source "+
"(codersdk/*.go doc comments, CLI --help text, or swagger annotations) and "+
"regenerate; edits to this file will not persist\n", path)
}
return htmlIssues, staleIssues
}
// collectMarkdown expands the given roots (files or directories) into a sorted
// list of unique .md files in canonical (repo-relative, slash) form.
func collectMarkdown(roots []string) ([]string, error) {
seen := map[string]bool{}
for _, root := range roots {
info, err := os.Stat(root)
if err != nil {
return nil, err
}
if !info.IsDir() {
if strings.HasSuffix(root, ".md") {
seen[canonicalPath(root)] = true
}
continue
}
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(path, ".md") {
seen[canonicalPath(path)] = true
}
return nil
})
if err != nil {
return nil, err
}
}
return slices.Sorted(maps.Keys(seen)), nil
}
// canonicalPath normalizes a path to a clean, slash-separated form, made
// relative to the working directory when absolute. Running the linter from the
// repo root (as CI and `make lint/docs-html` do) yields repo-relative keys such
// as docs/reference/cli/agent-firewall.md regardless of whether the caller
// passed a relative, ./-prefixed, or absolute path, so allowlist lookups and
// reported locations stay consistent.
func canonicalPath(p string) string {
p = filepath.Clean(p)
if filepath.IsAbs(p) {
if wd, err := os.Getwd(); err == nil {
if rel, err := filepath.Rel(wd, p); err == nil {
p = rel
}
}
}
return filepath.ToSlash(p)
}
// isGeneratedDoc reports whether a docs path is produced by `make gen` rather
// than hand-written, so findings can route the author to the generator source.
func isGeneratedDoc(path string) bool {
return strings.HasPrefix(canonicalPath(path), "docs/reference/")
}
// filterAllowed drops unknown-element findings suppressed by allowedUnknownTags
// for the file. It also reports any allowlist entry that suppressed nothing, so
// a stale escape hatch fails the build instead of silently masking a future
// regression of the same tag on that page.
func filterAllowed(path string, findings []finding) []finding {
allowed := allowedUnknownTags[canonicalPath(path)]
if allowed == nil {
return findings
}
out := make([]finding, 0, len(findings))
used := make(map[string]bool, len(allowed))
for _, f := range findings {
if f.kind == kindUnknownElement && allowed[f.tag] {
used[f.tag] = true
continue
}
out = append(out, f)
}
stale := make([]string, 0, len(allowed))
for tag := range allowed {
if !used[tag] {
stale = append(stale, tag)
}
}
slices.Sort(stale)
for _, tag := range stale {
// No line: the fix is in allowedUnknownTags, not at any line of the
// scanned doc. main reports these against the linter source.
out = append(out, finding{
kind: kindStaleAllowlist,
tag: tag,
msg: fmt.Sprintf("allowlist entry <%s> for %s no longer suppresses anything; "+
"remove it from allowedUnknownTags", tag, canonicalPath(path)),
})
}
return out
}
// checkSource parses Markdown and returns findings for invalid inline HTML. It
// inspects only raw HTML nodes, so angle brackets inside code spans, fenced
// code blocks, HTML comments, and autolinks are ignored.
func checkSource(src []byte) []finding {
doc := goldmark.New().Parser().Parse(text.NewReader(src))
c := &checker{src: src, lineStarts: lineStarts(src)}
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.RawHTML:
c.scanNode(segmentsOf(node.Segments))
case *ast.HTMLBlock:
segs := segmentsOf(node.Lines())
if node.HasClosure() {
segs = append(segs, node.ClosureLine)
}
c.scanNode(segs)
}
return ast.WalkContinue, nil
})
// Anything left open at end of file is unclosed.
for _, open := range c.stack {
c.findings = append(c.findings, unclosedFinding(open))
}
slices.SortStableFunc(c.findings, func(a, b finding) int {
return cmp.Compare(a.line, b.line)
})
return c.findings
}
// segmentsOf materializes a *text.Segments into a slice.
func segmentsOf(s *text.Segments) []text.Segment {
out := make([]text.Segment, 0, s.Len())
for i := range s.Len() {
out = append(out, s.At(i))
}
return out
}
// rawTagName extracts the original-case tag name from a raw token. The
// tokenizer's TagName lowercases, which hides whether a tag was capitalized, so
// scanNode reads the name straight from the raw bytes: skip the leading < and
// the / of an end tag, then take everything up to the first whitespace, / or >.
func rawTagName(raw []byte) string {
i := 0
for i < len(raw) && (raw[i] == '<' || raw[i] == '/') {
i++
}
start := i
for i < len(raw) {
switch raw[i] {
case ' ', '\t', '\n', '\r', '\f', '/', '>':
return string(raw[start:i])
}
i++
}
return string(raw[start:i])
}
type openTag struct {
tag string
line int
}
// unclosedFinding builds the finding for a container tag left open. It is the
// single source for the unclosed-tag message, shared by the end-of-file drain
// in checkSource and the dangling-inner-tag loop in matchEndTag.
func unclosedFinding(o openTag) finding {
return finding{
line: o.line,
kind: kindUnclosedTag,
tag: o.tag,
msg: fmt.Sprintf("unclosed <%s> tag", o.tag),
}
}
type checker struct {
src []byte
lineStarts []int
stack []openTag
findings []finding
}
// scanNode tokenizes an entire raw-HTML node at once. It concatenates the
// node's source segments into a single buffer, so a tag whose text wraps
// across lines is tokenized whole instead of being torn in half. It maps each
// token back to its source line. The balance stack persists across nodes, so a
// container opened in one block and closed in another still balances.
func (c *checker) scanNode(segs []text.Segment) {
if len(segs) == 0 {
return
}
// Concatenate the segment text into one buffer, recording where each
// segment lands so a buffer offset can be translated back to a source byte
// offset. Segments are individually contiguous in the source but may be
// separated by gaps (such as the line breaks a block spans).
type span struct {
bufStart, srcStart, length int
}
var buf []byte
spans := make([]span, 0, len(segs))
for _, seg := range segs {
v := seg.Value(c.src)
spans = append(spans, span{bufStart: len(buf), srcStart: seg.Start, length: len(v)})
buf = append(buf, v...)
}
srcOffset := func(bufPos int) int {
for i := len(spans) - 1; i >= 0; i-- {
if bufPos >= spans[i].bufStart {
return spans[i].srcStart + min(bufPos-spans[i].bufStart, spans[i].length)
}
}
return spans[0].srcStart
}
z := html.NewTokenizer(bytes.NewReader(buf))
pos := 0
for {
tt := z.Next()
if tt == html.ErrorToken {
return
}
raw := z.Raw()
line := c.lineAt(srcOffset(pos))
pos += len(raw)
switch tt {
case html.StartTagToken, html.SelfClosingTagToken:
rawName := rawTagName(raw)
name := strings.ToLower(rawName)
if autolinkShaped(name) {
// e.g. <https://coder.com> or <user@coder.com>: valid Markdown
// autolink syntax, never a real HTML element. goldmark only
// classifies these as autolinks in inline context, not inside a
// raw HTML block, so skip them explicitly to avoid false
// positives.
continue
}
if rawName != name {
// A capitalized tag is an MDX/JSX component reference, not an
// HTML element (element names are case-insensitive but
// conventionally lowercase; the docs renderer treats a
// capitalized tag as a component). Report it regardless of
// whether the lowercased name collides with a real element, so
// <Table>, <Section>, and <Image> are all caught.
c.findings = append(c.findings, finding{
line: line,
kind: kindUnknownElement,
tag: name,
msg: fmt.Sprintf("<%s> is a capitalized tag; the docs renderer reads it as a "+
"component reference and drops it unless registered. Use a lowercase HTML "+
"element or a registered component", rawName),
})
continue
}
if !allowedElements[name] {
c.findings = append(c.findings, finding{
line: line,
kind: kindUnknownElement,
tag: name,
msg: fmt.Sprintf("<%s> is not a recognized HTML element or component; wrap a "+
"placeholder in backticks, add a standard HTML element to allowedElements, "+
"or use a registered component", name),
})
continue
}
// Track balance for container elements that need an explicit end
// tag. A self-closing flag on a non-void HTML element (<div/>) is
// ignored by the HTML5 parser and the docs renderer, so it opens a
// container that leaks exactly like <div>; keep tracking it. A
// self-closing renderer component (<children/>) is the exception:
// MDX honors the self-closing form, so it is complete and must not
// be tracked. Void and optional-end-tag elements are never pushed.
if !voidElements[name] && !optionalEndTags[name] {
if tt != html.SelfClosingTagToken || !rendererComponents[name] {
c.stack = append(c.stack, openTag{tag: name, line: line})
}
}
case html.EndTagToken:
rawName := rawTagName(raw)
name := strings.ToLower(rawName)
if autolinkShaped(name) {
continue
}
if rawName != name {
// Closing tag of a capitalized component; its opening tag was
// already reported, and component tags are not balance-tracked.
continue
}
if voidElements[name] {
c.findings = append(c.findings, finding{
line: line,
kind: kindVoidEndTag,
tag: name,
msg: fmt.Sprintf("</%s> is invalid: <%s> is a void element with no end tag", name, name),
})
continue
}
if !allowedElements[name] || optionalEndTags[name] {
// Unknown end tags are reported via their start tag; optional
// end tags are not balance-tracked.
continue
}
c.matchEndTag(name, line)
}
}
}
// matchEndTag resolves an end tag against the balance stack: it pops to the
// matching open tag, emits kindUnclosedTag for any tags left dangling above the
// match, and emits kindStrayEndTag when nothing matches.
func (c *checker) matchEndTag(name string, line int) {
for i := len(c.stack) - 1; i >= 0; i-- {
if c.stack[i].tag == name {
// Tags above the match were left unclosed inside this element.
for j := len(c.stack) - 1; j > i; j-- {
c.findings = append(c.findings, unclosedFinding(c.stack[j]))
}
c.stack = c.stack[:i]
return
}
}
c.findings = append(c.findings, finding{
line: line,
kind: kindStrayEndTag,
tag: name,
msg: fmt.Sprintf("</%s> has no matching opening tag", name),
})
}
// schemeRe matches a URI scheme ending in a colon (e.g. "https:", "mailto:").
// A Markdown autolink inside a raw-HTML block tokenizes with such a name.
var schemeRe = regexp.MustCompile(`^[a-z][a-z0-9+.-]*:$`)
// autolinkShaped reports whether a tokenized tag name is a real Markdown
// autolink rather than an HTML element or a placeholder. goldmark only
// classifies autolinks as such in inline context; inside a raw-HTML block one
// tokenizes as a tag whose name is either a URI scheme ending in a colon
// (<https://coder.com> tokenizes as "https:") or a mail-shaped local@domain
// with a real, dotted domain (<user@coder.com>). A bare colon or at sign is
// not enough, so placeholders the linter targets (<region:id>, <user@host>)
// stay checked.
func autolinkShaped(name string) bool {
if schemeRe.MatchString(name) {
return true
}
at := strings.IndexByte(name, '@')
if at <= 0 || at >= len(name)-1 {
return false
}
domain := name[at+1:]
return !strings.Contains(domain, "@") && strings.Contains(domain, ".")
}
// lineStarts returns the byte offset of the start of each line.
func lineStarts(src []byte) []int {
starts := []int{0}
for i, b := range src {
if b == '\n' {
starts = append(starts, i+1)
}
}
return starts
}
// lineAt returns the 1-based line number for a byte offset.
func (c *checker) lineAt(offset int) int {
// Largest index i such that lineStarts[i] <= offset.
i := sort.Search(len(c.lineStarts), func(i int) bool {
return c.lineStarts[i] > offset
})
if i < 1 {
return 1
}
return i
}
+497
View File
@@ -0,0 +1,497 @@
package main
import (
"io"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestCheckSource(t *testing.T) {
t.Parallel()
cases := []struct {
name string
src string
// want maps each expected finding kind to the tag it concerns. Order
// independent; the test asserts the multiset of (kind, tag) pairs.
want []finding
}{
{
name: "swallowed placeholder in prose",
src: "Use `--region` set to <region> for the endpoint.\n",
want: []finding{{kind: kindUnknownElement, tag: "region"}},
},
{
name: "placeholder inside inline code is ignored",
src: "Constructs `https://bedrock-runtime.<region>.amazonaws.com`.\n",
want: nil,
},
{
name: "placeholder inside fenced code block is ignored",
src: "```\nendpoint: https://host.<region>.example.com\n```\n",
want: nil,
},
{
name: "placeholder inside indented code block is ignored",
src: " literal <region> here\n",
want: nil,
},
{
name: "double-underscore placeholder in prose",
src: "The tool name has the <server>__ prefix stripped.\n",
want: []finding{{kind: kindUnknownElement, tag: "server"}},
},
{
name: "void element end tag",
src: "First line.</br>\nSecond line.\n",
want: []finding{{kind: kindVoidEndTag, tag: "br"}},
},
{
name: "void element self and start tags are fine",
src: "A<br>B<br/>C <img src=\"x.png\"> D\n",
want: nil,
},
{
name: "capitalized component tag",
src: "<Image src=\"x.png\">\n",
want: []finding{{kind: kindUnknownElement, tag: "image"}},
},
{
name: "balanced kbd inline",
src: "Press <kbd>Ctrl</kbd> to continue.\n",
want: nil,
},
{
name: "balanced children component",
src: "<children></children>\n",
want: nil,
},
{
name: "unbalanced children component",
src: "<children>\n\nsome text\n",
want: []finding{{kind: kindUnclosedTag, tag: "children"}},
},
{
name: "unclosed div leaks wrapper",
src: "<div class=\"tabs\">\n\n## Heading\n\ncontent\n",
want: []finding{{kind: kindUnclosedTag, tag: "div"}},
},
{
name: "balanced div block",
src: "<div class=\"tabs\">\n\n## Heading\n\n</div>\n",
want: nil,
},
{
name: "stray end tag",
src: "text</div>\n",
want: []finding{{kind: kindStrayEndTag, tag: "div"}},
},
{
// Regression guard: a start tag whose attributes wrap across lines
// must be tokenized whole, not torn in half. A per-line tokenizer
// silently dropped this unclosed <div> (exit 0), the exact leaked
// wrapper the linter exists to catch.
name: "unclosed div with attributes wrapped across lines",
src: "<div\n class=\"tabs\">\n\n## Heading\n\ncontent\n",
want: []finding{{kind: kindUnclosedTag, tag: "div"}},
},
{
name: "unknown element with attributes wrapped across lines",
src: "See <Image\n src=\"x.png\"> here.\n",
want: []finding{{kind: kindUnknownElement, tag: "image"}},
},
{
// A valid inline tag wrapped across lines must NOT produce a
// spurious stray-end-tag on the closing tag.
name: "balanced kbd wrapped across lines",
src: "Press <kbd\n class=\"key\">Ctrl</kbd> now.\n",
want: nil,
},
{
// Interleaved nesting: the inner tag is left dangling when the outer
// tag closes, exercising matchEndTag's unclosed-reporting loop.
name: "inner tag unclosed when outer closes",
src: "<div>\n\n<span>\n\n</div>\n",
want: []finding{{kind: kindUnclosedTag, tag: "span"}},
},
{
name: "autolink is not raw html",
src: "See <https://coder.com/docs> for details.\n",
want: nil,
},
{
name: "autolink inside a children raw-html block is ignored",
src: "<children>\n This page is rendered on <https://coder.com/docs/tutorials>.\n</children>\n",
want: nil,
},
{
name: "email autolink is not raw html",
src: "Contact <support@coder.com> for help.\n",
want: nil,
},
{
name: "placeholder inside html comment is ignored",
src: "<!-- TODO: document <region> here -->\n",
want: nil,
},
{
name: "optional end tag li is not flagged as unclosed",
src: "<ul>\n<li>one\n<li>two\n</ul>\n",
want: nil,
},
{
name: "table cells with optional end tags are fine",
src: "<table><tr><td>a<td>b</tr></table>\n",
want: nil,
},
{
name: "clean prose with angle-bracket math is not html",
src: "If a < b and b > c then done.\n",
want: nil,
},
{
// A self-closing flag on a non-void HTML container is ignored by
// the HTML5 parser, so <div class="tabs"/> leaks its wrapper
// exactly like the open spelling and must still be caught.
name: "self-closing div still leaks wrapper",
src: "<div class=\"tabs\"/>\n\n## Heading\n\ncontent\n",
want: []finding{{kind: kindUnclosedTag, tag: "div"}},
},
{
// A renderer component is not an HTML element: MDX honors the
// self-closing form, so <children/> is complete and must not be
// flagged as unclosed, even though <div/> above is.
name: "self-closing children component is balanced",
src: "<children/>\n",
want: nil,
},
{
name: "self-closing children component with space is balanced",
src: "<children />\n",
want: nil,
},
{
// A capitalized tag whose lowercase name is a real element
// (<Table>, <Section>) is a component reference, not that element,
// and is reported rather than passing on the accidental lookup.
name: "capitalized real-element name is a component",
src: "<Table>\n",
want: []finding{{kind: kindUnknownElement, tag: "table"}},
},
{
name: "another capitalized real-element name is a component",
src: "<Section>\n\ncontent\n",
want: []finding{{kind: kindUnknownElement, tag: "section"}},
},
{
// The capitalized closing tag must not add a spurious stray-end-tag;
// the opening tag already produced the finding.
name: "capitalized component with close tag reports once",
src: "<Table>\n\ncontent\n\n</Table>\n",
want: []finding{{kind: kindUnknownElement, tag: "table"}},
},
{
// A colon-shaped placeholder is not a real autolink, so it is
// checked (and reported) inside a raw-HTML block.
name: "colon-shaped placeholder in raw block is caught",
src: "<div>\n<region:id>\n</div>\n",
want: []finding{{kind: kindUnknownElement, tag: "region:id"}},
},
{
name: "at-shaped placeholder without a domain dot is caught",
src: "<div>\n<user@host>\n</div>\n",
want: []finding{{kind: kindUnknownElement, tag: "user@host"}},
},
{
// A real dotted email is a genuine autolink and stays ignored, even
// inside a raw-HTML block.
name: "email autolink inside raw block is ignored",
src: "<div>\n<ops@coder.com>\n</div>\n",
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := checkSource([]byte(tc.src))
assertFindings(t, tc.want, got)
})
}
}
// TestFindingLine pins the reported line number so the line-tracking machinery
// (lineStarts, lineAt, offset mapping) cannot silently regress to a constant.
func TestFindingLine(t *testing.T) {
t.Parallel()
// 1: "intro", 2: blank, 3: the unclosed <div>.
src := "intro\n\n<div class=\"tabs\">\n\n## Heading\n\ncontent\n"
got := checkSource([]byte(src))
if len(got) != 1 {
t.Fatalf("want exactly 1 finding, got %d: %+v", len(got), got)
}
if got[0].kind != kindUnclosedTag || got[0].tag != "div" {
t.Fatalf("want unclosed <div>, got %+v", got[0])
}
if got[0].line != 3 {
t.Errorf("want unclosed <div> reported on line 3, got line %d", got[0].line)
}
}
// TestFindingLineWrapped pins the source line of a finding that is not the
// first token in its raw-HTML block, so the buffer-offset -> source-line
// mapping (srcOffset/spans) is actually exercised. A mapping that collapsed
// every token to the block start would report line 1 here instead of line 2.
func TestFindingLineWrapped(t *testing.T) {
t.Parallel()
// 1: <section> opens, 2: <Foo> is the finding, 3: </section> closes.
src := "<section>\n<Foo>\n</section>\n"
got := checkSource([]byte(src))
if len(got) != 1 {
t.Fatalf("want exactly 1 finding, got %d: %+v", len(got), got)
}
if got[0].kind != kindUnknownElement || got[0].tag != "foo" {
t.Fatalf("want unknown-element <Foo>, got %+v", got[0])
}
if got[0].line != 2 {
t.Errorf("want <Foo> reported on line 2, got line %d", got[0].line)
}
}
// TestIsGeneratedDoc covers the generated-doc routing branch, which no current
// docs page triggers (every placeholder was fixed at its source), so only a
// test exercises it.
func TestIsGeneratedDoc(t *testing.T) {
t.Parallel()
if !isGeneratedDoc("docs/reference/cli/server.md") {
t.Error("want docs/reference/** treated as generated")
}
if isGeneratedDoc("docs/admin/security/audit-logs.md") {
t.Error("want a hand-written docs path treated as not generated")
}
}
func TestFilterAllowed(t *testing.T) {
t.Parallel()
// agent-firewall's <host>/<glob> come from an external dependency and are
// suppressed on that specific path only.
src := "Format: \"domain=<host> [path=<glob>]\".\n"
raw := checkSource([]byte(src))
if len(raw) != 2 {
t.Fatalf("expected 2 raw findings, got %d: %+v", len(raw), raw)
}
filtered := filterAllowed("docs/reference/cli/agent-firewall.md", checkSource([]byte(src)))
if len(filtered) != 0 {
t.Fatalf("expected agent-firewall host/glob to be suppressed, got %+v", filtered)
}
// The same tokens are NOT suppressed on any other path.
other := filterAllowed("docs/reference/cli/other.md", checkSource([]byte(src)))
if len(other) != 2 {
t.Fatalf("expected 2 findings on non-allowlisted path, got %d: %+v", len(other), other)
}
}
// TestFilterAllowedStaleEntry verifies the self-clearing guard: when an
// allowlisted tag no longer appears in its file, the unused entry surfaces as a
// stale-allowlist-entry finding so the dead escape hatch fails the build.
func TestFilterAllowedStaleEntry(t *testing.T) {
t.Parallel()
// Only <host> remains; the <glob> allowlist entry now suppresses nothing.
src := "Format: \"domain=<host>\".\n"
got := filterAllowed("docs/reference/cli/agent-firewall.md", checkSource([]byte(src)))
if len(got) != 1 {
t.Fatalf("want exactly 1 stale finding, got %d: %+v", len(got), got)
}
if got[0].kind != kindStaleAllowlist || got[0].tag != "glob" {
t.Fatalf("want stale-allowlist-entry for <glob>, got %+v", got[0])
}
// The fix lives in the linter's allowlist, not at any doc line, so the
// finding carries no line (main reports it against the linter source).
if got[0].line != 0 {
t.Errorf("want stale finding to carry no doc line, got line %d", got[0].line)
}
if !strings.Contains(got[0].msg, "allowedUnknownTags") {
t.Errorf("want stale message to name allowedUnknownTags, got %q", got[0].msg)
}
}
func TestCollectMarkdown(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeTestFile(t, filepath.Join(dir, "b.md"))
writeTestFile(t, filepath.Join(dir, "a.md"))
writeTestFile(t, filepath.Join(dir, "sub", "c.md"))
writeTestFile(t, filepath.Join(dir, "ignore.txt"))
got, err := collectMarkdown([]string{dir})
if err != nil {
t.Fatalf("collectMarkdown: %v", err)
}
// Expected keys go through canonicalPath just like collectMarkdown's, so
// the assertion holds regardless of the temp dir's absolute location.
want := []string{
canonicalPath(filepath.Join(dir, "a.md")),
canonicalPath(filepath.Join(dir, "b.md")),
canonicalPath(filepath.Join(dir, "sub", "c.md")),
}
slices.Sort(want)
if !slices.Equal(got, want) {
t.Fatalf("want %v (sorted, deduped, .md only), got %v", want, got)
}
}
// TestReportFindings covers the per-file reporting in run: the reported
// location (linter source vs. scanned doc), the generator-source note routing
// per file class, and the returned counts. No current docs page triggers these
// branches, so only a test exercises them.
func TestReportFindings(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path string
findings []finding
wantContains []string
wantAbsent []string
wantHTML int
wantStale int
}{
{
name: "generated page routes to the generator source",
path: "docs/reference/cli/server.md",
findings: []finding{{line: 3, kind: kindUnknownElement, tag: "region", msg: "swallowed"}},
wantContains: []string{"docs/reference/cli/server.md:3: unknown-element: swallowed", "generated by `make gen`"},
wantHTML: 1,
},
{
// An allowlisted external page is generated, but its placeholders
// come from an external CLI, so the generator-source note is skipped.
name: "allowlisted external page omits the generator note",
path: "docs/reference/cli/agent-firewall.md",
findings: []finding{{line: 5, kind: kindUnknownElement, tag: "foo", msg: "swallowed"}},
wantContains: []string{"docs/reference/cli/agent-firewall.md:5: unknown-element: swallowed"},
wantAbsent: []string{"generated by `make gen`"},
wantHTML: 1,
},
{
name: "hand-written page omits the generator note",
path: "docs/admin/security/audit-logs.md",
findings: []finding{{line: 2, kind: kindUnknownElement, tag: "foo", msg: "swallowed"}},
wantAbsent: []string{"generated by `make gen`"},
wantHTML: 1,
},
{
// A stale-allowlist finding is reported against the linter source
// with no line, counts as stale (not HTML), and never draws the note.
name: "stale entry reports against the linter source",
path: "docs/reference/cli/agent-firewall.md",
findings: []finding{{kind: kindStaleAllowlist, tag: "glob", msg: "remove it from allowedUnknownTags"}},
wantContains: []string{docshtmlcheckSource + ": stale-allowlist-entry: remove it from allowedUnknownTags"},
wantAbsent: []string{"generated by `make gen`", "agent-firewall.md:"},
wantStale: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var out strings.Builder
html, stale := reportFindings(tc.path, tc.findings, &out)
got := out.String()
for _, want := range tc.wantContains {
if !strings.Contains(got, want) {
t.Errorf("output missing %q\ngot:\n%s", want, got)
}
}
for _, absent := range tc.wantAbsent {
if strings.Contains(got, absent) {
t.Errorf("output should not contain %q\ngot:\n%s", absent, got)
}
}
if html != tc.wantHTML || stale != tc.wantStale {
t.Errorf("want (html=%d stale=%d), got (html=%d stale=%d)", tc.wantHTML, tc.wantStale, html, stale)
}
})
}
}
// TestRun drives run end to end through temp files and asserts the exit-code
// contract: 0 clean, 1 findings, 2 unreadable root.
func TestRun(t *testing.T) {
t.Parallel()
dir := t.TempDir()
clean := filepath.Join(dir, "clean.md")
if err := os.WriteFile(clean, []byte("# ok\n\nPress <kbd>Ctrl</kbd>.\n"), 0o600); err != nil {
t.Fatal(err)
}
if code := run([]string{clean}, io.Discard, io.Discard); code != 0 {
t.Errorf("clean file: want exit 0, got %d", code)
}
bad := filepath.Join(dir, "bad.md")
if err := os.WriteFile(bad, []byte("A <region> placeholder.\n"), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr strings.Builder
if code := run([]string{bad}, &stdout, &stderr); code != 1 {
t.Errorf("file with a finding: want exit 1, got %d", code)
}
if !strings.Contains(stdout.String(), "unknown-element") {
t.Errorf("want an unknown-element line, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "invalid inline HTML issue(s)") {
t.Errorf("want the summary footer, got:\n%s", stderr.String())
}
if code := run([]string{filepath.Join(dir, "does-not-exist")}, io.Discard, io.Discard); code != 2 {
t.Errorf("missing root: want exit 2, got %d", code)
}
}
func writeTestFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("# doc\n"), 0o600); err != nil {
t.Fatal(err)
}
}
func assertFindings(t *testing.T, want, got []finding) {
t.Helper()
type key struct {
kind findingKind
tag string
}
counts := map[key]int{}
for _, f := range got {
counts[key{f.kind, f.tag}]++
}
wantCounts := map[key]int{}
for _, f := range want {
wantCounts[key{f.kind, f.tag}]++
}
for k, n := range wantCounts {
if counts[k] != n {
t.Errorf("want %d finding(s) of {%s %s}, got %d\nall findings: %+v", n, k.kind, k.tag, counts[k], got)
}
}
for k, n := range counts {
if wantCounts[k] == 0 {
t.Errorf("unexpected %d finding(s) of {%s %s}\nall findings: %+v", n, k.kind, k.tag, got)
}
}
}