Skip to content

Commit c857b15

Browse files
authored
Merge pull request #32 from kurtstohrer/fix/issue-31-fetch-method-dedup
Dedup fetch entries by (method, endpoint) — fixes #31
2 parents 66ca6fc + bd61d2b commit c857b15

5 files changed

Lines changed: 183 additions & 19 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "annotask",
3-
"version": "0.2.1",
3+
"version": "0.2.2",
44
"description": "Visual UI design tool for web apps. Make changes in the browser and generate structured reports that AI agents can apply to source code. Works with Vue, React, Svelte, SolidJS, and plain HTML via Vite or Webpack.",
55
"license": "MIT",
66
"type": "module",

src/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@ export interface ProjectDataEntry {
272272
line?: number
273273
/** Endpoint or query key extracted from the definition body, when a literal. */
274274
endpoint?: string
275+
/** HTTP verb for inline fetch / axios / ofetch / $fetch / htmx entries —
276+
* upper-case (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`). Two calls to the
277+
* same endpoint with different methods produce distinct entries; the
278+
* method is part of the scanner's dedup key. */
279+
method?: string
275280
/** Path-only endpoints (`/api/health`) resolved to an absolute URL via the
276281
* nearest vite.config's server.proxy — so a Vue MFE that proxies `/api` to
277282
* FastAPI at :4320 doesn't get its highlights attributed to Go at :4330

src/server/__tests__/data-source-scanner.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,46 @@ describe('scanDataSources — proxy-aware resolved_endpoint', () => {
116116
}
117117
})
118118

119+
it('keeps GET + PATCH on the same endpoint as distinct entries (issue #31)', async () => {
120+
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'annotask-scan-'))
121+
try {
122+
await fsp.writeFile(path.join(tmp, 'package.json'), JSON.stringify({
123+
name: 'root', dependencies: { 'vue-router': '4.0.0' },
124+
}))
125+
await fsp.mkdir(path.join(tmp, 'src', 'pages', 'TenantEditPage'), { recursive: true })
126+
await fsp.writeFile(
127+
path.join(tmp, 'src', 'pages', 'TenantEditPage', 'TenantEditPage.jsx'),
128+
`
129+
export function TenantEditPage({ platformId }) {
130+
const [original, setOriginal] = useState(null);
131+
useEffect(() => {
132+
fetch(\`/api/tenants/\${encodeURIComponent(platformId)}\`, { signal: new AbortController().signal })
133+
.then(r => r.json()).then(setOriginal);
134+
}, [platformId]);
135+
136+
const handleSubmit = async (patch) => {
137+
const res = await fetch(\`/api/tenants/\${encodeURIComponent(platformId)}\`, {
138+
method: 'PATCH',
139+
body: JSON.stringify(patch),
140+
});
141+
return res.json();
142+
};
143+
}
144+
`,
145+
)
146+
clearDataSourceCache()
147+
const cat = await scanDataSources(tmp)
148+
const tenantEntries = cat.project_entries.filter(
149+
e => e.file.endsWith('TenantEditPage.jsx') && e.endpoint === '/api/tenants/',
150+
)
151+
const methods = tenantEntries.map(e => e.method).sort()
152+
expect(methods).toEqual(['GET', 'PATCH'])
153+
expect(tenantEntries.find(e => e.method === 'PATCH')?.display_name).toBe('PATCH /api/tenants/')
154+
} finally {
155+
fs.rmSync(tmp, { recursive: true, force: true })
156+
}
157+
})
158+
119159
it('leaves resolved_endpoint undefined when no vite.config is reachable', async () => {
120160
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'annotask-scan-'))
121161
try {

src/server/data-source-scanner.ts

Lines changed: 133 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -517,35 +517,54 @@ function detectEntries(file: string, content: string, acc: ProjectDataEntry[]):
517517
urlConstants.set(uc[1], uc[2])
518518
}
519519

520+
// Dedup by `(method, endpoint)` so GET + PATCH on the same path both
521+
// survive. A plain `endpoint` key silently drops the mutation when a
522+
// read call to the same URL appears earlier in the same file.
520523
const seenEndpoints = new Set<string>()
521-
const pushEndpoint = (args: string, index: number) => {
524+
const pushEndpoint = (args: string, index: number, method?: string) => {
522525
const endpoint = extractEndpointFromArgs(args, baseUrls, urlConstants)
523526
if (!endpoint) return
524-
if (seenEndpoints.has(endpoint)) return
525-
seenEndpoints.add(endpoint)
527+
const key = `${method ?? ''}${endpoint}`
528+
if (seenEndpoints.has(key)) return
529+
seenEndpoints.add(key)
526530
const line = content.slice(0, index).split('\n').length
527531
const hint_symbols = collectHintSymbols(content, index)
528532
acc.push({
529533
kind: 'fetch',
530534
name: endpointToName(endpoint),
531-
display_name: endpointToDisplayName(endpoint),
535+
display_name: endpointToDisplayName(endpoint, method),
532536
file,
533537
line,
534538
endpoint,
539+
...(method ? { method } : {}),
535540
used_count: 0,
536541
...(hint_symbols.length > 0 ? { hint_symbols } : {}),
537542
})
538543
}
539544

540545
// Direct calls: fetch('/x'), axios.get('...'), ofetch(...), $fetch(...).
541-
const inlineCallRe = /\b(fetch|axios\.(?:get|post|put|patch|delete)|ofetch|\$fetch)\s*\(([\s\S]{0,400}?)\)/g
546+
// `fetch(...)` args often contain nested `()` from `encodeURIComponent`,
547+
// `JSON.stringify`, template expressions, etc. A non-greedy regex stops
548+
// at the first inner `)` and misses the options object, so
549+
// `extractCallArgs` walks forward from `(` balancing parens, strings, and
550+
// template backticks to capture the full argument list.
551+
const inlineCallRe = /\b(fetch|axios\.(?:get|post|put|patch|delete)|ofetch|\$fetch)\s*\(/g
542552
let m: RegExpExecArray | null
543-
while ((m = inlineCallRe.exec(content)) !== null) pushEndpoint(m[2], m.index)
553+
while ((m = inlineCallRe.exec(content)) !== null) {
554+
const call = m[1]
555+
const argsStart = m.index + m[0].length
556+
const args = extractCallArgs(content, argsStart)
557+
if (args === null) continue
558+
pushEndpoint(args, m.index, extractHttpMethod(call, args))
559+
}
544560

545561
// Two-step builds: `const url = new URL(`${API_BASE}/x`); fetch(url)`.
546562
// Common pattern when callers append searchParams before dispatching —
547563
// without this scan pass those endpoints are invisible, so every service
548564
// whose main route is constructed this way loses its catalog entry.
565+
// No method is knowable from the URL ctor alone, so leave `method`
566+
// undefined — the accompanying `fetch(url, …)` call (captured by
567+
// inlineCallRe above) produces the method-bearing entry.
549568
const urlCtorRe = /\bnew\s+URL\s*\(([\s\S]{0,400}?)\)/g
550569
while ((m = urlCtorRe.exec(content)) !== null) pushEndpoint(m[1], m.index)
551570

@@ -555,17 +574,110 @@ function detectEntries(file: string, content: string, acc: ProjectDataEntry[]):
555574
// bare path ("/api/health-fragment") or a full URL ("http://host/...").
556575
const isMarkupish = /\.(vue|svelte|astro|html)$/.test(file)
557576
if (isMarkupish) {
558-
const hxAttrRe = /\shx-(?:get|post|put|patch|delete)\s*=\s*['"]([^'"]+)['"]/gi
577+
const hxAttrRe = /\shx-(get|post|put|patch|delete)\s*=\s*['"]([^'"]+)['"]/gi
559578
while ((m = hxAttrRe.exec(content)) !== null) {
560-
const raw = m[1]
579+
const verb = m[1].toUpperCase()
580+
const raw = m[2]
561581
// Skip bindings like hx-get="{{ dynamic }}" that aren't concrete.
562582
if (raw.includes('{') || raw.includes('$')) continue
563-
pushEndpoint(`"${raw}"`, m.index)
583+
pushEndpoint(`"${raw}"`, m.index, verb)
564584
}
565585
}
566586
}
567587
}
568588

589+
/**
590+
* Walk forward from the `(` of a call expression and return the full argument
591+
* list (up to but not including the matching `)`). Balances nested parens,
592+
* brackets, and braces; skips over single/double/backtick strings and
593+
* template-literal `${…}` expressions so a `)` inside a string doesn't close
594+
* the call early. Returns `null` if the call is unterminated within
595+
* `MAX_ARGS_SPAN` chars (malformed / truncated source).
596+
*/
597+
const MAX_ARGS_SPAN = 2000
598+
function extractCallArgs(source: string, start: number): string | null {
599+
const end = Math.min(source.length, start + MAX_ARGS_SPAN)
600+
let depth = 1
601+
let i = start
602+
while (i < end) {
603+
const c = source[i]
604+
if (c === '"' || c === "'") {
605+
i = skipString(source, i, c, end)
606+
if (i < 0) return null
607+
continue
608+
}
609+
if (c === '`') {
610+
i = skipTemplate(source, i, end)
611+
if (i < 0) return null
612+
continue
613+
}
614+
if (c === '(' || c === '[' || c === '{') depth++
615+
else if (c === ')' || c === ']' || c === '}') {
616+
depth--
617+
if (depth === 0) return source.slice(start, i)
618+
}
619+
i++
620+
}
621+
return null
622+
}
623+
624+
function skipString(source: string, i: number, quote: string, end: number): number {
625+
i++
626+
while (i < end) {
627+
const c = source[i]
628+
if (c === '\\') { i += 2; continue }
629+
if (c === quote) return i + 1
630+
if (c === '\n') return i // unterminated — bail without failing
631+
i++
632+
}
633+
return -1
634+
}
635+
636+
function skipTemplate(source: string, i: number, end: number): number {
637+
i++
638+
while (i < end) {
639+
const c = source[i]
640+
if (c === '\\') { i += 2; continue }
641+
if (c === '`') return i + 1
642+
if (c === '$' && source[i + 1] === '{') {
643+
let depth = 1
644+
i += 2
645+
while (i < end && depth > 0) {
646+
const cc = source[i]
647+
if (cc === '{') depth++
648+
else if (cc === '}') depth--
649+
else if (cc === '"' || cc === "'") { i = skipString(source, i, cc, end); if (i < 0) return -1; continue }
650+
else if (cc === '`') { i = skipTemplate(source, i, end); if (i < 0) return -1; continue }
651+
i++
652+
}
653+
continue
654+
}
655+
i++
656+
}
657+
return -1
658+
}
659+
660+
/**
661+
* Resolve the HTTP verb for an inline fetch / axios / ofetch / $fetch call.
662+
*
663+
* axios.get(…) → GET
664+
* axios.patch(url, body) → PATCH
665+
* fetch(url, { method: 'PATCH' }) → PATCH
666+
* fetch(url) → GET (HTTP default)
667+
* ofetch(url, { method: "POST" }) → POST
668+
*
669+
* When the options argument is a variable we can't inspect, default to GET —
670+
* that matches the browser's behavior when `method` is omitted. Returning
671+
* `undefined` would collapse the entry back into the pre-fix dedup bucket.
672+
*/
673+
function extractHttpMethod(call: string, args: string): string {
674+
const axiosVerb = call.match(/^axios\.(get|post|put|patch|delete)$/)
675+
if (axiosVerb) return axiosVerb[1].toUpperCase()
676+
const inline = args.match(/\bmethod\s*:\s*['"`]([A-Za-z]+)['"`]/)
677+
if (inline) return inline[1].toUpperCase()
678+
return 'GET'
679+
}
680+
569681
/**
570682
* Identify the local variable(s) that hold an inline fetch's result so the
571683
* binding analyzer has a real identifier to trace into template / JSX. The
@@ -705,21 +817,26 @@ function extractEndpointFromArgs(args: string, baseUrls: Map<string, string>, ur
705817

706818
/**
707819
* Readable label for the Data view list — keeps host + port visible so the
708-
* user can tell same-path-different-host endpoints apart at a glance.
820+
* user can tell same-path-different-host endpoints apart at a glance. When a
821+
* method is known, prefix it so GET/PATCH/etc. pairs on the same path stay
822+
* distinguishable in the sidebar.
709823
*
710-
* Absolute URLs: `localhost:4320 /api/health`
711-
* Host-only (rare): `localhost:4320 /`
712-
* Relative paths: `/api/health`
824+
* Absolute URLs: `localhost:4320 GET /api/health`
825+
* Host-only (rare): `localhost:4320 GET /`
826+
* Relative paths: `PATCH /api/tenants/`
827+
* Method unknown: `/api/health`
713828
*/
714-
function endpointToDisplayName(endpoint: string): string {
829+
function endpointToDisplayName(endpoint: string, method?: string): string {
715830
const cleaned = endpoint.replace(/[?#].*$/, '')
831+
const prefix = method ? `${method} ` : ''
716832
const hostMatch = cleaned.match(/^https?:\/\/([^/]+)(\/.*)?$/)
717833
if (hostMatch) {
718834
const host = hostMatch[1]
719835
const path = hostMatch[2] || '/'
720-
return `${host} ${path}`
836+
return `${host} ${prefix}${path}`
721837
}
722-
return cleaned.startsWith('/') ? cleaned : `/${cleaned}`
838+
const path = cleaned.startsWith('/') ? cleaned : `/${cleaned}`
839+
return `${prefix}${path}`
723840
}
724841

725842
/** Turn `/api/users/:id` or `https://host/api/workflows` into a camelCase identifier. */

src/shell/composables/useDataSources.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,11 @@ async function rebuildHighlightSources(): Promise<void> {
178178
}
179179

180180
/** Composite key so binding graphs for same-named entries across MFEs
181-
* (apiHealth in every stress-test MFE) stay distinct in the map. */
181+
* (apiHealth in every stress-test MFE) stay distinct in the map. Method is
182+
* included so a GET + PATCH pair on the same endpoint keeps two rows rather
183+
* than collapsing to one. */
182184
function entryBindingKey(entry: ProjectDataEntry): string {
183-
return `${entry.file}\u0001${entry.name}`
185+
return `${entry.file}\u0001${entry.name}\u0002${entry.method ?? ''}`
184186
}
185187

186188
/**

0 commit comments

Comments
 (0)