diff --git a/internal/contracts/contract.go b/internal/contracts/contract.go index fd4aafa4b..333d2aa67 100644 --- a/internal/contracts/contract.go +++ b/internal/contracts/contract.go @@ -18,8 +18,8 @@ const ( // patterns the gRPC extractor recognises (NewClient), so // the matcher's canonical-name join treats grpc and thrift as one // RPC family when pairing. - ContractThrift ContractType = "thrift" - ContractGraphQL ContractType = "graphql" + ContractThrift ContractType = "thrift" + ContractGraphQL ContractType = "graphql" ContractTopic ContractType = "topic" ContractWS ContractType = "ws" ContractEnv ContractType = "env" diff --git a/internal/contracts/contract_test.go b/internal/contracts/contract_test.go new file mode 100644 index 000000000..14c223d77 --- /dev/null +++ b/internal/contracts/contract_test.go @@ -0,0 +1,38 @@ +package contracts + +import "testing" + +// TestNormalizeHTTPPathWithParams_TemplateSegments pins the shared +// normalizer's pre-htmx-branch behavior, restored by scoping template +// handling to the htmx extractor: template expressions (Go {{...}}, +// Jinja {%...%}, ERB <%...%>) are NOT path parameters and NOT base-URL +// slots at this layer — they stay literal. Only the htmx extractor's +// normalizeHtmxPath collapses them, so every other caller (route_ast_go, +// http_filebased, TS fetch sites, ...) sees template syntax as opaque +// text exactly as it did before the htmx branch existed. +func TestNormalizeHTTPPathWithParams_TemplateSegments(t *testing.T) { + cases := []struct{ name, in, want string }{ + {"go template param stays literal", "/ui/parts/{{.P.ID}}/exp", "/ui/parts/{{.P.ID}}/exp"}, + // Pre-branch quirk, verified against b55b9a0d: a BARE identifier + // between double braces ({{ID}}) contains an inner {ID} brace + // param, so the positional renamer rewrites it to {{p1}} — the + // outer braces survive. Dotted forms ({{.P.ID}}) stay fully + // literal because "." cannot start the \w+ param name. + {"bare double-brace id keeps inner brace param", "/u/{{ID}}/x", "/u/{{p1}}/x"}, + {"two template params stay literal", "/a/{{.A}}/b/{{.B}}", "/a/{{.A}}/b/{{.B}}"}, + {"jinja expression segment stays literal", "/shop/{% sku %}/edit", "/shop/{% sku %}/edit"}, + {"erb segment stays literal", "/shop/<%= sku %>/edit", "/shop/<%= sku %>/edit"}, + {"leading base slot no longer stripped", "{{.Base}}/v1/users", "/{{.Base}}/v1/users"}, + {"leading base slot with slash no longer stripped", "/{{.Base}}/v1/users", "/{{.Base}}/v1/users"}, + {"partial-segment stays literal", "/items-{{.ID}}", "/items-{{.ID}}"}, + {"query untouched by normalizer", "/s?q={{.Q}}", "/s?q={{.Q}}"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _ := NormalizeHTTPPathWithParams(tc.in) + if got != tc.want { + t.Fatalf("NormalizeHTTPPathWithParams(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/contracts/htmx.go b/internal/contracts/htmx.go new file mode 100644 index 000000000..4f4e5d20f --- /dev/null +++ b/internal/contracts/htmx.go @@ -0,0 +1,237 @@ +package contracts + +import ( + "bytes" + "fmt" + "regexp" + "strings" + + "github.com/zzet/gortex/internal/graph" +) + +// HtmxExtractor detects htmx request attributes (hx-get, hx-post, hx-put, +// hx-patch, hx-delete) in HTML template files and emits the HTTP consumer +// side of each request: the template instructs the browser to call that +// route, so a route consumed only from a template is not an orphan +// provider. Canonical IDs collide with provider route contracts through +// normalizeHtmxPath, which maps whole-segment template expressions +// ({{.P.ID}}) onto the same {p1} placeholder space as a provider's +// declared {id} params before delegating to NormalizeHTTPPathWithParams. +// +// Coverage gaps: .gohtml carries Go-template htmx pages but has no +// registered language in the parser (nothing maps the extension), and +// .tpl is claimed by the Helm extractor ahead of the forest gotmpl +// grammar — so htmx attributes in those files are not scanned. +type HtmxExtractor struct{} + +// htmxAttrRe matches the five request-issuing htmx attributes with either +// quote style. The attribute name must be preceded by a whitespace or +// quote character, and admits exactly hx-* and the official data-hx-* +// form — nothing hyphen-prefixed beyond that (track-hx-get and friends +// never match). Group 1: full attribute name; group 2: verb; group 3: +// double-quoted value; group 4: single-quoted value. Deliberately an +// attribute-level scan, not an HTML parse — Go templates are routinely +// not well-formed HTML until rendered ({{if}} blocks split tags +// mid-element). Known limitation: an hx-* written inside ANOTHER +// attribute's value (data-doc="hx-get='/y'") is quote-preceded and still +// matches; full tag-context scanning is an upstream follow-up. +var htmxAttrRe = regexp.MustCompile(`(?i)[\s"']((?:data-)?hx-(get|post|put|patch|delete))\s*=\s*(?:"([^"]*)"|'([^']*)')`) + +// htmxCommentRe matches HTML comments and Go template comments (the +// {{- /* ... */ -}} form, trim markers optional). (?s) lets HTML +// comments span lines. Matches +// are blanked with an equal-length run of spaces before scanning, so a +// commented-out hx-* attribute emits no contract while every byte offset +// — and therefore every Line number, computed from the ORIGINAL source — +// stays true to the file. +var htmxCommentRe = regexp.MustCompile(`(?s)|\{\{-?\s*/\*.*?\*/\s*-?\}\}`) + +// htmxTemplateSegment matches a path segment that is entirely a Go template +// value expression — {{…}} output forms only. Control actions ({{if…}}, +// {{range…}}, {{end}}, …) render nothing and must not become params; other +// template families ({%…%}, <%…%>) are statement syntax and are not scanned +// (only Go-template languages are in SupportedLanguages). +var htmxTemplateSegment = regexp.MustCompile(`^\{\{[^{}]*\}\}$`) + +// htmxControlAction reports whether a {{…}} segment is a Go template control +// action rather than a value interpolation. +var htmxControlAction = regexp.MustCompile(`^\{\{-?\s*(if|else|end|range|with|define|template|block|break|continue|nil)\b`) + +// htmxExternalSchemeRe matches an absolute URL whose scheme sits at the +// START of the value — the only position where "://" marks a knowably +// external origin. A scheme deeper in the value is inside a query +// parameter (/login?next=https://app.example/) and the route is local. +var htmxExternalSchemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`) + +// normalizeHtmxPath applies template-aware segment collapsing BEFORE the +// shared normalizer, so template semantics stay local to this extractor +// and provider-side (and every other consumer's) identity is untouched. +// Returns ok=false for values that cannot produce a trustworthy route ID: +// still containing template syntax after normalization (control flow +// like /api/{{if}}/v2{{else}}/v1{{end}}/items), or normalizing to root +// from an empty-ish value. +// +// Note: a LEADING whole-segment expression ({{.Base}}/v1/users) is treated +// as a path parameter here, not a base-URL slot — so it pairs only with a +// provider declaring a first param (/{base}/v1/users), never with /v1/users. +func normalizeHtmxPath(raw string) (path string, ok bool) { + segs := strings.Split(raw, "/") + changed := false + for i, seg := range segs { + // Control-action segments ({{if…}}, {{end}}, …) are left LITERAL — + // skipped from collapsing, not from the loop — so the residue check + // below still sees the {{ and rejects the whole value. + if seg == "" || !htmxTemplateSegment.MatchString(seg) || htmxControlAction.MatchString(seg) { + continue + } + // Declaration actions ({{$id := .ID}}, {{x = y}}) assign rather than interpolate — left literal so the residue check rejects the value. + if strings.Contains(seg, ":=") || strings.Contains(seg, " = ") { + continue + } + segs[i] = "{tplparam}" + changed = true + } + if changed { + raw = strings.Join(segs, "/") + } + norm, _ := NormalizeHTTPPathWithParams(raw) + if strings.Contains(norm, "{{") || strings.Contains(norm, "{%") || strings.Contains(norm, "<%") { + return "", false + } + return norm, true +} + +// SupportedLanguages covers the registered template languages that carry +// htmx attributes in standard quoted form: html (.html/.htm), gotmpl +// (.gotmpl/.tmpl), and templ (.templ). +// +// htmldjango (.djhtml) is deliberately dropped, not overlooked: +// plain-Django providers mint method-less http::ANY:: IDs, which +// never collide with this extractor's verb-specific consumers, and +// Django's idiomatic {% url 'name' %} indirection is a skip-shape anyway +// — so scanning .djhtml adds unpairable noise. Revisit when upstream +// bridges ANY providers into verb-specific identity. +func (e *HtmxExtractor) SupportedLanguages() []string { + return []string{"html", "gotmpl", "templ"} +} + +// Extract emits one consumer contract per htmx attribute occurrence, +// deduplicated per (verb, normalized path, line). HTML and Go template +// comments are blanked from the scanned copy first — commented-out +// attributes are dead markup, not consumers. +func (e *HtmxExtractor) Extract(filePath string, src []byte, fileNodes []*graph.Node, _ []*graph.Edge) []Contract { + var out []Contract + // Line numbers come from the ORIGINAL source; only the scanned copy + // is comment-stripped, each comment replaced by an equal-length run + // of spaces so byte offsets in the stripped copy map 1:1 onto src. + // Newlines inside a multi-line comment become spaces in the scanned + // copy — which is exactly why `lines` must be split from src, not + // from the stripped text, for Line numbers to stay true to the file. + lines := strings.Split(string(src), "\n") + scan := string(htmxCommentRe.ReplaceAllFunc(src, func(m []byte) []byte { + return bytes.Repeat([]byte{' '}, len(m)) + })) + seen := make(map[string]struct{}) + for _, m := range htmxAttrRe.FindAllStringSubmatchIndex(scan, -1) { + // Group 1 is the full attribute name ((?:data-)?hx-verb); the + // verb itself moved to group 2 and the value groups to 3/4 when + // the leading \b was replaced by the [\s"'] delimiter group. + verb := strings.ToUpper(scan[m[4]:m[5]]) + raw := "" + if m[6] != -1 { + // Trim immediately: a leading-space value (" ?sort=x") + // otherwise survives the query strip as " " and the + // normalizer widens it to the root path — a junk contract. + raw = strings.TrimSpace(scan[m[6]:m[7]]) + } + if m[8] != -1 { + raw = strings.TrimSpace(scan[m[8]:m[9]]) + } + // Query strings and fragments never appear in route registrations. + // Strip them BEFORE the external-URL check: skipHtmxValue rejects + // scheme-bearing values, and a legitimate local route with a URL + // in its query (/login?next=https://app.example/) must survive it. + if i := strings.IndexAny(raw, "?#"); i >= 0 { + raw = raw[:i] + } + if skipHtmxValue(raw) { + continue + } + // A query-only value ("?sort=mpn") strips to the empty string, + // which NormalizeHTTPPathWithParams would widen to "/" — a junk + // root-path consumer that can falsely pair with a real homepage + // provider. Skip it instead. + if raw == "" { + continue + } + norm, ok := normalizeHtmxPath(raw) + if !ok { + continue + } + // Anchor the line number on the attribute-name group (m[2]), not + // the whole match (m[0]): the leading [\s"'] delimiter is one + // byte back, which lands on the PREVIOUS line when an attribute + // directly follows a newline. + ln := lineNumber(lines, m[2]) + key := fmt.Sprintf("%s::%s::%d", verb, norm, ln) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, Contract{ + ID: fmt.Sprintf("http::%s::%s", verb, norm), + Type: ContractHTTP, + Role: RoleConsumer, + SymbolID: htmxAnchorSymbol(fileNodes, ln), + FilePath: filePath, + Line: ln, + Meta: map[string]any{"framework": "htmx", "method": verb, "raw_path": raw}, + Confidence: 0.9, + }) + } + return out +} + +// skipHtmxValue filters attribute values that are not route references: +// knowably-external URLs, empty values, same-page anchors, javascript: +// URIs (case-insensitive — "JavaScript:void(0)" is the same no-op), and +// values that are (or start with) an unrendered control/interpolation +// expression — a dynamically assembled URL has no path we can match. +func skipHtmxValue(v string) bool { + v = strings.TrimSpace(v) + // A LEADING scheme or protocol-relative origin is knowably external — + // pairing it with a local provider after host-stripping would be a false + // match. Only a scheme at the start counts (htmxExternalSchemeRe): a + // "://" deeper in the value sits inside a query string + // (/login?next=https://app.example/), whose route is still local. + // (Variable bases like {{.Base}}/x are handled by the template rules.) + if htmxExternalSchemeRe.MatchString(v) || strings.HasPrefix(v, "//") { + return true + } + if v == "" || strings.HasPrefix(v, "#") || strings.HasPrefix(strings.ToLower(v), "javascript:") { + return true + } + for _, open := range []string{"{{", "{%", "<%"} { + if strings.HasPrefix(v, open) { + return true + } + } + return false +} + +// htmxAnchorSymbol picks the graph anchor for a consumer contract: the +// enclosing template element when one encloses the attribute's line, +// otherwise the file node, otherwise "" (contract still enters the +// registry and gets a KindContract node; only the EdgeConsumes edge is +// skipped by the indexer when SymbolID is empty). +func htmxAnchorSymbol(fileNodes []*graph.Node, ln int) string { + if sid := findEnclosingSymbol(fileNodes, ln); sid != "" { + return sid + } + for _, n := range fileNodes { + if n.Kind == graph.KindFile { + return n.ID + } + } + return "" +} diff --git a/internal/contracts/htmx_test.go b/internal/contracts/htmx_test.go new file mode 100644 index 000000000..5cf56e6cb --- /dev/null +++ b/internal/contracts/htmx_test.go @@ -0,0 +1,311 @@ +package contracts + +import ( + "strings" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +const htmxFixture = ` + + + + +jump +noop +dyn +empty +
dup same path other element
+sort + + +after multi-line comment +cond +leading-space sort +JS uri + + + +literal scheme — knowably external +protocol-relative — knowably external +hyphen-prefixed lookalike +
hx inside another attribute's value
+local route with URL in query +declaration action + +` + +func TestHtmxExtractor_SupportedLanguages(t *testing.T) { + got := (&HtmxExtractor{}).SupportedLanguages() + want := []string{"html", "gotmpl", "templ"} + if len(got) != len(want) { + t.Fatalf("SupportedLanguages() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("SupportedLanguages()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestHtmxExtractor_Extract(t *testing.T) { + fileNodes := []*graph.Node{{ID: "ui/internal/templates/parts.html", Kind: graph.KindFile}} + out := (&HtmxExtractor{}).Extract("ui/internal/templates/parts.html", []byte(htmxFixture), fileNodes, nil) + if len(out) != 11 { + t.Fatalf("got %d contracts, want 11 (10 distinct ids + the line-3/line-10 re-occurrence pair; none from skip, external, lookalike, commented, query-URL, or declaration shapes): %+v", len(out), out) + } + + byID := make(map[string]Contract) + for _, c := range out { + byID[c.ID] = c + } + + // The full expected ID set. Export + the duplicate div share one + // contract ID (dup is a second occurrence of the same route; both + // recorded, keyed by ID here). /after-comment-block proves the + // multi-line comment strip (line 16, true to the file), /health2 + // pins the data-hx-* prefix form, the PUT/PATCH pair pins the two + // verbs the fixture previously never exercised positively. + // + // http::GET::/y is the KNOWN LIMITATION pinned deliberately: an + // hx-* attribute written inside ANOTHER attribute's value + // (data-doc="hx-get='/y'") is quote-preceded and still matches. + // The attribute boundary cannot see tag context without a real + // HTML parse; full tag-context scanning is an upstream follow-up. + wantIDs := map[string]bool{ + "http::GET::/ui/parts/{p1}/exp": false, + "http::POST::/ui/parts/{p1}/reset": false, + "http::DELETE::/ui/parts/{p1}": false, + "http::GET::/health": false, + "http::GET::/after-comment-block": false, + "http::GET::/health2": false, + "http::PUT::/ui/parts/{p1}/archive": false, + "http::PATCH::/ui/parts/{p1}": false, + "http::GET::/y": false, + // N1 pin: a local route with a URL embedded in its query string + // must survive the external-URL check (query stripped first). + "http::GET::/login": false, + } + for id := range wantIDs { + c, ok := byID[id] + if !ok { + t.Fatalf("missing contract %q; got IDs %v", id, keysOf(byID)) + } + if c.Role != RoleConsumer || c.Type != ContractHTTP { + t.Fatalf("%q: role=%v type=%v, want consumer/http", id, c.Role, c.Type) + } + if c.FilePath != "ui/internal/templates/parts.html" { + t.Fatalf("%q: FilePath=%q", id, c.FilePath) + } + if c.Meta["framework"] != "htmx" || c.Meta["raw_path"] == nil { + t.Fatalf("%q: Meta=%v", id, c.Meta) + } + if c.Meta["method"] == nil || c.Meta["method"] == "" { + t.Fatalf("%q: Meta[method] empty", id) + } + if c.Confidence != 0.9 { + t.Fatalf("%q: Confidence=%v, want 0.9", id, c.Confidence) + } + } + + // Query string stripped before normalization. + if c := byID["http::POST::/ui/parts/{p1}/reset"]; c.Meta["raw_path"] != "/ui/parts/{{.P.ID}}/reset" { + t.Fatalf("query not stripped: raw_path=%v", c.Meta["raw_path"]) + } + + // Skipped shapes produce nothing — including a query-only value, whose + // stripped raw_path ("") must never reach a contract, the leading-space + // variant whose untrimmed form used to bypass the guard, the two + // knowably-external URLs (literal scheme + protocol-relative — pairing + // either with a local provider after host-stripping would be a false + // match), the hyphen-prefixed attribute lookalike track-hx-get, + // which the quote/whitespace attribute boundary must not admit, and + // the declaration action ({{$id := .ID}}) — it assigns rather than + // interpolates, so the whole value must be rejected, not collapsed + // to a param. + for _, id := range byID { + if id.Meta["raw_path"] == "#section" || id.Meta["raw_path"] == "" || + id.Meta["raw_path"] == "?sort=mpn&dir=asc" || + id.Meta["raw_path"] == "javascript:void(0)" || + id.Meta["raw_path"] == "JavaScript:void(0)" || + id.Meta["raw_path"] == "/commented/route" || + id.Meta["raw_path"] == "/also/commented" || + id.Meta["raw_path"] == "/api/{{if .V2}}/v2{{else}}/v1{{end}}/items" || + id.Meta["raw_path"] == "https://api.vendor.com/v1/charge" || + id.Meta["raw_path"] == "//cdn.example.com/x" || + id.Meta["raw_path"] == "/x" || + id.Meta["raw_path"] == "/v1/charge" || + id.Meta["raw_path"] == "/orders/{{$id := .ID}}/items" || + id.Meta["raw_path"] == "{{ if .Edit }}/edit{{ else }}/new{{ end }}" { + t.Fatalf("skip-shape extracted: %+v", id) + } + } + // No contract for this fixture may normalize to the root path: a + // query-only hx value must not widen to a junk http::::/ + // consumer that could falsely pair with a real homepage provider. + for id := range byID { + if strings.HasSuffix(id, "::/") { + t.Fatalf("root-path contract %q extracted from fixture", id) + } + } + + // Every contract anchors on the file node (no enclosing symbol in HTML). + for id, c := range byID { + if c.SymbolID != "ui/internal/templates/parts.html" { + t.Fatalf("%q: SymbolID=%q, want file-node fallback", id, c.SymbolID) + } + } + + // Line numbers point at the attributes and are computed from the + // ORIGINAL source (lineNumber is 1-based), even though scanning runs + // on the comment-stripped copy: /health sits on line 5, the + // after-comment-block attribute on line 16 — AFTER a comment + // spanning lines 13-15, whose equal-length space replacement keeps + // every byte offset true to the file — and the data-hx pin on line 20. + // The PUT pin (line 21) and the known-limitation /y pin (line 26, + // inside another attribute's value) anchor on the attribute-name + // group, not the whole match, whose leading delimiter character + // could sit on the previous line. + if c := byID["http::GET::/health"]; c.Line != 5 { + t.Fatalf("GET /health Line=%d, want 5", c.Line) + } + if c := byID["http::GET::/after-comment-block"]; c.Line != 16 { + t.Fatalf("GET /after-comment-block Line=%d, want 16 (after multi-line comment)", c.Line) + } + if c := byID["http::GET::/health2"]; c.Line != 20 { + t.Fatalf("GET /health2 Line=%d, want 20 (data-hx-get pin)", c.Line) + } + if c := byID["http::PUT::/ui/parts/{p1}/archive"]; c.Line != 21 { + t.Fatalf("PUT archive Line=%d, want 21", c.Line) + } + if c := byID["http::GET::/y"]; c.Line != 26 { + t.Fatalf("GET /y Line=%d, want 26 (known limitation)", c.Line) + } + // N1 pin: the query-URL value on line 27 must yield the local route + // path, not be discarded as knowably external. + if c := byID["http::GET::/login"]; c.Line != 27 { + t.Fatalf("GET /login Line=%d, want 27 (query-URL pin)", c.Line) + } +} + +// TestNormalizeHtmxPath pins the extractor-local template pre-pass: +// whole-segment expressions collapse to positional params so consumer +// IDs collide with provider route IDs, while control-flow templates +// that survive normalization are rejected (ok=false) rather than +// minting a junk contract. +func TestNormalizeHtmxPath(t *testing.T) { + cases := []struct { + name string + in string + want string + wantOK bool + }{ + {"go template param", "/ui/parts/{{.P.ID}}/exp", "/ui/parts/{p1}/exp", true}, + {"go template param with spaces", "/orders/{{ order.ID }}", "/orders/{p1}", true}, + {"bare go template param", "/items/{{.ID}}", "/items/{p1}", true}, + // {%…%} and <%…%> are statement syntax of template families this + // extractor does not scan (SupportedLanguages covers Go-template + // languages only) — the segments stay literal and the post- + // normalization residue check rejects the value. + {"jinja statement segment not scanned", "/shop/{% sku %}/edit", "", false}, + {"erb statement segment not scanned", "/shop/<%= sku %>/edit", "", false}, + // A control action renders nothing and must not become a param: + // left literal, the residue check rejects the whole value. + {"control action segment rejected", "/api/{{if .V2}}/items", "", false}, + // A declaration action ({{$id := .ID}}) assigns rather than + // interpolates — left literal so the residue check rejects the + // value instead of minting a bogus param slot. + {"declaration action segment rejected", "/orders/{{$id := .ID}}/items", "", false}, + {"two template params", "/a/{{.A}}/b/{{.B}}", "/a/{p1}/b/{p2}", true}, + {"declared then template param", "/w/{wid}/t/{{.TID}}", "/w/{p1}/t/{p2}", true}, + // A partial-segment expression leaves {{...}} in the normalized + // path, so normalizeHtmxPath rejects it (no trustworthy route ID) + // rather than minting a contract with template syntax in its ID. + // The same residue rejection covers two value expressions + // concatenated inside ONE segment: [^{}]* refuses the nested + // braces, the segment stays literal, the value is rejected. + {"partial-segment template rejected", "/items-{{.ID}}", "", false}, + {"concatenated expressions in one segment rejected", "/x/{{.A}}{{.B}}", "", false}, + {"control flow survives normalization", "/api/{{if .V2}}/v2{{else}}/v1{{end}}/items", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := normalizeHtmxPath(tc.in) + if ok != tc.wantOK { + t.Fatalf("normalizeHtmxPath(%q) ok = %v, want %v (path %q)", tc.in, ok, tc.wantOK, got) + } + if got != tc.want { + t.Fatalf("normalizeHtmxPath(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } + + // Position parity: the collapsed consumer path must equal what the + // provider side normalizes for a declared {id} param — same {p1} + // slot, so the matcher pairs them. + provider, _ := NormalizeHTTPPathWithParams("/ui/parts/{id}/exp") + consumer, ok := normalizeHtmxPath("/ui/parts/{{.P.ID}}/exp") + if !ok || consumer != provider { + t.Fatalf("consumer %q (ok=%v) != provider %q — matcher will orphan the route", consumer, ok, provider) + } + + // Whole-value expressions are owned by the skip layer, not this + // function: skipHtmxValue drops them before normalizeHtmxPath runs. + if !skipHtmxValue("{{ if .Edit }}/edit{{ else }}/new{{ end }}") { + t.Fatalf("whole-value expression should be skipped before normalizeHtmxPath") + } +} + +func TestHtmxConsumerIDCollidesWithProvider(t *testing.T) { + tmpl := `` + consumers := (&HtmxExtractor{}).Extract("ui/internal/templates/parts.html", []byte(tmpl), nil, nil) + if len(consumers) != 1 { + t.Fatalf("got %d contracts, want 1: %+v", len(consumers), consumers) + } + + // Provider side: what route_ast_go / http_filebased build for a + // declared route — same normalizer, same ID format. + norm, _ := NormalizeHTTPPathWithParams("/ui/parts/{id}/exp") + providerID := "http::GET::" + norm + if consumers[0].ID != providerID { + t.Fatalf("consumer ID %q != provider ID %q — matcher will orphan the route", + consumers[0].ID, providerID) + } + + // Registry round-trip: both sides land in the same workspace bucket. + reg := NewRegistry() + reg.AddAllScoped([]Contract{{ + ID: providerID, Type: ContractHTTP, Role: RoleProvider, + FilePath: "ui/internal/router.go", Line: 42, + }}, "go-parts", "", "") + reg.AddAllScoped(consumers, "go-parts", "", "") + bucket := reg.ByWorkspace("go-parts") + if len(bucket) != 2 { + t.Fatalf("workspace bucket has %d contracts, want 2", len(bucket)) + } + + // Real matcher pass: the htmx consumer must rescue the provider — + // the pair appears in Matched and the provider is NOT an orphan. + res := Match(reg) + matched := false + for _, link := range res.Matched { + if link.ContractID == providerID && link.Consumer.ID == consumers[0].ID { + matched = true + } + } + if !matched { + t.Fatalf("Match() did not pair provider %q with the htmx consumer; matched=%d orphans(prov=%d cons=%d)", + providerID, len(res.Matched), len(res.OrphanProviders), len(res.OrphanConsumers)) + } + for _, p := range res.OrphanProviders { + if p.ID == providerID { + t.Fatalf("provider %q left in OrphanProviders despite the htmx consumer", providerID) + } + } +} + +// keysOf is already declared in http_test.go (same signature, same body); +// reuse it here rather than redeclaring it in this package. diff --git a/internal/indexer/htmx_wiring_test.go b/internal/indexer/htmx_wiring_test.go new file mode 100644 index 000000000..2c0687199 --- /dev/null +++ b/internal/indexer/htmx_wiring_test.go @@ -0,0 +1,24 @@ +package indexer + +import ( + "testing" + + "github.com/zzet/gortex/internal/contracts" +) + +func TestBuildPerFileContractExtractors_IncludesHtmx(t *testing.T) { + idx := &Indexer{} + _, byLang := idx.buildPerFileContractExtractors() + for _, lang := range []string{"html", "gotmpl", "templ"} { + found := false + for _, ex := range byLang[lang] { + if _, ok := ex.(*contracts.HtmxExtractor); ok { + found = true + break + } + } + if !found { + t.Fatalf("byLang[%q] has no HtmxExtractor", lang) + } + } +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 5954c2004..ec95d37db 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -6082,6 +6082,7 @@ func (idx *Indexer) buildPerFileContractExtractors() ([]contracts.Extractor, map &contracts.NestMicroserviceExtractor{}, &contracts.EnvVarExtractor{}, &contracts.TerraformExtractor{}, + &contracts.HtmxExtractor{}, } // Config-driven event bus: only registered when the user declared // boundaries (index.event_bus / CODEGRAPH_EVENT_CONFIG), so the default