import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';

const { parseSourceSafeSpy } = vi.hoisted(() => ({ parseSourceSafeSpy: vi.fn() }));

vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => {
  const { buildSafeParseMock } = await import('../../helpers/parse-source-safe-mock.js');
  return buildSafeParseMock(parseSourceSafeSpy);
});

import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js';
import { getPluginForFile } from '../../../src/core/group/extractors/http-patterns/index.js';
import type { RepoHandle } from '../../../src/core/group/types.js';

describe('HttpRouteExtractor', () => {
  let tmpDir: string;
  let extractor: HttpRouteExtractor;

  beforeEach(() => {
    extractor = new HttpRouteExtractor();
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-http-extract-'));
  });

  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  });

  const makeRepo = (repoPath: string): RepoHandle => ({
    id: 'test-repo',
    path: 'test/backend',
    repoPath,
    storagePath: path.join(repoPath, '.gitnexus'),
  });

  describe('plugin selection', () => {
    it('does not route Blade templates through the PHP source-scan plugin', () => {
      expect(getPluginForFile('resources/views/welcome.blade.php')).toBeUndefined();
      expect(getPluginForFile('routes/web.php')).toBeDefined();
    });
  });

  const toPosixPath = (filePath: string): string => filePath.replace(/\\/g, '/');

  describe('provider extraction — graph-first (Strategy A)', () => {
    it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => {
      const dir = path.join(tmpDir, 'graph-first');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
@RestController
@RequestMapping("/api/v2")
public class UserController {
    @GetMapping("/users")
    public List<User> list() { return service.findAll(); }

    @PostMapping("/users")
    public User create(@RequestBody User user) { return service.save(user); }
}
`,
      );

      const mockDbExecutor = async (query: string) => {
        if (query.includes('HANDLES_ROUTE')) {
          return [
            {
              fileId: 'file-uid-ctrl',
              filePath: 'src/controller/UserController.java',
              routePath: '/api/v2/users',
              routeId: 'route-uid-users',
              responseKeys: null,
              routeSource: 'decorator-GetMapping',
            },
          ];
        }
        if (query.includes('CONTAINS')) {
          return [
            {
              uid: 'uid-ctrl-list',
              name: 'list',
              filePath: 'src/controller/UserController.java',
              labels: ['Method'],
            },
            {
              uid: 'uid-ctrl-create',
              name: 'create',
              filePath: 'src/controller/UserController.java',
              labels: ['Method'],
            },
          ];
        }
        return [];
      };

      const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const getRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users');
      expect(getRoute).toBeDefined();
      expect(getRoute!.confidence).toBe(0.9);
      expect(getRoute!.symbolUid).not.toBe('file-uid-ctrl');
    });

    it('supplements graph providers with source-scan providers from other files', async () => {
      const dir = path.join(tmpDir, 'graph-source-provider-union');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
@RestController
@RequestMapping("/api/v2")
public class UserController {
    @GetMapping("/users")
    public List<User> list() { return service.findAll(); }
}
`,
      );
      fs.writeFileSync(
        path.join(dir, 'cmd/server.go'),
        `
package main

func healthHandler(w http.ResponseWriter, r *http.Request) {}

func main() {
  http.HandleFunc("/api/health", healthHandler)
}
`,
      );

      const mockDbExecutor = async (query: string) => {
        if (query.includes('HANDLES_ROUTE')) {
          return [
            {
              fileId: 'file-uid-ctrl',
              filePath: 'src/controller/UserController.java',
              routePath: '/api/v2/users',
              routeId: 'route-uid-users',
              responseKeys: null,
              routeSource: 'decorator-GetMapping',
            },
          ];
        }
        if (query.includes('FETCHES')) return [];
        if (query.includes('CONTAINS')) {
          return [
            {
              uid: 'uid-ctrl-list',
              name: 'list',
              filePath: 'src/controller/UserController.java',
              labels: ['Method'],
            },
          ];
        }
        return [];
      };

      const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const graphRouteMatches = providers.filter(
        (c) => c.contractId === 'http::GET::/api/v2/users',
      );
      expect(graphRouteMatches).toHaveLength(1);
      expect(graphRouteMatches[0].symbolUid).toBe('uid-ctrl-list');
      expect(graphRouteMatches[0].meta.extractionStrategy).toBe('graph_assisted');

      const sourceRoute = providers.find((c) => c.contractId === 'http::GET::/api/health');
      expect(sourceRoute).toBeDefined();
      expect(sourceRoute?.symbolName).toBe('healthHandler');
      expect(sourceRoute?.meta.extractionStrategy).toBe('source_scan');
    });
  });

  describe('provider extraction — source-scan fallback (Strategy B)', () => {
    it('extracts Spring @GetMapping annotation', async () => {
      const dir = path.join(tmpDir, 'spring');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v2")
public class UserController {
    @GetMapping("/users")
    public List<User> list() { return service.findAll(); }

    @PostMapping("/users")
    public User create(@RequestBody User user) { return service.save(user); }

    @GetMapping("/users/{id}")
    public User getById(@PathVariable Long id) { return service.findById(id); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.length).toBeGreaterThanOrEqual(3);

      const listRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users');
      expect(listRoute).toBeDefined();
      expect(listRoute!.meta.method).toBe('GET');
      expect(listRoute!.meta.path).toBe('/api/v2/users');

      const createRoute = providers.find((c) => c.contractId === 'http::POST::/api/v2/users');
      expect(createRoute).toBeDefined();

      const getByIdRoute = providers.find(
        (c) => c.contractId === 'http::GET::/api/v2/users/{param}',
      );
      expect(getByIdRoute).toBeDefined();
    });

    // ─── #1834 — Spring named annotation arguments ──────────────────
    // Spring annotations accept both positional shorthand
    // (`@GetMapping("/users")`) and named arguments
    // (`@GetMapping(value = "/users")` or `@GetMapping(path = "/users")`).
    // The two AST shapes produced by tree-sitter-java differ:
    //   @GetMapping("/users")          → annotation_argument_list > string_literal
    //   @GetMapping(value = "/users")  → annotation_argument_list > element_value_pair
    // The named-arg pattern in `http-patterns/java.ts` MUST constrain
    // the `key` field to `path`/`value`; without that constraint the
    // query also captures other string-valued attributes such as
    // `produces`, `consumes`, `headers`, `name`, `params` (see PR #1834
    // review). The tests below pin both the positive cases and the
    // negative anti-regression cases.
    it('extracts Spring class-level @RequestMapping(path = "/api")', async () => {
      const dir = path.join(tmpDir, 'spring-class-named-path');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(path = "/api/v3")
public class UserController {
    @GetMapping("/users")
    public List<User> list() { return service.findAll(); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/api/v3/users');
      expect(route).toBeDefined();
      expect(route!.meta.path).toBe('/api/v3/users');
    });

    it('extracts Spring class-level @RequestMapping(value = "/api")', async () => {
      const dir = path.join(tmpDir, 'spring-class-named-value');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/OrderController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(value = "/orders")
public class OrderController {
    @GetMapping("/list")
    public List<Order> list() { return service.findAll(); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/orders/list');
      expect(route).toBeDefined();
    });

    it('extracts Spring method-level @GetMapping(value = "/users") (named value)', async () => {
      const dir = path.join(tmpDir, 'spring-method-named-value');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
public class UserController {
    @GetMapping(value = "/users")
    public List<User> list() { return service.findAll(); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/users');
      expect(route).toBeDefined();
      expect(route!.symbolName).toBe('list');
    });

    it('extracts Spring method-level @GetMapping(path = "/users") (named path)', async () => {
      const dir = path.join(tmpDir, 'spring-method-named-path-get');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
public class UserController {
    @GetMapping(path = "/users")
    public List<User> list() { return service.findAll(); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/users');
      expect(route).toBeDefined();
      expect(route!.symbolName).toBe('list');
    });

    it('extracts Spring method-level @PostMapping(path = "/users") (named path)', async () => {
      const dir = path.join(tmpDir, 'spring-method-named-path-post');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
public class UserController {
    @PostMapping(path = "/users")
    public User create(@RequestBody User user) { return service.save(user); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::POST::/users');
      expect(route).toBeDefined();
      expect(route!.symbolName).toBe('create');
    });

    it('combines class named-arg prefix with method positional path', async () => {
      const dir = path.join(tmpDir, 'spring-mixed-class-named-method-pos');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(path = "/api")
public class UserController {
    @GetMapping("/users")
    public List<User> list() { return service.findAll(); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/api/users');
      expect(route).toBeDefined();
    });

    it('combines class positional prefix with method named-arg path', async () => {
      const dir = path.join(tmpDir, 'spring-mixed-class-pos-method-named');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
public class UserController {
    @GetMapping(value = "/users")
    public List<User> list() { return service.findAll(); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/api/users');
      expect(route).toBeDefined();
    });

    it('does NOT emit a provider for @GetMapping(produces = ...) without path/value', async () => {
      // Anti-regression: without the `key:` constraint, the named-arg
      // query would capture `produces = "application/json"` and emit
      // a bogus `http::GET::/application/json` contract.
      const dir = path.join(tmpDir, 'spring-produces-only');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/MisleadingController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
public class MisleadingController {
    @GetMapping(produces = "application/json")
    public List<User> list() { return service.findAll(); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      // No GET provider should be emitted for this method — the only
      // string literal in the annotation is a non-route attribute.
      expect(
        providers.find((c) => c.contractId === 'http::GET::/application/json'),
      ).toBeUndefined();
      // And the controller has no other route, so providers list for
      // this file should be empty.
      const fromThisFile = providers.filter((c) =>
        c.symbolRef.filePath.endsWith('MisleadingController.java'),
      );
      expect(fromThisFile).toHaveLength(0);
    });

    it('emits exactly one provider for @GetMapping(name = "...", value = "/users")', async () => {
      // Anti-regression: without the `key:` constraint, the named-arg
      // query would capture both string literals and emit two
      // contracts (`/listUsers` + `/users`). With the constraint, only
      // `/users` is emitted.
      const dir = path.join(tmpDir, 'spring-name-and-value');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
public class UserController {
    @GetMapping(name = "listUsers", value = "/users")
    public List<User> list() { return service.findAll(); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const usersRoute = providers.find((c) => c.contractId === 'http::GET::/users');
      expect(usersRoute).toBeDefined();
      expect(usersRoute!.symbolName).toBe('list');

      // The non-route `name` attribute must NOT produce a route.
      expect(providers.find((c) => c.contractId === 'http::GET::/listUsers')).toBeUndefined();

      const fromThisFile = providers.filter((c) =>
        c.symbolRef.filePath.endsWith('UserController.java'),
      );
      expect(fromThisFile).toHaveLength(1);
    });

    it('uses `path` (not non-route key) as class prefix when both appear', async () => {
      // Anti-regression: without the `key:` constraint, the LAST
      // element_value_pair in the annotation wins because
      // prefixByClassId.set is called per match, in document order. So
      // `@RequestMapping(path = "/api", name = "myApi")` would mistakenly
      // set the prefix to `myApi`. With the constraint, only the
      // `path`/`value` pair is captured and the prefix stays `/api`.
      const dir = path.join(tmpDir, 'spring-class-prefix-last-wins');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(path = "/api", name = "myApi")
public class UserController {
    @GetMapping("/users")
    public List<User> list() { return service.findAll(); }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/api/users');
      expect(route).toBeDefined();

      // Must NOT have used `myApi` as the class prefix.
      expect(providers.find((c) => c.contractId === 'http::GET::/myApi/users')).toBeUndefined();
    });

    // ─── #1834 follow-up — Spring on Kotlin ──────────────────────────
    // The same positional / named-argument distinction applies to
    // Kotlin Spring Boot controllers. The Kotlin tree-sitter grammar
    // (fwcd/tree-sitter-kotlin) produces a different AST shape than
    // tree-sitter-java — both forms share `value_argument`, with the
    // optional leading `simple_identifier "="` distinguishing named
    // from positional. The plugin in `http-patterns/kotlin.ts` mirrors
    // the safety bar from java.ts: positional uses `.` to anchor the
    // string_literal as the first named child of `value_argument`,
    // and the named pattern restricts the `simple_identifier` key to
    // `^(path|value)$` to avoid capturing `produces`, `consumes`,
    // `headers`, `name`, `params`, etc.
    //
    // tree-sitter-kotlin is an optionalDependency. If the binding is
    // unavailable in the current test environment, `getPluginForFile`
    // returns undefined for `.kt` files and we skip the suite.
    const kotlinAvailable = getPluginForFile('Probe.kt') !== undefined;
    const itKotlin = kotlinAvailable ? it : it.skip;

    itKotlin('extracts Kotlin @RequestMapping("/api/v1") (positional class prefix)', async () => {
      const dir = path.join(tmpDir, 'kotlin-spring-class-positional');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.kt'),
        `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/v1")
class UserController {
  @GetMapping("/users") fun list() {}
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/api/v1/users');
      expect(route).toBeDefined();
      expect(route!.symbolName).toBe('list');
      expect(route!.meta.framework).toBe('spring');
    });

    itKotlin('extracts Kotlin @RequestMapping(path = "/api/v2") (named class prefix)', async () => {
      const dir = path.join(tmpDir, 'kotlin-spring-class-named-path');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.kt'),
        `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping(path = "/api/v2")
class UserController {
  @GetMapping("/users") fun list() {}
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/api/v2/users');
      expect(route).toBeDefined();
    });

    itKotlin(
      'extracts Kotlin @RequestMapping(value = "/orders") (named class prefix)',
      async () => {
        const dir = path.join(tmpDir, 'kotlin-spring-class-named-value');
        fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
        fs.writeFileSync(
          path.join(dir, 'src/controller/OrderController.kt'),
          `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping(value = "/orders")
class OrderController {
  @GetMapping("/list") fun list() {}
}
`,
        );

        const contracts = await extractor.extract(null, dir, makeRepo(dir));
        const providers = contracts.filter((c) => c.role === 'provider');

        expect(providers.find((c) => c.contractId === 'http::GET::/orders/list')).toBeDefined();
      },
    );

    itKotlin('extracts Kotlin method-level @GetMapping(value = "/users")', async () => {
      const dir = path.join(tmpDir, 'kotlin-spring-method-named-value');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.kt'),
        `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class UserController {
  @GetMapping(value = "/users") fun list() {}
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::GET::/users');
      expect(route).toBeDefined();
      expect(route!.symbolName).toBe('list');
    });

    itKotlin('extracts Kotlin method-level @GetMapping(path = "/users")', async () => {
      const dir = path.join(tmpDir, 'kotlin-spring-method-named-path-get');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.kt'),
        `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class UserController {
  @GetMapping(path = "/users") fun list() {}
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined();
    });

    itKotlin('extracts Kotlin method-level @PostMapping(path = "/users")', async () => {
      const dir = path.join(tmpDir, 'kotlin-spring-method-named-path-post');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.kt'),
        `package com.example
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class UserController {
  @PostMapping(path = "/users") fun create() {}
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const route = providers.find((c) => c.contractId === 'http::POST::/users');
      expect(route).toBeDefined();
      expect(route!.symbolName).toBe('create');
    });

    itKotlin('combines Kotlin class named-arg prefix with method positional path', async () => {
      const dir = path.join(tmpDir, 'kotlin-spring-mixed-class-named-method-pos');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.kt'),
        `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping(path = "/api")
class UserController {
  @GetMapping("/users") fun list() {}
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
    });

    itKotlin('combines Kotlin class positional prefix with method named-arg path', async () => {
      const dir = path.join(tmpDir, 'kotlin-spring-mixed-class-pos-method-named');
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.kt'),
        `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api")
class UserController {
  @GetMapping(value = "/users") fun list() {}
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
    });

    itKotlin(
      'does NOT emit a Kotlin provider for @GetMapping(produces = ...) without path/value',
      async () => {
        // Anti-regression: without the `simple_identifier` key
        // constraint, the named-arg query would capture
        // `produces = "application/json"` and emit a bogus
        // `http::GET::/application/json` contract.
        const dir = path.join(tmpDir, 'kotlin-spring-produces-only');
        fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
        fs.writeFileSync(
          path.join(dir, 'src/controller/MisleadingController.kt'),
          `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class MisleadingController {
  @GetMapping(produces = "application/json") fun list() {}
}
`,
        );

        const contracts = await extractor.extract(null, dir, makeRepo(dir));
        const providers = contracts.filter((c) => c.role === 'provider');

        expect(
          providers.find((c) => c.contractId === 'http::GET::/application/json'),
        ).toBeUndefined();
        const fromThisFile = providers.filter((c) =>
          c.symbolRef.filePath.endsWith('MisleadingController.kt'),
        );
        expect(fromThisFile).toHaveLength(0);
      },
    );

    itKotlin(
      'emits exactly one Kotlin provider for @GetMapping(name = "...", value = "/users")',
      async () => {
        // Anti-regression: without the key constraint, both string
        // literals would be captured as method paths, emitting two
        // contracts (`/listUsers` + `/users`).
        const dir = path.join(tmpDir, 'kotlin-spring-name-and-value');
        fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
        fs.writeFileSync(
          path.join(dir, 'src/controller/UserController.kt'),
          `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class UserController {
  @GetMapping(name = "listUsers", value = "/users") fun list() {}
}
`,
        );

        const contracts = await extractor.extract(null, dir, makeRepo(dir));
        const providers = contracts.filter((c) => c.role === 'provider');

        const usersRoute = providers.find((c) => c.contractId === 'http::GET::/users');
        expect(usersRoute).toBeDefined();
        expect(usersRoute!.symbolName).toBe('list');

        expect(providers.find((c) => c.contractId === 'http::GET::/listUsers')).toBeUndefined();

        const fromThisFile = providers.filter((c) =>
          c.symbolRef.filePath.endsWith('UserController.kt'),
        );
        expect(fromThisFile).toHaveLength(1);
      },
    );

    itKotlin(
      'uses Kotlin `path` (not non-route key) as class prefix when both appear',
      async () => {
        // Anti-regression: without the key constraint, the LAST captured
        // value_argument would win in the prefix map. Here `name = "myApi"`
        // appears after `path = "/api"` — the prefix must remain `/api`.
        const dir = path.join(tmpDir, 'kotlin-spring-class-prefix-key-wins');
        fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
        fs.writeFileSync(
          path.join(dir, 'src/controller/UserController.kt'),
          `package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping(path = "/api", name = "myApi")
class UserController {
  @GetMapping("/users") fun list() {}
}
`,
        );

        const contracts = await extractor.extract(null, dir, makeRepo(dir));
        const providers = contracts.filter((c) => c.role === 'provider');

        expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
        expect(providers.find((c) => c.contractId === 'http::GET::/myApi/users')).toBeUndefined();
      },
    );

    it('does not emit annotated Java interfaces as concrete Spring provider routes', async () => {
      const dir = path.join(tmpDir, 'spring-interface-only');
      fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/rest/DepartmentApi.java'),
        `
package com.example.rest;
import org.springframework.web.bind.annotation.*;

@RequestMapping("/departments")
public interface DepartmentApi {
    @GetMapping("")
    Object list();

    @GetMapping("/{name}")
    Object getByName(@PathVariable String name);
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers).toHaveLength(0);
    });

    it('inherits Spring interface route mappings when controller methods omit annotations', async () => {
      const dir = path.join(tmpDir, 'spring-interface-inherited-methods');
      fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });

      fs.writeFileSync(
        path.join(dir, 'src/rest/StatusApi.java'),
        `
package com.example.rest;
import org.springframework.web.bind.annotation.*;

@RequestMapping("/status")
public interface StatusApi {
    @GetMapping("")
    Object getStatus();
}
`,
      );

      fs.writeFileSync(
        path.join(dir, 'src/controller/StatusController.java'),
        `
package com.example.controller;
import com.example.rest.StatusApi;
import org.springframework.web.bind.annotation.*;

@RestController
public class StatusController implements StatusApi {
    @Override
    public Object getStatus() { return null; }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const statusRoute = providers.find((c) => c.contractId === 'http::GET::/status');
      expect(statusRoute).toBeDefined();
      expect(toPosixPath(statusRoute!.symbolRef.filePath)).toBe(
        'src/controller/StatusController.java',
      );
      expect(statusRoute!.symbolName).toBe('getStatus');
      expect(providers.filter((c) => c.symbolRef.filePath.includes('StatusApi.java'))).toHaveLength(
        0,
      );
    });

    it('combines controller class mapping with inherited interface method mapping', async () => {
      const dir = path.join(tmpDir, 'spring-interface-controller-prefix');
      fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });

      fs.writeFileSync(
        path.join(dir, 'src/rest/UserApi.java'),
        `
package com.example.rest;
import org.springframework.web.bind.annotation.*;

public interface UserApi {
    @GetMapping("/users")
    Object listUsers();
}
`,
      );

      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example.controller;
import com.example.rest.UserApi;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
public class UserController implements UserApi {
    @Override
    public Object listUsers() { return null; }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const usersRoute = providers.find((c) => c.contractId === 'http::GET::/api/users');
      expect(usersRoute).toBeDefined();
      expect(toPosixPath(usersRoute!.symbolRef.filePath)).toBe(
        'src/controller/UserController.java',
      );
    });

    it('does not duplicate inherited Spring prefixes already present on the controller', async () => {
      const dir = path.join(tmpDir, 'spring-inherited-prefix-dedup');
      fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });

      fs.writeFileSync(
        path.join(dir, 'src/rest/DataReleaseFacade.java'),
        `
package com.example.rest;
import org.springframework.web.bind.annotation.*;

@RequestMapping("/open/ai")
public interface DataReleaseFacade {
    @GetMapping("/query")
    Object query();
}
`,
      );

      fs.writeFileSync(
        path.join(dir, 'src/controller/BaseFacadeService.java'),
        `
package com.example.controller;
import org.springframework.web.bind.annotation.*;

@RequestMapping("/open/ai")
public abstract class BaseFacadeService {
}
`,
      );

      fs.writeFileSync(
        path.join(dir, 'src/controller/DataReleaseFacadeImpl.java'),
        `
package com.example.controller;
import com.example.rest.DataReleaseFacade;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/open/ai")
public class DataReleaseFacadeImpl extends BaseFacadeService implements DataReleaseFacade {
    @Override
    public Object query() { return null; }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const queryRoute = providers.find((c) => c.contractId === 'http::GET::/open/ai/query');
      expect(queryRoute).toBeDefined();
      expect(toPosixPath(queryRoute!.symbolRef.filePath)).toBe(
        'src/controller/DataReleaseFacadeImpl.java',
      );
      expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/open/ai/query')).toBe(
        undefined,
      );
    });

    it('still combines distinct inherited Spring prefixes that share a leading segment', async () => {
      const dir = path.join(tmpDir, 'spring-interface-shared-leading-prefix');
      fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });

      fs.writeFileSync(
        path.join(dir, 'src/rest/DataReleaseApi.java'),
        `
package com.example.rest;
import org.springframework.web.bind.annotation.*;

@RequestMapping("/open/ai")
public interface DataReleaseApi {
    @GetMapping("/query")
    Object query();
}
`,
      );

      fs.writeFileSync(
        path.join(dir, 'src/controller/DataReleaseFacadeImpl.java'),
        `
package com.example.controller;
import com.example.rest.DataReleaseApi;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/open")
public class DataReleaseFacadeImpl implements DataReleaseApi {
    @Override
    public Object query() { return null; }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(
        providers.find((c) => c.contractId === 'http::GET::/open/open/ai/query'),
      ).toBeDefined();
      expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/query')).toBeUndefined();
    });

    it('keeps a controller prefix when a prefix-less interface method starts with the same path', async () => {
      const dir = path.join(tmpDir, 'spring-interface-method-prefix-overlap');
      fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });

      fs.writeFileSync(
        path.join(dir, 'src/rest/UserApi.java'),
        `
package com.example.rest;
import org.springframework.web.bind.annotation.*;

public interface UserApi {
    @GetMapping("/users/{id}")
    Object getUser();
}
`,
      );

      fs.writeFileSync(
        path.join(dir, 'src/controller/UserController.java'),
        `
package com.example.controller;
import com.example.rest.UserApi;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController implements UserApi {
    @Override
    public Object getUser() { return null; }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(
        providers.find((c) => c.contractId === 'http::GET::/users/users/{param}'),
      ).toBeDefined();
      expect(providers.find((c) => c.contractId === 'http::GET::/users/{param}')).toBeUndefined();
    });

    it('skips ambiguous inherited routes when interfaces share a simple name', async () => {
      const dir = path.join(tmpDir, 'spring-interface-simple-name-collision');
      fs.mkdirSync(path.join(dir, 'src/a'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'src/b'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });

      fs.writeFileSync(
        path.join(dir, 'src/a/StatusApi.java'),
        `
package com.example.a;
import org.springframework.web.bind.annotation.*;

public interface StatusApi {
    @GetMapping("/a/status")
    Object getStatus();
}
`,
      );

      fs.writeFileSync(
        path.join(dir, 'src/b/StatusApi.java'),
        `
package com.example.b;
import org.springframework.web.bind.annotation.*;

public interface StatusApi {
    @GetMapping("/b/status")
    Object getStatus();
}
`,
      );

      fs.writeFileSync(
        path.join(dir, 'src/controller/StatusController.java'),
        `
package com.example.controller;
import com.example.a.StatusApi;
import org.springframework.web.bind.annotation.*;

@RestController
public class StatusController implements StatusApi {
    @Override
    public Object getStatus() { return null; }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.find((c) => c.contractId === 'http::GET::/b/status')).toBeUndefined();
      expect(providers.find((c) => c.contractId === 'http::GET::/a/status')).toBeUndefined();
      expect(
        providers.filter((c) => c.symbolRef.filePath.includes('StatusController.java')),
      ).toHaveLength(0);
    });

    it('extracts Express router.get patterns', async () => {
      const dir = path.join(tmpDir, 'express');
      fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/routes/users.ts'),
        `
import { Router } from 'express';
const router = Router();

router.get('/api/users', async (req, res) => { res.json([]); });
router.post('/api/users', async (req, res) => { res.json({}); });
router.delete('/api/users/:id', async (req, res) => { res.sendStatus(204); });

export default router;
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.length).toBeGreaterThanOrEqual(3);
      expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
      expect(providers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
      expect(
        providers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
      ).toBeDefined();
    });

    it('dedupes source-only providers by contract id', async () => {
      const dir = path.join(tmpDir, 'source-only-same-contract-id');
      fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/routes/health-a.ts'),
        `
router.get('/api/health', healthA);
`,
      );
      fs.writeFileSync(
        path.join(dir, 'src/routes/health-b.ts'),
        `
router.get('/api/health', healthB);
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.contractId === 'http::GET::/api/health');

      expect(providers).toHaveLength(1);
      expect(providers[0].role).toBe('provider');
      expect(providers[0].meta.extractionStrategy).toBe('source_scan');
    });

    it('extracts Go Gin and Echo route registrations', async () => {
      const dir = path.join(tmpDir, 'go-frameworks');
      fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'cmd', 'server.go'),
        `
package main

func createOrder(c *gin.Context) {}
func listOrders(c echo.Context) error { return nil }

func main() {
  r := gin.Default()
  r.POST("/api/orders/:id", createOrder)

  e := echo.New()
  e.GET("/api/orders", listOrders)
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const ginRoute = providers.find((c) => c.contractId === 'http::POST::/api/orders/{param}');
      expect(ginRoute).toBeDefined();
      expect(ginRoute?.symbolName).toBe('createOrder');

      const echoRoute = providers.find((c) => c.contractId === 'http::GET::/api/orders');
      expect(echoRoute).toBeDefined();
      expect(echoRoute?.symbolName).toBe('listOrders');
    });

    it('extracts stdlib HandleFunc providers', async () => {
      const dir = path.join(tmpDir, 'go-stdlib-provider');
      fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'cmd', 'server.go'),
        `
package main

func healthHandler(w http.ResponseWriter, r *http.Request) {}

func main() {
  http.HandleFunc("/api/health", healthHandler)
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const healthRoute = providers.find((c) => c.contractId === 'http::GET::/api/health');
      expect(healthRoute).toBeDefined();
      expect(healthRoute?.symbolName).toBe('healthHandler');
    });

    it('extracts NestJS controller decorators', async () => {
      const dir = path.join(tmpDir, 'nestjs');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'orders.controller.ts'),
        `
import { Controller, Patch } from '@nestjs/common';

@Controller('orders')
export class OrdersController {
  @Patch(':id')
  updateOrder() {
    return {};
  }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      const patchRoute = providers.find((c) => c.contractId === 'http::PATCH::/orders/{param}');
      expect(patchRoute).toBeDefined();
      expect(patchRoute?.symbolName).toBe('updateOrder');
    });
  });

  describe('consumer extraction — fetch patterns', () => {
    it('extracts fetch() calls', async () => {
      const dir = path.join(tmpDir, 'frontend');
      fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/api/users.ts'),
        `
export async function fetchUsers() {
  const res = await fetch('/api/users');
  return res.json();
}

export async function createUser(data: any) {
  const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });
  return res.json();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.length).toBeGreaterThanOrEqual(2);
      expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
    });

    it('extracts axios calls', async () => {
      const dir = path.join(tmpDir, 'axios-fe');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/api.ts'),
        `
import axios from 'axios';
export const getUsers = () => axios.get('/api/users');
export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`);
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
      ).toBeDefined();
    });

    it('extracts jQuery $.get and $.post shorthand', async () => {
      const dir = path.join(tmpDir, 'jquery-shorthand');
      fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'public/js/users.js'),
        `
function loadUsers() {
  $.get('/api/users', function (data) { console.log(data); });
}

function createUser(payload) {
  $.post('/api/users', payload);
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      const getRoute = consumers.find((c) => c.contractId === 'http::GET::/api/users');
      expect(getRoute).toBeDefined();
      expect(getRoute?.meta.framework).toBe('jquery');

      const postRoute = consumers.find((c) => c.contractId === 'http::POST::/api/users');
      expect(postRoute).toBeDefined();
      expect(postRoute?.meta.framework).toBe('jquery');
    });

    it('extracts jQuery $.ajax with method: and type: keys and default GET', async () => {
      const dir = path.join(tmpDir, 'jquery-ajax');
      fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'public/js/orders.js'),
        `
$.ajax({ url: '/api/orders', method: 'PUT', data: {} });
$.ajax({ url: '/api/items',  type:   'DELETE' });
$.ajax({ url: '/api/default' });

function reloadOrder(id) {
  return $.ajax({ url: \`/api/orders/\${id}\`, method: 'GET' });
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::PUT::/api/orders')).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::DELETE::/api/items')).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::GET::/api/default')).toBeDefined();
      // Template-literal URL inside $.ajax is normalized to {param} the same
      // way the fetch/axios paths do — confirms readStringProp accepts
      // template_string values for jQuery ajax, not just for axios object form.
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
      ).toBeDefined();
    });

    it('extracts axios({ method, url }) object form regardless of key order', async () => {
      const dir = path.join(tmpDir, 'axios-object');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/orders.ts'),
        `
import axios from 'axios';

export function createOrder(data: unknown) {
  return axios({ method: 'POST', url: '/api/orders', data });
}

export function updateUser(id: string, data: unknown) {
  return axios({ url: \`/api/users/\${id}\`, method: 'PUT', data });
}

export function listDefaults() {
  return axios({ url: '/api/defaults' });
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined();
    });

    it('does not emit consumers for unrelated object-literal calls (negative control)', async () => {
      const dir = path.join(tmpDir, 'jquery-axios-negative');
      fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'public/js/misc.js'),
        `
// jQuery but not an ajax/get/post call
$.fn.extend({ url: '/nope', method: 'POST' });
$.each([1, 2, 3], function (i, v) { return v; });

// Not axios and not $ — unrelated helper that happens to take { url, method }
function myHelper(opts) { return opts; }
myHelper({ url: '/nope', method: 'POST' });

// Bare object literal, not a call argument at all
const cfg = { url: '/nope', method: 'POST' };
console.log(cfg);
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      // None of the above should have produced any HTTP consumer contracts.
      const nopeConsumers = consumers.filter(
        (c) => typeof c.meta.path === 'string' && c.meta.path.includes('/nope'),
      );
      expect(nopeConsumers).toHaveLength(0);
    });

    it('extracts Python requests calls', async () => {
      const dir = path.join(tmpDir, 'python-consumer');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'client.py'),
        `
import requests

def create_order():
    return requests.post("https://svc.local/api/orders/42", json={"id": 42})
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
      ).toBeDefined();
    });
    it('extracts Python httpx.AsyncClient calls assigned to attributes or aliases', async () => {
      const dir = path.join(tmpDir, 'python-httpx-consumer');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'client.py'),
        `
import httpx
import httpx as hx
from httpx import AsyncClient
from httpx import AsyncClient as HttpxAsyncClient

# Dotted-package look-alikes — must NOT be detected as httpx.
import my_pkg.httpx as evil_mod
from my_pkg.httpx import AsyncClient as evil_async
# Longer dotted path — must also NOT be detected.
import a.b.c.httpx as deep_evil
from a.b.c.httpx import AsyncClient as deep_evil_async
# Relative import — module_name is a relative_import node, not dotted_name, so
# it must not produce a contract either.
from .httpx import AsyncClient as rel_evil_async

module_client = httpx.AsyncClient(base_url="https://svc.local")
module_alias_client = hx.AsyncClient(base_url="https://svc.local")
module_direct_client = AsyncClient(base_url="https://svc.local")
module_renamed_client = HttpxAsyncClient(base_url="https://svc.local")
evil_mod_client = evil_mod.AsyncClient(base_url="https://svc.local")
evil_direct_client = evil_async(base_url="https://svc.local")
deep_evil_mod_client = deep_evil.AsyncClient(base_url="https://svc.local")
deep_evil_direct_client = deep_evil_async(base_url="https://svc.local")
rel_evil_direct_client = rel_evil_async(base_url="https://svc.local")

class TopicClient:
    def __init__(self):
        self._client = httpx.AsyncClient(base_url="https://svc.local")

    async def list_topics(self):
        return await self._client.get("/topic")

    async def publish(self):
        return await self._client.request("POST", "/questions/import")

    async def delete_topic(self):
        return await self._client.delete("/topic")

async def check_duplicate():
    async with httpx.AsyncClient() as client:
        data = {}
        data.get("/nope")
        service.request("POST", "/nope")
        return await client.post("https://svc.local/questions/duplicate-check")

async def import_aliases():
    local_alias_client = hx.AsyncClient(base_url="https://svc.local")
    local_direct_client = AsyncClient(base_url="https://svc.local")
    local_renamed_client = HttpxAsyncClient(base_url="https://svc.local")
    await local_alias_client.get("/alias-topic")
    await local_direct_client.patch("/direct-topic")
    await local_renamed_client.request("PUT", "/renamed-topic")
    async with hx.AsyncClient() as alias_context:
        await alias_context.delete("/alias-context")
    async with AsyncClient() as direct_context:
        return await direct_context.post("/direct-context")

def unrelated_scope_collision():
    client = acquire_cache_client()
    return client.get("/ignored-same-name")

def module_scope_shadow_collision():
    client = acquire_cache_client()
    return client.get("/ignored-module-same-name")

def shadow_direct_alias():
    AsyncClient = lambda: FakeClient()
    client = AsyncClient()
    return client.get("/shadow-direct-fp")

def shadow_module_alias():
    hx = FakeMod()
    client = hx.AsyncClient()
    return client.get("/shadow-module-fp")

async def shadow_direct_context():
    AsyncClient = lambda: FakeClient()
    async with AsyncClient() as client:
        return await client.get("/shadow-direct-context-fp")

def shadow_tuple_destructure():
    AsyncClient, _other = (lambda: FakeClient()), 42
    client = AsyncClient()
    return client.get("/shadow-tuple-fp")

# Class-body assignment of an imported alias is a class attribute under Python
# LEGB rules — methods inside still see the module binding. The detector must
# NOT poison the methods, so the legitimate httpx call below should still emit.
class ClassBodyRebindHolder:
    AsyncClient = lambda: FakeClient()

    def __init__(self):
        self._client = httpx.AsyncClient(base_url="https://svc.local")

    async def fetch(self):
        return await self._client.get("/class-body-rebind-ok")

module_client.get("/module-topic")
module_alias_client.get("/module-alias-topic")
module_direct_client.get("/module-direct-topic")
module_renamed_client.get("/module-renamed-topic")
evil_mod_client.get("/evil-module-dotted-fp")
evil_direct_client.get("/evil-direct-dotted-fp")
deep_evil_mod_client.get("/deep-evil-module-dotted-fp")
deep_evil_direct_client.get("/deep-evil-direct-dotted-fp")
rel_evil_direct_client.get("/rel-evil-direct-fp")
`,
      );

      // Isolated file for module-level rebind: shadowing applies file-wide, so
      // it must not affect the assertions in client.py above.
      fs.writeFileSync(
        path.join(dir, 'src', 'module_rebind.py'),
        `
from httpx import AsyncClient

# Module-level rebind: the rest of this file's bare AsyncClient calls must NOT
# emit httpx consumer contracts.
AsyncClient = lambda: FakeClient()

shadowed_module_client = AsyncClient(base_url="https://svc.local")
shadowed_module_client.get("/module-level-rebind-fp")
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      const expected = [
        'http::GET::/topic',
        'http::POST::/questions/import',
        'http::DELETE::/topic',
        'http::POST::/questions/duplicate-check',
        'http::GET::/alias-topic',
        'http::PATCH::/direct-topic',
        'http::PUT::/renamed-topic',
        'http::DELETE::/alias-context',
        'http::POST::/direct-context',
        'http::GET::/module-topic',
        'http::GET::/module-alias-topic',
        'http::GET::/module-direct-topic',
        'http::GET::/module-renamed-topic',
        // Class-body rebind of `AsyncClient` is a class attribute, not a
        // method-scope shadow — the legitimate httpx.AsyncClient call inside
        // the class must still emit.
        'http::GET::/class-body-rebind-ok',
      ];

      for (const contractId of expected) {
        const consumer = consumers.find((c) => c.contractId === contractId);
        expect(consumer).toBeDefined();
        expect(consumer?.meta.framework).toBe('python-httpx');
      }

      // Positive control: the legitimate `module_direct_client = AsyncClient(...)`
      // path was actually exercised, so the negative dotted-package assertions
      // below are not passing vacuously.
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/module-direct-topic'),
      ).toBeDefined();

      expect(consumers.find((c) => c.contractId === 'http::GET::/nope')).toBeUndefined();
      expect(consumers.find((c) => c.contractId === 'http::POST::/nope')).toBeUndefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/ignored-same-name'),
      ).toBeUndefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/ignored-module-same-name'),
      ).toBeUndefined();
      // Finding 1: dotted-package look-alikes (`my_pkg.httpx`, three-segment
      // `a.b.c.httpx`, and relative `.httpx`) must not be detected.
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/evil-module-dotted-fp'),
      ).toBeUndefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/evil-direct-dotted-fp'),
      ).toBeUndefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/deep-evil-module-dotted-fp'),
      ).toBeUndefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/deep-evil-direct-dotted-fp'),
      ).toBeUndefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/rel-evil-direct-fp'),
      ).toBeUndefined();
      // Finding 2: locally rebound imported aliases must not be detected.
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/shadow-direct-fp'),
      ).toBeUndefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/shadow-module-fp'),
      ).toBeUndefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/shadow-direct-context-fp'),
      ).toBeUndefined();
      // Tuple/list destructuring rebinds must also shadow the alias.
      expect(consumers.find((c) => c.contractId === 'http::GET::/shadow-tuple-fp')).toBeUndefined();
      // Module-level rebind in a separate file must shadow the whole file.
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/module-level-rebind-fp'),
      ).toBeUndefined();
    });

    it('extracts Java Spring RestTemplate, WebClient and OkHttp literal calls', async () => {
      const dir = path.join(tmpDir, 'java-consumer');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'ApiClient.java'),
        `
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.WebClient;
import okhttp3.Request;

class ApiClient {
  void run(RestTemplate restTemplate, WebClient webClient) {
    restTemplate.getForObject("/api/users/{id}", String.class, 42);
    restTemplate.exchange("/api/users/{id}/details", HttpMethod.GET, null, String.class);
    webClient.post().uri("/api/users");
    new Request.Builder().url("/api/orders/42").build();
  }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}/details'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::GET::/api/users/{param}/details' &&
            c.meta.framework === 'spring-rest-template' &&
            c.confidence === 0.7,
        ),
      ).toBeDefined();
      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::POST::/api/users' &&
            c.meta.framework === 'spring-web-client' &&
            c.confidence === 0.7,
        ),
      ).toBeDefined();
    });

    it('does NOT match Java WebClient long-form method(HttpMethod).uri(...) yet', async () => {
      const dir = path.join(tmpDir, 'java-web-client-long-form');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'LongFormClient.java'),
        `
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.client.WebClient;

class LongFormClient {
  void run(WebClient webClient) {
    webClient.method(HttpMethod.PATCH).uri("/api/users/42").retrieve();
  }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'),
      ).toBeUndefined();
    });

    it('extracts OpenFeign clients as consumers, not providers', async () => {
      const dir = path.join(tmpDir, 'java-openfeign-consumer');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'OrderClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(name = "order-service", url = "\${order.service.url}", path = "/api")
interface OrderClient {
  @GetMapping("/orders/{id}")
  OrderDto getOrder(@PathVariable("id") String id);

  @PostMapping(path = "/orders")
  OrderDto createOrder(OrderDto body);
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(
        consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::POST::/api/orders' &&
            c.meta.framework === 'openfeign' &&
            c.confidence === 0.7,
        ),
      ).toBeDefined();
      expect(
        providers.find((c) => c.symbolRef.filePath.endsWith('OrderClient.java')),
      ).toBeUndefined();
    });

    it('extracts OpenFeign clients without an interface path prefix', async () => {
      const dir = path.join(tmpDir, 'java-openfeign-no-prefix');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'HealthClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;

@FeignClient(name = "health-service")
interface HealthClient {
  @GetMapping("/health")
  String health();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::GET::/health' &&
            c.meta.framework === 'openfeign' &&
            c.confidence === 0.7,
        ),
      ).toBeDefined();
      expect(
        providers.find((c) => c.symbolRef.filePath.endsWith('HealthClient.java')),
      ).toBeUndefined();
    });

    it('does not treat @FeignClient text in an interface body as a Feign annotation', async () => {
      const dir = path.join(tmpDir, 'java-non-feign-interface-text');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'NotFeignClient.java'),
        `
import org.springframework.web.bind.annotation.GetMapping;

interface NotFeignClient {
  String MARKER = "@FeignClient";

  @GetMapping("/not-feign")
  String call();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(consumers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined();
      expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined();
    });

    it('extracts OpenFeign clients with @RequestMapping interface prefixes', async () => {
      const dir = path.join(tmpDir, 'java-openfeign-request-mapping-prefix');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'InventoryClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@FeignClient(name = "inventory-service")
@RequestMapping(path = "/api")
interface InventoryClient {
  @GetMapping("/inventory/{id}")
  InventoryDto getInventory(String id);
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::GET::/api/inventory/{param}' &&
            c.meta.framework === 'openfeign',
        ),
      ).toBeDefined();
    });

    it('prefers @FeignClient(path=...) over @RequestMapping prefixes on OpenFeign clients', async () => {
      const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'PrecedenceClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@FeignClient(name = "order-service", path = "/feign-path")
@RequestMapping("/rm-path")
interface PrecedenceClient {
  @GetMapping("/orders")
  OrderDto getOrders();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders')).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
    });

    it('extracts native @RequestLine consumers on @FeignClient interfaces', async () => {
      const dir = path.join(tmpDir, 'java-feign-request-line-basic');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'AiClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;

@FeignClient(name = "ai-backend")
interface AiClient {
  @RequestLine("POST /ai/summarize")
  String summarize();

  @RequestLine("GET /ai/health")
  String health();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::POST::/ai/summarize' &&
            c.meta.framework === 'openfeign' &&
            c.confidence === 0.75,
        ),
      ).toBeDefined();
      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::GET::/ai/health' &&
            c.meta.framework === 'openfeign' &&
            c.confidence === 0.75,
        ),
      ).toBeDefined();
    });

    it('joins @FeignClient(path=...) prefix with @RequestLine paths', async () => {
      const dir = path.join(tmpDir, 'java-feign-request-line-prefix');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'OrderClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;

@FeignClient(name = "order-service", path = "/api")
interface OrderClient {
  @RequestLine("GET /orders/{id}")
  OrderDto get(Long id);

  @RequestLine("DELETE /orders/{id}")
  void delete(Long id);
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
      ).toBeDefined();
    });

    it('strips query strings from @RequestLine values when forming contract IDs', async () => {
      const dir = path.join(tmpDir, 'java-feign-request-line-query');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'SearchClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;

@FeignClient(name = "search-service")
interface SearchClient {
  @RequestLine("GET /search?q={query}&limit={limit}")
  SearchResult search();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      // Query string is dropped — contract ID is method+path only.
      expect(consumers.find((c) => c.contractId === 'http::GET::/search')).toBeDefined();
      expect(
        consumers.find((c) => c.contractId.includes('?') || c.contractId.includes('limit')),
      ).toBeUndefined();
    });

    it('extracts native @RequestLine on a plain interface without @FeignClient (Feign.builder())', async () => {
      // The canonical core-Feign usage: a plain interface with `@RequestLine`,
      // wired up via `Feign.builder()`. There is NO `@FeignClient` annotation
      // (that is the Spring Cloud variant, which uses Spring MVC annotations and
      // is mutually exclusive with `@RequestLine`). This is the shape used by
      // real client-jar consumers, so it must be recognized.
      const dir = path.join(tmpDir, 'java-request-line-no-feign');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'BigModelClient.java'),
        `
import feign.Headers;
import feign.RequestLine;
import feign.Response;

public interface BigModelClient {
  @RequestLine("POST /ai/summarization")
  @Headers("Content-Type: application/json")
  Response summarize();

  @RequestLine("GET /ai/concurrent")
  Response concurrent();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::POST::/ai/summarization' &&
            c.meta.framework === 'openfeign' &&
            c.confidence === 0.75,
        ),
      ).toBeDefined();
      expect(
        consumers.find(
          (c) => c.contractId === 'http::GET::/ai/concurrent' && c.meta.framework === 'openfeign',
        ),
      ).toBeDefined();
    });

    it('mixes @RequestLine and @GetMapping methods on the same @FeignClient interface', async () => {
      const dir = path.join(tmpDir, 'java-feign-mixed-annotations');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'MixedClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import feign.RequestLine;

@FeignClient(name = "mixed-service", path = "/api")
interface MixedClient {
  @GetMapping("/spring-style")
  String springStyle();

  @RequestLine("GET /native-style")
  String nativeStyle();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      // Both annotation styles produce contracts — they don't conflict.
      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::GET::/api/spring-style' &&
            c.meta.framework === 'openfeign' &&
            c.confidence === 0.7,
        ),
      ).toBeDefined();
      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::GET::/api/native-style' &&
            c.meta.framework === 'openfeign' &&
            c.confidence === 0.75,
        ),
      ).toBeDefined();
    });

    it('extracts @RequestLine values written with the named "value" argument', async () => {
      const dir = path.join(tmpDir, 'java-feign-request-line-named');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'NamedArgClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;

@FeignClient(name = "named-arg-service")
interface NamedArgClient {
  @RequestLine(value = "POST /create")
  String create();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find(
          (c) => c.contractId === 'http::POST::/create' && c.meta.framework === 'openfeign',
        ),
      ).toBeDefined();
    });

    it('ignores @RequestLine whose named argument is not "value"', async () => {
      // The consolidated query matches every named annotation argument; the
      // scanRouteAnnotations loop drops a @RequestLine whose key is not `value`.
      const dir = path.join(tmpDir, 'java-feign-request-line-wrong-key');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'WrongKeyClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;

@FeignClient(name = "wrong-key-service")
interface WrongKeyClient {
  @RequestLine(name = "GET /should-not-extract")
  String shouldNotBeExtracted();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find((c) => c.contractId === 'http::GET::/should-not-extract'),
      ).toBeUndefined();
    });

    it('ignores @RequestLine values that are not a "VERB /path" line', async () => {
      // `parseRequestLine` only accepts a recognized HTTP verb followed by a
      // path starting with `/`. Malformed values (no verb, no leading-slash
      // path, or unknown verb) must be dropped — this guards the relaxed
      // (no-@FeignClient) matcher from turning arbitrary `@RequestLine` string
      // literals into bogus contracts.
      const dir = path.join(tmpDir, 'java-request-line-malformed');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'MalformedClient.java'),
        `
import feign.RequestLine;

interface MalformedClient {
  @RequestLine("not a request line at all")
  String noVerb();

  @RequestLine("GET relative/no/leading/slash")
  String noLeadingSlash();

  @RequestLine("FETCH /unknown-verb")
  String unknownVerb();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      // None of the three malformed values should yield a contract.
      expect(
        consumers.filter((c) => c.symbolRef.filePath.endsWith('MalformedClient.java')),
      ).toHaveLength(0);
    });

    it('ignores @RequestLine on a class method (Feign proxies are interfaces only)', async () => {
      // The relaxed matcher still requires an enclosing interface: Feign builds
      // its proxy from an interface, so a `@RequestLine` on a concrete class
      // method is not a Feign call and must not be emitted as a consumer.
      const dir = path.join(tmpDir, 'java-request-line-on-class');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'NotAProxy.java'),
        `
import feign.RequestLine;

class NotAProxy {
  @RequestLine("GET /should-not-extract")
  String call() { return null; }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find((c) => c.contractId === 'http::GET::/should-not-extract'),
      ).toBeUndefined();
    });

    it('prefers @FeignClient(path=...) over @RequestMapping when @RequestMapping appears first', async () => {
      // Reverse-order companion to the precedence test above: @FeignClient(path)
      // must win even when @RequestMapping is the first annotation in source,
      // exercising the deferred interfaceRequestMappingPrefixes apply.
      const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence-reversed');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'ReversedPrecedenceClient.java'),
        `
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@RequestMapping("/rm-path")
@FeignClient(name = "order-service", path = "/feign-path")
interface ReversedPrecedenceClient {
  @GetMapping("/orders")
  OrderDto getOrders();
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders')).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
    });

    it('extracts Java and Apache HttpClient literal request construction', async () => {
      const dir = path.join(tmpDir, 'java-http-client-consumer');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'HttpClients.java'),
        `
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpPatch;

class HttpClients {
  void run(HttpClient client) throws Exception {
    HttpRequest get = HttpRequest.newBuilder()
        .uri(URI.create("/api/users/1"))
        .GET()
        .build();
    HttpRequest post = HttpRequest.newBuilder()
        .uri(URI.create("/api/users"))
        .POST(HttpRequest.BodyPublishers.ofString("{}"))
        .build();

    new HttpGet("/api/orders/2");
    new HttpPost("/api/orders");
    new HttpPut("/api/orders/3");
    new HttpDelete("/api/orders/4");
    new HttpPatch("/api/orders/5");
  }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined();
      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::POST::/api/users' &&
            c.meta.framework === 'java-http-client' &&
            c.confidence === 0.65,
        ),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find(
          (c) =>
            c.contractId === 'http::POST::/api/orders' &&
            c.meta.framework === 'apache-http-client' &&
            c.confidence === 0.65,
        ),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
      ).toBeDefined();
    });

    // ─── Kotlin consumers (RestTemplate / WebClient short+long / OkHttp) ──
    // Same shape as the Java consumer test above, but parsed by the
    // tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Four
    // consumer flavors covered here: RestTemplate (#1855), WebClient
    // short form (#1855), OkHttp (#1855), and WebClient long form
    // (`webClient.method(HttpMethod.X).uri(...)`, this PR / #1884) —
    // see kotlin.ts file header for the full list.
    //
    // tree-sitter-kotlin is an optionalDependency. If the binding is
    // unavailable, `getPluginForFile` returns undefined for `.kt` and
    // we skip the suite (matches the gating on the Provider tests).
    const kotlinConsumerAvailable = getPluginForFile('Probe.kt') !== undefined;
    const itKotlinConsumer = kotlinConsumerAvailable ? it : it.skip;

    itKotlinConsumer('extracts Kotlin RestTemplate verbs', async () => {
      const dir = path.join(tmpDir, 'kotlin-rest-template');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'ApiClient.kt'),
        `package com.example
import org.springframework.web.client.RestTemplate

class ApiClient(private val restTemplate: RestTemplate) {
  fun run() {
    restTemplate.getForObject("/api/users/1", User::class.java)
    restTemplate.getForEntity("/api/users/2", User::class.java)
    restTemplate.postForObject("/api/users", body, User::class.java)
    restTemplate.postForEntity("/api/users", body, User::class.java)
    restTemplate.put("/api/users/3", body)
    restTemplate.delete("/api/users/4")
    restTemplate.patchForObject("/api/users/5", body, User::class.java)
  }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'),
      ).toBeDefined();

      // Framework label must be the same `spring-rest-template` used
      // by the Java plugin so polyglot repos coalesce on a single key.
      const restConsumers = consumers.filter((c) => c.meta.framework === 'spring-rest-template');
      expect(restConsumers.length).toBeGreaterThanOrEqual(5);
    });

    itKotlinConsumer('extracts Kotlin WebClient short-form verbs', async () => {
      const dir = path.join(tmpDir, 'kotlin-web-client-short');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'OrderClient.kt'),
        `package com.example
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
import org.springframework.web.reactive.function.client.awaitBodilessEntity

class OrderClient(private val webClient: WebClient) {
  suspend fun run() {
    val r1 = webClient.get().uri("/api/orders/1").retrieve().awaitBody<Order>()
    val r2 = webClient.post().uri("/api/orders").retrieve().awaitBody<Order>()
    val r3 = webClient.put().uri("/api/orders/2").retrieve().awaitBody<Order>()
    val r4 = webClient.delete().uri("/api/orders/3").retrieve().awaitBodilessEntity()
    val r5 = webClient.patch().uri("/api/orders/4").retrieve().awaitBody<Order>()
  }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(
        consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
      ).toBeDefined();
      expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
      ).toBeDefined();

      const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client');
      expect(wcConsumers.length).toBeGreaterThanOrEqual(5);
    });

    itKotlinConsumer('extracts Kotlin OkHttp Request.Builder().url(...)', async () => {
      const dir = path.join(tmpDir, 'kotlin-okhttp');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'OkClient.kt'),
        `package com.example
import okhttp3.OkHttpClient
import okhttp3.Request

class OkClient(private val client: OkHttpClient) {
  fun fetch() {
    val req = Request.Builder().url("/api/items").build()
    val resp = client.newCall(req).execute()
  }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      const okConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/items');
      expect(okConsumer).toBeDefined();
      expect(okConsumer!.meta.framework).toBe('okhttp');
    });

    itKotlinConsumer(
      'OkHttp Request.Builder().url("/x").post(body) — verb defaults to GET (Java parity)',
      async () => {
        // Anti-overreach / known-limitation pin: OkHttp encodes the
        // HTTP verb on a sibling call (`.post(body)` / `.delete()` /
        // ...), not on `.url(...)`. The query at `kotlin.ts:OK_HTTP_PATTERNS`
        // intentionally does not walk the chain to recover the verb —
        // it emits `method: 'GET'` for every match, mirroring the Java
        // plugin's `OK_HTTP_PATTERNS` (java.ts).
        //
        // This test pins the accepted behavior so a future verb-walk
        // implementation must update kotlin.ts's known-limitation
        // comment in lockstep. Concretely:
        //   - `Request.Builder().url("/api/users").post(body).build()`
        //     → ONE consumer: `http::GET::/api/users` (heuristic-default)
        //     → NO `http::POST::/api/users` consumer
        //
        // Test signal:
        //   - if this becomes correct (POST detected) without updating
        //     the kotlin.ts comment + java.ts behavior together, this
        //     test goes red and the reviewer must reconcile both sides.
        const dir = path.join(tmpDir, 'kotlin-okhttp-post-chain');
        fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
        fs.writeFileSync(
          path.join(dir, 'src', 'OkPostClient.kt'),
          `package com.example
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody

class OkPostClient(private val client: OkHttpClient, private val body: RequestBody) {
  fun create() {
    val req = Request.Builder().url("/api/users").post(body).build()
    client.newCall(req).execute()
  }
}
`,
        );

        const contracts = await extractor.extract(null, dir, makeRepo(dir));
        const consumers = contracts.filter((c) => c.role === 'consumer');

        const fromThisFile = consumers.filter((c) =>
          c.symbolRef.filePath.endsWith('OkPostClient.kt'),
        );

        // Heuristic-default GET: exactly one consumer is emitted for
        // the .url("/x") capture, with method=GET regardless of the
        // sibling .post(body) call.
        expect(fromThisFile).toHaveLength(1);
        expect(fromThisFile[0].contractId).toBe('http::GET::/api/users');
        expect(fromThisFile[0].meta.method).toBe('GET');

        // Anti-overreach: no second contract with POST should appear.
        // If a future verb-walk lands and this assertion needs to flip
        // (i.e. POST is now detected), bump kotlin.ts's known-limitation
        // comment and java.ts in the same PR.
        expect(fromThisFile.find((c) => c.contractId === 'http::POST::/api/users')).toBeUndefined();
      },
    );

    itKotlinConsumer('extracts Kotlin WebClient long form GET', async () => {
      const dir = path.join(tmpDir, 'kotlin-web-client-long-get');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'LongGetClient.kt'),
        `package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody

class LongGetClient(private val webClient: WebClient) {
  suspend fun run() {
    val r = webClient.method(HttpMethod.GET).uri("/api/users").retrieve().awaitBody<User>()
  }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      const route = consumers.find((c) => c.contractId === 'http::GET::/api/users');
      expect(route).toBeDefined();
      expect(route!.meta.framework).toBe('spring-web-client');
    });

    itKotlinConsumer('extracts Kotlin WebClient long form POST/PUT/DELETE/PATCH', async () => {
      const dir = path.join(tmpDir, 'kotlin-web-client-long-verbs');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src', 'LongVerbClient.kt'),
        `package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
import org.springframework.web.reactive.function.client.awaitBodilessEntity

class LongVerbClient(private val webClient: WebClient) {
  suspend fun run() {
    webClient.method(HttpMethod.POST).uri("/api/orders").retrieve().awaitBody<Order>()
    webClient.method(HttpMethod.PUT).uri("/api/orders/1").retrieve().awaitBody<Order>()
    webClient.method(HttpMethod.DELETE).uri("/api/orders/2").retrieve().awaitBodilessEntity()
    webClient.method(HttpMethod.PATCH).uri("/api/orders/3").retrieve().awaitBody<Order>()
  }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
      ).toBeDefined();

      // All four should be tagged as `spring-web-client` so polyglot
      // repos coalesce on the same framework key as the short form.
      // The fixture is fully deterministic — exactly 4 long-form calls,
      // no short-form / RestTemplate / OkHttp calls mixed in — so an
      // exact count is meaningful (DoD §2.7). If a future change
      // accidentally emits a 5th consumer (e.g. duplicate query firing,
      // or a regressed receiver constraint matching unrelated calls),
      // this assertion catches it.
      const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client');
      expect(wcConsumers).toHaveLength(4);
    });

    itKotlinConsumer(
      'short-form query does NOT also fire on Kotlin WebClient long form (no double-emit)',
      async () => {
        // The long-form query handles `webClient.method(HttpMethod.X).uri(...)`,
        // and the short-form query handles `webClient.get().uri(...)`. Both
        // queries carry sibling `(navigation_suffix (simple_identifier) @verb)`
        // constraints — short form requires the verb name itself
        // (`get`/`post`/...), long form requires the literal name
        // `method`. The two are disjoint.
        //
        // This test pins that disjointness: a single `.method(HttpMethod.GET)`
        // call must emit ONE consumer, not two (one from each query).
        const dir = path.join(tmpDir, 'kotlin-web-client-long-no-double');
        fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
        fs.writeFileSync(
          path.join(dir, 'src', 'NoDoubleClient.kt'),
          `package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody

class NoDoubleClient(private val webClient: WebClient) {
  suspend fun run() {
    webClient.method(HttpMethod.GET).uri("/api/single").retrieve().awaitBody<String>()
  }
}
`,
        );

        const contracts = await extractor.extract(null, dir, makeRepo(dir));
        const consumers = contracts.filter((c) => c.role === 'consumer');

        const fromThisFile = consumers.filter((c) =>
          c.symbolRef.filePath.endsWith('NoDoubleClient.kt'),
        );
        expect(fromThisFile).toHaveLength(1);
        expect(fromThisFile[0].contractId).toBe('http::GET::/api/single');
      },
    );

    itKotlinConsumer(
      'does NOT match Kotlin WebClient long form with variable-bound verb',
      async () => {
        // Anti-overreach: source-scan can't follow `val verb = HttpMethod.X`
        // back to the literal — that's a graph-aware concern. The long-form
        // query requires `(navigation_expression HttpMethod . verb)` as the
        // `value_argument` shape, so a bare `simple_identifier` (the
        // variable name) fails to match. Pin this so a future relaxation
        // of the value_argument shape cannot silently start guessing the
        // verb from arbitrary identifiers.
        const dir = path.join(tmpDir, 'kotlin-web-client-long-var-verb');
        fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
        fs.writeFileSync(
          path.join(dir, 'src', 'VariableVerbClient.kt'),
          `package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody

class VariableVerbClient(private val webClient: WebClient) {
  suspend fun run() {
    val verb = HttpMethod.PATCH
    val r = webClient.method(verb).uri("/api/dynamic").retrieve().awaitBody<String>()
  }
}
`,
        );

        const contracts = await extractor.extract(null, dir, makeRepo(dir));
        const consumers = contracts.filter((c) => c.role === 'consumer');

        const fromThisFile = consumers.filter((c) =>
          c.symbolRef.filePath.endsWith('VariableVerbClient.kt'),
        );
        expect(fromThisFile).toHaveLength(0);
      },
    );

    itKotlinConsumer(
      'does NOT pick up unrelated string-literal calls on a non-restTemplate receiver',
      async () => {
        // Anti-regression: the RestTemplate receiver constraint
        // (#eq? @obj "restTemplate") must hold. A field with a
        // different conventional name (e.g. `cacheClient`) calling
        // `.getForObject("/x", ...)` should NOT produce a route.
        const dir = path.join(tmpDir, 'kotlin-rest-template-other-receiver');
        fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
        fs.writeFileSync(
          path.join(dir, 'src', 'CacheClient.kt'),
          `package com.example

class CacheClient(private val cacheClient: SomeCache) {
  fun run() {
    cacheClient.getForObject("/cache/key", String::class.java)
  }
}
`,
        );

        const contracts = await extractor.extract(null, dir, makeRepo(dir));
        const consumers = contracts.filter((c) => c.role === 'consumer');

        expect(consumers.find((c) => c.contractId === 'http::GET::/cache/key')).toBeUndefined();
        const fromCache = consumers.filter((c) => c.symbolRef.filePath.endsWith('CacheClient.kt'));
        expect(fromCache).toHaveLength(0);
      },
    );

    it('extracts Go stdlib and resty calls', async () => {
      const dir = path.join(tmpDir, 'go-consumer');
      fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'cmd', 'client.go'),
        `
package main

import (
  "net/http"

  "github.com/go-resty/resty/v2"
)

func main() {
  http.Get("/api/health")
  client := resty.New()
  client.R().Delete("/api/orders/42")
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/api/health')).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
      ).toBeDefined();
    });
  });

  describe('provider extraction — Laravel', () => {
    it('extracts Laravel Route::get patterns', async () => {
      const dir = path.join(tmpDir, 'laravel');
      fs.mkdirSync(path.join(dir, 'routes'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'routes/api.php'),
        `<?php
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
Route::delete('/users/{id}', [UserController::class, 'destroy']);
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.length).toBeGreaterThanOrEqual(3);
      expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined();
      expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined();
      expect(providers.find((c) => c.contractId === 'http::DELETE::/users/{param}')).toBeDefined();
    });
  });

  describe('consumer extraction — PHP', () => {
    it('extracts Laravel Http facade calls', async () => {
      const dir = path.join(tmpDir, 'php-http-facade');
      fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'app/Client.php'),
        `<?php
use Illuminate\\Support\\Facades\\Http;

class Client {
    public function run() {
        Http::get('/api/users');
        Http::post('/api/orders/42');
        Http::delete('/api/users/7');
    }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
      ).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
      ).toBeDefined();
    });

    it('extracts Guzzle $client->method() calls', async () => {
      const dir = path.join(tmpDir, 'php-guzzle');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/ApiClient.php'),
        `<?php
use GuzzleHttp\\Client;

class ApiClient {
    public function run(Client $client) {
        $client->get('/api/health');
        $client->post('/api/orders/42');
    }
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/api/health')).toBeDefined();
      expect(
        consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
      ).toBeDefined();
    });

    it('extracts file_get_contents HTTP calls', async () => {
      const dir = path.join(tmpDir, 'php-fgc');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/fetch.php'),
        `<?php
function fetchRemote() {
    $data = file_get_contents('https://example.test/api/items/1');
    $local = file_get_contents('/tmp/local-file.txt');
    return $data;
}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.find((c) => c.contractId === 'http::GET::/api/items/{param}')).toBeDefined();
      // file paths and stream wrappers must not emit consumer contracts
      expect(consumers.find((c) => c.meta.path === '/tmp/local-file.txt')).toBeUndefined();
    });
  });

  describe('provider extraction — FastAPI', () => {
    it('extracts FastAPI @app.get decorator patterns', async () => {
      const dir = path.join(tmpDir, 'fastapi');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/main.py'),
        `from fastapi import FastAPI
app = FastAPI()

@app.get("/users")
async def list_users():
    return []

@app.post("/users")
async def create_user(user: UserCreate):
    return user
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.length).toBeGreaterThanOrEqual(2);
      expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined();
      expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined();
    });

    it('joins FastAPI @router.<verb> path with include_router(prefix=...) from main.py (attribute shape)', async () => {
      const dir = path.join(tmpDir, 'fastapi-router-attr');
      fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'main.py'),
        `from fastapi import FastAPI
from api import assistant
app = FastAPI()
app.include_router(assistant.router, prefix='/ai', tags=['ai'])
`,
      );
      fs.writeFileSync(
        path.join(dir, 'api/assistant.py'),
        `from fastapi import APIRouter
router = APIRouter()

@router.post("/assistant")
async def assistant(req):
    return {}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.find((c) => c.contractId === 'http::POST::/ai/assistant')).toBeDefined();
      // bare unprefixed form should not be emitted when a prefix mapping exists
      expect(providers.find((c) => c.contractId === 'http::POST::/assistant')).toBeUndefined();
    });

    it('joins FastAPI @router.<verb> path with include_router(prefix=...) (named-import shape)', async () => {
      const dir = path.join(tmpDir, 'fastapi-router-named');
      fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'main.py'),
        `from fastapi import FastAPI
from api.predict import router as predict_router
app = FastAPI()
app.include_router(predict_router, prefix='/ai')
`,
      );
      fs.writeFileSync(
        path.join(dir, 'api/predict.py'),
        `from fastapi import APIRouter
router = APIRouter()

@router.get("/concurrent")
async def concurrent():
    return {}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');

      expect(providers.find((c) => c.contractId === 'http::GET::/ai/concurrent')).toBeDefined();
    });

    it('emits @router.<verb> path unmodified when no include_router prefix is configured', async () => {
      const dir = path.join(tmpDir, 'fastapi-router-no-prefix');
      fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
      fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`);
      fs.writeFileSync(
        path.join(dir, 'api/loose.py'),
        `from fastapi import APIRouter
router = APIRouter()

@router.get("/standalone")
async def standalone():
    return {}
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');
      expect(providers.find((c) => c.contractId === 'http::GET::/standalone')).toBeDefined();
    });
  });

  describe('consumer extraction — graph-first (Strategy A)', () => {
    it('extracts consumers from FETCHES graph edges', async () => {
      const dir = path.join(tmpDir, 'graph-consumers');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(path.join(dir, 'src/api.ts'), 'export const api = {};');

      const mockDbExecutor = async (query: string) => {
        if (query.includes('HANDLES_ROUTE')) return [];
        if (query.includes('FETCHES')) {
          return [
            {
              fileId: 'file-uid-api',
              filePath: 'src/api.ts',
              routePath: '/api/users',
              routeId: 'route-uid-users',
              fetchReason: 'fetch-url-match',
            },
          ];
        }
        if (query.includes('CONTAINS')) {
          return [
            {
              uid: 'uid-fn-fetch',
              name: 'fetchUsers',
              filePath: 'src/api.ts',
              labels: ['Function'],
            },
          ];
        }
        return [];
      };

      const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      expect(consumers.length).toBeGreaterThanOrEqual(1);
      expect(consumers[0].confidence).toBe(0.9);
      expect(consumers[0].symbolName).toBe('fetchUsers');
    });

    it('supplements graph consumers with source-scan consumers from other files', async () => {
      const dir = path.join(tmpDir, 'graph-source-consumer-union');
      fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true });
      fs.writeFileSync(path.join(dir, 'src/api/graph.ts'), 'export const api = {};');
      fs.writeFileSync(
        path.join(dir, 'src/api/health.ts'),
        `
export async function fetchHealth() {
  const res = await fetch('/api/health');
  return res.json();
}
`,
      );

      const mockDbExecutor = async (query: string) => {
        if (query.includes('HANDLES_ROUTE')) return [];
        if (query.includes('FETCHES')) {
          return [
            {
              fileId: 'file-uid-api',
              filePath: 'src/api/graph.ts',
              routePath: '/api/users',
              routeId: 'route-uid-users',
              fetchReason: 'fetch-url-match',
            },
          ];
        }
        if (query.includes('CONTAINS')) {
          return [
            {
              uid: 'uid-fn-fetch',
              name: 'fetchUsers',
              filePath: 'src/api/graph.ts',
              labels: ['Function'],
            },
          ];
        }
        return [];
      };

      const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
      const consumers = contracts.filter((c) => c.role === 'consumer');

      const graphConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/users');
      expect(graphConsumer).toBeDefined();
      expect(graphConsumer?.symbolUid).toBe('uid-fn-fetch');
      expect(graphConsumer?.meta.extractionStrategy).toBe('graph_assisted');

      const sourceConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/health');
      expect(sourceConsumer).toBeDefined();
      expect(sourceConsumer?.meta.extractionStrategy).toBe('source_scan');
    });
  });

  describe('edge cases', () => {
    it('returns empty for repo with no matching files', async () => {
      const dir = path.join(tmpDir, 'empty-repo');
      fs.mkdirSync(dir, { recursive: true });
      fs.writeFileSync(path.join(dir, 'README.md'), '# Hello');

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      expect(contracts).toHaveLength(0);
    });

    it('handles graph queries that throw gracefully', async () => {
      const dir = path.join(tmpDir, 'graph-error');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(path.join(dir, 'src/routes.ts'), `router.get('/api/health', handler);`);

      const throwingExecutor = async () => {
        throw new Error('DB unavailable');
      };

      const contracts = await extractor.extract(throwingExecutor, dir, makeRepo(dir));
      // Should fall back to source scan
      const providers = contracts.filter((c) => c.role === 'provider');
      expect(providers.length).toBeGreaterThanOrEqual(1);
    });
  });

  describe('path normalization', () => {
    it('strips trailing slash', async () => {
      const dir = path.join(tmpDir, 'trailing');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/router.ts'),
        `
router.get('/api/users/', handler);
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const provider = contracts.find((c) => c.role === 'provider');
      expect(provider?.meta.path).toBe('/api/users');
    });

    it('normalizes path params from multiple syntaxes', async () => {
      const dir = path.join(tmpDir, 'params');
      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'src/router.ts'),
        `
router.get('/api/users/:id', handler1);
router.get('/api/posts/{postId}', handler2);
`,
      );

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      contracts.forEach((c) => {
        expect(c.meta.path).not.toContain(':id');
        expect(c.meta.path).not.toContain('{postId}');
        if (typeof c.meta.path === 'string' && c.meta.path.includes('users/')) {
          expect(c.meta.path).toContain('{param}');
        }
      });
    });
  });

  // ─── #1185: contract extractors must honour .gitnexusignore ─────────
  //
  // Pre-#1185 the source-scan path used a hardcoded
  // `[node_modules, .git, dist, build, vendor]` glob ignore array, so a
  // user's `.gitnexusignore` pattern (e.g. a Python venv `mentor_env/`,
  // a generated stubs dir, a noisy fixture tree) was silently scanned
  // anyway. Since #1185 the source-scan path consumes the shared
  // `IgnoreService` (mirrors `filesystem-walker.ts`), so any pattern in
  // `.gitnexusignore` (or `.gitignore`) prunes the glob.
  describe('respects .gitnexusignore (#1185)', () => {
    it('source-scan glob skips files matched by .gitnexusignore', async () => {
      const dir = path.join(tmpDir, 'gitnexusignore-honoured');
      fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true });
      // Control: a normal route file that SHOULD be discovered.
      fs.writeFileSync(
        path.join(dir, 'src/routes/users.ts'),
        `import { Router } from 'express';
const router = Router();
router.get('/api/users', (req, res) => res.json([]));
export default router;
`,
      );
      // Vendored source under a venv-style dir: the same Express
      // pattern, but inside a directory the user wants excluded.
      fs.writeFileSync(
        path.join(dir, 'mentor_env/lib/leaked.ts'),
        `import { Router } from 'express';
const r = Router();
r.get('/api/leaked', (req, res) => res.json([]));
export default r;
`,
      );
      fs.writeFileSync(path.join(dir, '.gitnexusignore'), 'mentor_env/\n');

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');
      // Control survives.
      expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
      // Excluded path is pruned at the glob level — nothing emitted.
      expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined();
      // Defence-in-depth: no contract whose symbolRef is under mentor_env/.
      expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
    });

    // Pinned by the @claude review on PR #1247: above, only `.gitnexusignore`
    // is exercised. `createIgnoreFilter` reads `.gitignore` too via
    // `loadIgnoreRules`, but that integration is only proven at the
    // `IgnoreService` level — no extractor-level test for the
    // `.gitignore`-only code path. Adding one minimal extractor-level
    // assertion here closes the gap (one shared test is sufficient
    // because all three extractors consume the same filter object).
    it('source-scan glob also skips files matched by `.gitignore` (no `.gitnexusignore`)', async () => {
      const dir = path.join(tmpDir, 'gitignore-honoured');
      fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
      fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true });
      // Same Express pattern as above so detection logic is identical.
      fs.writeFileSync(
        path.join(dir, 'src/routes/users.ts'),
        `import { Router } from 'express';
const router = Router();
router.get('/api/users', (req, res) => res.json([]));
export default router;
`,
      );
      fs.writeFileSync(
        path.join(dir, 'mentor_env/lib/leaked.ts'),
        `import { Router } from 'express';
const r = Router();
r.get('/api/leaked', (req, res) => res.json([]));
export default r;
`,
      );
      // Note: NO .gitnexusignore — only `.gitignore`. This proves the
      // `.gitignore` code path inside `createIgnoreFilter` is wired to
      // the extractors' globs.
      fs.writeFileSync(path.join(dir, '.gitignore'), 'mentor_env/\n');

      const contracts = await extractor.extract(null, dir, makeRepo(dir));
      const providers = contracts.filter((c) => c.role === 'provider');
      expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
      expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined();
      expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
    });
  });

  describe('Windows SIGSEGV regression — large input must route through parseSourceSafe', () => {
    it('routes >32 767-char source file through parseSourceSafe (not direct parser.parse)', async () => {
      parseSourceSafeSpy.mockClear();

      // >40 000-char Java controller file. Direct parser.parse(content) on
      // an input this size SIGSEGVs the process on Windows. The spy assertion
      // is what catches the regression — a "no throw" assertion alone is
      // satisfied by the bypass on Linux/macOS where parser.parse(40 000 chars)
      // succeeds.
      const padding = Array.from(
        { length: 600 },
        (_, i) => `    public String helper${i}() { return "padding-${i}-aaaaaaaaaaaaaaaaaaa"; }\n`,
      ).join('');
      const largeJava = `package com.example;\n\n@RestController\npublic class BigController {\n${padding}}\n`;
      expect(largeJava.length).toBeGreaterThan(40_000);

      // Use mkdtempSync rather than a fixed subdir name: satisfies CodeQL's
      // js/insecure-temporary-file rule by generating a unique random suffix
      // instead of relying on the parent tmpDir's predictable Date.now() name.
      const dir = fs.mkdtempSync(path.join(tmpDir, 'large-input-'));
      fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
      fs.writeFileSync(path.join(dir, 'src/controller/BigController.java'), largeJava);

      const mockDbExecutor = async (_query: string) => [];
      await extractor.extract(mockDbExecutor, dir, makeRepo(dir));

      expect(parseSourceSafeSpy).toHaveBeenCalled();
    });
  });
});
