Skip to content

feat(contracts): htmx extractor — hx-* attributes as HTTP consumer contracts - #608

Merged
zzet merged 11 commits into
zzet:mainfrom
madeinoz67:feat/htmx-extractor-606
Aug 19, 2026
Merged

feat(contracts): htmx extractor — hx-* attributes as HTTP consumer contracts#608
zzet merged 11 commits into
zzet:mainfrom
madeinoz67:feat/htmx-extractor-606

Conversation

@madeinoz67

Copy link
Copy Markdown
Contributor

Title: feat(contracts): htmx extractor — hx-* attributes as HTTP consumer contracts

Summary

Adds an htmx extractor so server-rendered templates that issue hx-get / hx-post /
hx-put / hx-patch / hx-delete attributes are indexed as HTTP consumer
contracts and paired with the route providers that serve them. Before this, every
route consumed only from htmx template attributes showed up as an orphan provider.

Scope note: this covers the attribute lane. URLs assembled in JavaScript
(htmx.ajax()), whole-URL template variables (hx-get="{{.Action}}"), hx-boost
inheritance, and native form action / a href consumers are out of scope for
this PR — those routes remain reported as orphans.

Fixes #606

Changes

  • HtmxExtractor (internal/contracts/htmx.go): attribute-level scan (templates
    are routinely not well-formed HTML until rendered) for the five request verbs,
    emitting http::<VERB>::<normalized path> consumer contracts with
    meta: {framework: htmx, method, raw_path}. Scanning is comment-aware: HTML
    comments and Go template comments are blanked (offset-preserving) before
    matching, so commented-out markup does not mint consumers. Values that cannot
    produce a trustworthy route ID are skipped: empty/query-only/anchor/
    javascript: URIs (case-insensitive), literal-scheme and protocol-relative
    URLs (knowably external — checked after query/fragment stripping, so a local
    /login?next=https://app/ still pairs), whole-URL template expressions, and
    values still containing template syntax after normalization (control-flow and
    declaration actions). data-hx-* attributes are matched (official htmx
    form); nothing else hyphen-prefixed.
  • Template-aware path normalization, local to the extractor
    (normalizeHtmxPath): whole-segment Go template value expressions
    (/ui/parts/{{.P.ID}}/exp) collapse to positional params so consumer IDs
    collide with provider route IDs through the existing shared normalizer —
    which is itself unchanged (byte-identical to before this PR modulo gofmt).
    Control actions ({{if}}/{{range}}/{{end}}…) and declaration actions
    ({{$x := …}}) render nothing and are rejected rather than mis-collapsed. A
    leading expression is treated as a path param, not a base-URL slot
    (conservative: pairs only with a provider declaring a first param).
  • Indexer wiring (internal/indexer/indexer.go): registers HtmxExtractor for
    html, gotmpl, and templ in both full and incremental paths (single
    byLang registration site). Graph edges flow through the existing
    commitContracts — zero new edge code; KindContract nodes + consumes
    edges are automatic. (htmldjango deliberately omitted: plain-Django
    providers mint method-less http::ANY:: IDs that cannot collide with
    verb-specific consumers, and the idiomatic {% url %} form is unresolvable
    statically — worth revisiting if method-less providers gain verb bridging.)
  • Tests: extractor unit tests (verbs, quoting, comments, skip shapes, dedup,
    data- form); a collision-gate test pinning that htmx consumer IDs are
    byte-identical to provider route IDs built through the same normalizer, plus
    a matcher-level test asserting a provider leaves the orphan set when its
    htmx consumer exists; indexer wiring test.

Known limitations

  • .gohtml templates are not a registered language anywhere in gortex yet, and
    .tpl is claimed by the Helm extractor ahead of gotmpl — templates using
    those extensions are not scanned.
  • Routes consumed only via browser navigation (<a href>), external REST
    clients, or JS-built URLs remain in the orphan list — static analysis cannot
    see those consumers.
  • Existing indexes require a full re-index to pick up htmx contracts:
    contract extraction is change-driven and there is no extractor-set
    versioning, so upgrading alone changes nothing until templates are re-indexed.

Testing

  • Focused suites green (internal/contracts, internal/indexer wiring);
    go build ./..., go vet, gofmt clean on touched files. Full
    go test -race ./...: all packages pass on this machine except five
    failures verified identical at the merge base (pre-existing /
    environment-dependent: pricing-table + githooks fixtures fail the same on
    main; store_sqlite race needs CI's 30m budget; one opencode test is
    load-flaky and passes isolated).
  • New tests added for new functionality (incl. RED-verified regression pins:
    disabling the query-only guard or the comment-strip fails the pinned
    fixture counts).
  • Benchmarks — not perf-relevant (regex over template text; ~1s for a
    176-file repo inside a full index).

End-to-end dogfood on the go-parts repo (XDG-isolated scratch daemon, A/B
against the merge-base binary on the same checkout, re-run on the final HEAD of
this branch):

  • orphan providers 48 → 26; 27 htmx consumer contracts across 14 template
    files; 22 distinct providers matched exclusively by htmx consumers
  • the issue's live example hx-get="/ui/parts/{{.P.ID}}/exp" now pairs:
    find_usages returns consumes edges from row.html / row-created.html
  • exhaustive check on the final HEAD: every hx-referenced target (37 raw
    attribute values, normalized, method-aware) is absent from the orphan list;
    the 26 remaining orphans are the external REST surface plus static/health/
    catch-all/browser-navigation routes htmx attributes never touch

…paths

{{...}} / {%...%} / <%...%> path segments now collapse to positional
params so template-side consumers pair with declared route params; a
leading expression is treated as the base-URL slot and stripped. Also
repairs the pre-existing {{ID}} -> {{p1}} double-brace mangle.
Junk-contract guard: a query-only hx value (e.g. hx-get="?sort=mpn") strips
to the empty string, which NormalizeHTTPPathWithParams widened to "/" —
emitting a junk http::GET::/ consumer that could falsely pair with a real
GET / homepage provider. Skip empty raw values after the ?# strip; the
fixture gains the query-only shape plus a no-root-path-contract guard, and
a RED check confirms the guard is what fails without the fix.

Test-hygiene pins: pin the fixture contract count at 5 (4 ids + the line-10
re-occurrence), assert Meta[method] is set and Confidence is 0.9 for every
expected contract, and reword the line-number and keysOf comments to drop
brief-relative wording (keysOf note moved to the true end of file).
…tmx extractor

Move the whole-segment template-expression handling ({{...}}, {%...%},
<%...%>) out of the shared NormalizeHTTPPathWithParams and into a
local normalizeHtmxPath pre-pass used only by the htmx extractor.

Delete the base-strip branch entirely: it was dead code for its own
motivating shape (skipHtmxValue drops {{-prefixed attribute values, so
{{.Base}}/v1/users never reached it) and it mispaired /{{.Org}}/...
first-param routes by deleting the leading param segment.

The shared normalizer returns to its pre-branch identity semantics
exactly (verified against b55b9a0, modulo unrelated gofmt whitespace),
restoring parity for all ~21 non-htmx call sites; provider-side IDs are
untouched. Template assertions move to TestNormalizeHtmxPath, and
contract_test.go now pins the restored literal passthrough — including
the pre-branch quirk that a bare {{ID}} keeps its inner brace param
(-> {{p1}}), which normalizeHtmxPath avoids by collapsing the whole
segment before the shared normalizer runs.
…coverage

Comment hygiene (RedTeam finding 2): HTML comments and Go template
comments are blanked from the scanned copy before attribute matching —
each comment replaced with an equal-length run of spaces so byte offsets
map 1:1 onto the source, and Line numbers stay computed from the
ORIGINAL text (a newline inside a multi-line comment becomes a space in
the scanned copy). Commented-out hx-* attributes no longer mint
consumer contracts.

Guard hardening (finding 4): raw values are TrimSpace'd immediately at
extraction, killing the leading-space bypass where ' ?sort=x' stripped
to ' ' and the normalizer widened it to a junk http::GET::/ root
consumer; skipHtmxValue probes javascript: case-insensitively
(JavaScript:void(0) previously minted /JavaScript{p1}(0)); the htmxAttrRe
doc now notes \b intentionally admits the official data-hx-* prefix
form, pinned by a data-hx-get fixture line.

Coverage honesty (finding 6): SupportedLanguages drops htmldjango —
plain-Django providers mint method-less http::ANY::<path> IDs that never
collide with verb-specific consumers and the idiomatic {% url %} form is
a skip-shape — and adds templ (registered language, standard quoted
attributes). Known gaps documented in the doc comment: .gohtml has no
registered language and .tpl is claimed by the Helm extractor.

Tests: fixture gains the commented route, a multi-line comment with an
attribute after it (Line pinned at 16), the mid-path control-flow
template (no contract), the leading-space query value (no contract),
JavaScript:void(0) (no contract), and data-hx-get="/health2" (pinned);
count re-verified empirically at 7 with the full expected ID set.
…n attr boundary, matcher test

- F1: skipHtmxValue rejects literal-scheme (://) and protocol-relative
  (//) values before host-stripping can manufacture a false local match
- F2: htmxTemplateSegment collapses Go value expressions only; control
  actions ({{if}}/{{end}}/…) left literal so the residue check rejects
  the value; {%…%}/<%…%> statement syntax no longer scanned
- F3: htmxAttrRe requires a whitespace/quote attribute boundary and an
  explicit (?:data-)?hx-verb name — track-hx-get lookalikes never match;
  group indices shifted and line numbers anchored on the name group
- F4: positive PUT/PATCH fixture contracts, real contracts.Match
  assertion (provider rescued, not orphaned), repinned len(out)=10 with
  the full expected-ID set
…tion actions

N1: Extract ran skipHtmxValue before the ?# strip, and skipHtmxValue
rejected any value containing "://" anywhere — so
hx-get="/login?next=https://app.example/" (local route with a URL in its
query) was discarded as knowably external. Strip query/fragment first so
skipHtmxValue only sees the path, and replace the substring check with
htmxExternalSchemeRe (^[a-zA-Z][a-zA-Z0-9+.-]*://) so only a scheme at
the START counts as external (protocol-relative "//" prefix retained).

F2 residual: htmxControlAction covers control keywords, but a declaration
action ({{$id := .ID}}) still collapsed to a {tplparam} slot — a
declaration assigns, it is not a value interpolation. normalizeHtmxPath
now leaves {{...}} segments containing := or " = " literal so the
post-normalization residue check rejects the whole value.

Tests: /login?next=https://app.example/ -> http::GET::/login pinned
(line 27); /orders/{{$id := .ID}}/items -> no contract (skip-shape chain
+ normalize table case); len(out) repinned at 11 with the full 10-entry
expected-ID set.
@madeinoz67

Copy link
Copy Markdown
Contributor Author

Looked into the failing govulncheck check — it does not appear to be caused by this PR:

  • The single finding is GO-2026-6115 in github.com/ledongthuc/pdf (the PDF text-extraction dep), reported as Fixed in: N/A — no fixed version exists upstream yet.
  • ledongthuc/pdf is in go.mod at this PR's merge base, so the dependency predates the change; the advisory is just newer than main's last security workflow run (which passed at PR lsp: solution targeting for csharp-ls (pin env, auto-detect, targeted restore, idle-TTL knob) #604). Main's next scheduled security run should hit the same finding.
  • The tar: Cannot open: File exists lines in the same job are module-cache restore noise, separate from the vuln verdict.

Options as I see them: add GO-2026-6115 to the security workflow's ignore list until upstream ships a fix, or swap the pdf dep when one lands. Happy to do either as a separate PR if useful.

@zzet
zzet merged commit 2f789fc into zzet:main Aug 19, 2026
12 of 14 checks passed
@zzet

zzet commented Aug 19, 2026

Copy link
Copy Markdown
Owner

@madeinoz67 thank you for your contribution.

P.s. The advisory was withdrawn, and the code has protection against that issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

htmx extractor: wire hx-get/hx-post/hx-delete in HTML templates to registered routes as consumer edges

2 participants