feat(contracts): htmx extractor — hx-* attributes as HTTP consumer contracts - #608
Merged
Conversation
…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.
Contributor
Author
|
Looked into the failing
Options as I see them: add GO-2026-6115 to the security workflow's ignore list until upstream ships a fix, or swap the |
zzet
approved these changes
Aug 19, 2026
Owner
|
@madeinoz67 thank you for your contribution. P.s. The advisory was withdrawn, and the code has protection against that issue. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Title:
feat(contracts): htmx extractor — hx-* attributes as HTTP consumer contractsSummary
Adds an htmx extractor so server-rendered templates that issue
hx-get/hx-post/hx-put/hx-patch/hx-deleteattributes are indexed as HTTP consumercontracts 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-boostinheritance, and native
form action/a hrefconsumers are out of scope forthis PR — those routes remain reported as orphans.
Fixes #606
Changes
internal/contracts/htmx.go): attribute-level scan (templatesare routinely not well-formed HTML until rendered) for the five request verbs,
emitting
http::<VERB>::<normalized path>consumer contracts withmeta: {framework: htmx, method, raw_path}. Scanning is comment-aware: HTMLcomments 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-relativeURLs (knowably external — checked after query/fragment stripping, so a local
/login?next=https://app/still pairs), whole-URL template expressions, andvalues still containing template syntax after normalization (control-flow and
declaration actions).
data-hx-*attributes are matched (official htmxform); nothing else hyphen-prefixed.
(
normalizeHtmxPath): whole-segment Go template value expressions(
/ui/parts/{{.P.ID}}/exp) collapse to positional params so consumer IDscollide 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. Aleading expression is treated as a path param, not a base-URL slot
(conservative: pairs only with a provider declaring a first param).
internal/indexer/indexer.go): registers HtmxExtractor forhtml,gotmpl, andtemplin both full and incremental paths (singlebyLangregistration site). Graph edges flow through the existingcommitContracts— zero new edge code;KindContractnodes +consumesedges are automatic. (
htmldjangodeliberately omitted: plain-Djangoproviders mint method-less
http::ANY::IDs that cannot collide withverb-specific consumers, and the idiomatic
{% url %}form is unresolvablestatically — worth revisiting if method-less providers gain verb bridging.)
data-form); a collision-gate test pinning that htmx consumer IDs arebyte-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
.gohtmltemplates are not a registered language anywhere in gortex yet, and.tplis claimed by the Helm extractor ahead of gotmpl — templates usingthose extensions are not scanned.
<a href>), external RESTclients, or JS-built URLs remain in the orphan list — static analysis cannot
see those consumers.
contract extraction is change-driven and there is no extractor-set
versioning, so upgrading alone changes nothing until templates are re-indexed.
Testing
internal/contracts,internal/indexerwiring);go build ./...,go vet, gofmt clean on touched files. Fullgo test -race ./...: all packages pass on this machine except fivefailures verified identical at the merge base (pre-existing /
environment-dependent: pricing-table + githooks fixtures fail the same on
main;store_sqliterace needs CI's 30m budget; one opencode test isload-flaky and passes isolated).
disabling the query-only guard or the comment-strip fails the pinned
fixture counts).
176-file repo inside a full index).
End-to-end dogfood on the
go-partsrepo (XDG-isolated scratch daemon, A/Bagainst the merge-base binary on the same checkout, re-run on the final HEAD of
this branch):
files; 22 distinct providers matched exclusively by htmx consumers
hx-get="/ui/parts/{{.P.ID}}/exp"now pairs:find_usagesreturnsconsumesedges fromrow.html/row-created.htmlattribute 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