GitNexus/gitnexus/test/unit/django-root-discovery.test.ts
Dinh Huy dbd4e1c9fb
feat(group): Support Django route extraction for multi-repo (#1836)
* [+] Add django route discovery to create cross-link for multi-repo

* [+] Update ingestion

* [~] Fix bugs and abstraction violation

* feat(python-http): add keyword url= and variable propagation for consumer detection

- Add REQUESTS_KEYWORD_URL_PATTERNS for requests.get(url='...') keyword args
- Add WRAPPER_URI_PATTERNS for generic wrapper.fetch(uri='...') calls
- Add WRAPPER_URI_VAR_PATTERNS + buildLocalStringMap for uri=variable propagation
- Add LOCAL_STRING_ASSIGNMENTS to track uri='...' assignments
- Wire both direct-string and variable-propagation loops in scan()
- Add normalizeConsumerPath() helper

Note: Automatic cross-link detection remains limited for runtime-computed URLs
(URLs built via .format(), string concat, or module constants). Manual
manifest links needed for known cross-repo contracts.

* [+] add extract uri and url keywork pattern for request http

* feat(python-http): add variable propagation for uri=/url= consumer patterns

Re-add LOCAL_STRING_ASSIGNMENTS, WRAPPER_URI_VAR_PATTERNS,
buildLocalStringMap(), and normalizeConsumerPath() lost during
cherry-pick merge of upstream keyword-URL commit.

Together with the upstream WRAPPER_URI_PATTERNS and
REQUESTS_KEYWORD_URL_PATTERNS, we now detect:
- requests.get(url='literal') keyword args
- wrapper.fetch(uri='literal') keyword args
- wrapper.fetch(uri=variable) where variable was assigned a string literal

* fix(group): discover Django roots relative to manage.py dir + multi-project (#1836 R1)

A Django project not at the repo root (e.g. backend/manage.py) discovered
zero routes: the settings module path was resolved repo-root-relative only,
so backend/myproj/settings.py was never found and discovery returned null.

Resolve settings, star-imported base settings, ROOT_URLCONF, and the root
urls.py against the manage.py's own directory first, then the repo root
(resolvedSettingsPath is now project-dir-aware so relative imports anchor
correctly). Iterate every manage.py so a monorepo with several Django
projects yields each project's root — the provider hook becomes plural
(discoverRootRouteFiles → string[]) and the main-thread pass loops over all
roots (inner-scoped continues, parser hoisted once per language).

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(group): remove dead code in Django root discovery (#1836 R9)

- Collapse the identical if/else in extractStarImports to one push.
- Drop the unreachable baseModule.startsWith('.') branch (baseModule is
  always a resolved slash-path or a bare absolute module — never dot-prefixed).
- Import DjangoFileReader from django.ts instead of re-declaring the type.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(group): walk Django includes once per prefix, not per file (#1836 R2)

The include() recursion guard was keyed on file path alone and shared across
the whole walk, so a urlconf included under two prefixes (a "diamond" — the
same app mounted at /v1/ and /v2/) emitted routes for only the first mount.

Key the guard on (resolvedFilePath, accumulatedPrefix) at all three sites
(function entry, path()-wrapped include, bare include) so a file reached
under two distinct prefixes is walked once per prefix while a genuine cycle
(same file + same prefix) still terminates — null/'' prefixes collapse to one
key so a no-prefix re-entry is treated as a cycle. MAX_INCLUDE_DEPTH remains
the backstop. Adds diamond + self-include-cycle tests.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(group): extract Django routes from non-list urlpatterns (#1836 R3)

findUrlpatternsLists only accepted a list-literal RHS, so common shapes
yielded zero routes: concatenation (urlpatterns = a + b), wrapper calls
(format_suffix_patterns([...]), i18n_patterns, staticfiles_urlpatterns), and
tuples.

Add collectUrlpatternContainers to descend binary_operator operands, known
wrapper-call list arguments, and tuples. Inherently-dynamic forms (DRF
router.urls, comprehensions, bare names) still yield nothing but now emit a
debug log so the silent-zero case is observable rather than mysterious.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(group): thread Django parser explicitly, drop module singleton (#1836 R4)

extractDjangoRoutes relied on a module-level _djangoParser set via
setDjangoParser before each call — hidden state that would break if a second
language ever used the include re-parse path, and an easy-to-forget contract.

Pass the tree-sitter parser as an explicit parameter of extractDjangoRoutes
(the extractRoutes provider hook already receives it) and delete the global
plus its setter. The Python provider wires it directly; tests pass the parser
in place of the removed setDjangoParser() call.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): isolate a throwing extractRoutes in the cross-file route pass (#1836 R5)

The main-thread cross-file route pass called provider.extractRoutes without a
guard, so a throw (e.g. a future grammar edge case in the include() walk) would
propagate out of the parse phase and abort the entire analyze — unlike the
worker, which isolates per-file failures.

Wrap the per-root extractRoutes call in try/catch that logs a warning and
continues to the next root. Export extractCrossFileRoutes and add a unit test
driving a stub provider whose extractRoutes throws, asserting the pass returns
[] and does not propagate.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(ingestion): bucket only route-capable languages in cross-file pass (#1836 R6)

extractCrossFileRoutes runs in the deferred band on every analyze (incl. warm
all-cache-hit runs). It now derives the set of languages whose provider exposes
the cross-file route hooks once, returns early if none do, and buckets only
those languages' paths — so a non-framework repo no longer pays to bucket the
languages it doesn't use here.

Route results are intentionally not persisted across runs, so a Django repo
still re-derives its routes each analyze; documented inline that cross-run
route caching is a deliberate follow-up rather than implemented here.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(group): prettier-format http-patterns/python.ts (#1836 R7)

The file was not formatted to the root .prettierrc (the consumer-path
normalizer used single-line try/catch and method chains), so the CI
quality/format check (`prettier --check .`) failed. Reflow only — no logic
change (`git diff -w` confines the change to normalizeConsumerPath's layout).

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(group): dedup Python URI detections by byte offset, not line arithmetic (#1836 R8)

The wrapper-URI dedup key was lineNum*1000+methodRow, which can collide for
distinct calls in files over 1000 lines (carry into the row term) and can
fail to dedup a genuine duplicate when a node straddles a line boundary.

Key on node byte offsets (`${pathNode.startIndex}:${methodNode.startIndex}`),
matching the sibling seenVarDetections dedup a few lines below.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ingestion): end-to-end Django cross-file route extraction (#1836 R10)

Adds an integration test that runs runPipelineFromRepo against a Django
fixture whose project lives under backend/, asserting the resulting Route
graph nodes (/health, /api/items, /api/items/<int:pk>). This exercises the
previously-untested main-thread orchestration glue (discovery → parse →
extractRoutes → allExtractedRoutes → Route nodes) and, because the project is
in a subdirectory, regresses the subdir-discovery fix (R1) — a repo-root-only
resolver would discover nothing and emit zero Route nodes.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(group): anchor Django include() resolution at the project root (#1836 review F1)

resolveIncludedFile tried the bare repo-root candidate (app/urls.py) before the
project-relative one, so in a monorepo with both a repo-root app/ and a
backend/ Django project that also has an app/, include('app.urls') from the
backend project resolved to the WRONG service's routes.

Probe up-tree from the root urls.py for the nearest manage.py (the Django
project root / sys.path entry) and try that-anchored candidate first. Absolute
module paths like `app.urls` now resolve to <projectRoot>/app/urls.py
unambiguously. When no manage.py is reachable (e.g. unit tests with a urls-only
reader) the prior strategy order is preserved. Adds a monorepo wrong-app test.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(group): drop bogus Django provider source-scan, use graph routes (#1836 review F2)

The DJANGO_PATH_PATTERNS / DJANGO_URL_PATTERNS source scan emitted an HTTP
provider contract for every path()/re_path()/url() string literal, without
checking it was inside urlpatterns, without skipping include() mount points,
and without composing the include() prefix across files. For
`path('api/', include('app.urls'))` + child `path('items/', view)` it emitted
providers for `/api` (a mount, not a route) and `/items` (un-prefixed) — which
survived the exact-contract-ID dedup alongside the correct graph route
`/api/items`, polluting cross-repo matching with false providers.

Remove the Django provider patterns and their scan blocks. Django provider
contracts come from the graph Route nodes, which the ingestion route extractor
builds with includes already composed (and now correctly, per the other fixes).
Python HTTP *consumer* patterns (requests/wrapper) are unaffected.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(group): match method-agnostic Django providers to any-method consumers (#1836 review F3)

Django function views are method-agnostic, so extractDjangoRoutes emits
httpMethod '*'. That '*' was dropped by normalizeRouteMethod and then defaulted
to GET by the contract extractor, while the matcher only expanded wildcard
*consumers* — so a `POST /api/items` consumer never matched the Django
provider that was silently narrowed to GET.

- routes.ts: preserve '*' as a method-agnostic marker on the Route node, so the
  contract layer emits a wildcard provider (http::*::path) instead of GET.
- matching.ts: make findMatchingKeys symmetric — a specific-method consumer
  now matches an exact-method provider OR a wildcard (http::*::) provider on the
  same path, mirroring the existing wildcard-consumer expansion.

Co-Authored-By: HuyNguyenDinh <61400397+HuyNguyenDinh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dinh Huy <huynd86@fpt.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:11:30 +01:00

117 lines
5.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { discoverDjangoRootUrls } from '../../src/core/ingestion/route-extractors/django-root-discovery.js';
/** Build a disk-style reader from a path → content record. */
const makeReader = (fsMap: Record<string, string>) => (relativePath: string) =>
Object.prototype.hasOwnProperty.call(fsMap, relativePath) ? fsMap[relativePath] : null;
/** A `manage.py` body pointing at the given dotted settings module. */
const manageFor = (settingsModule: string) =>
`#!/usr/bin/env python\nimport os\ndef main():\n os.environ.setdefault('DJANGO_SETTINGS_MODULE', '${settingsModule}')\n`;
const MANAGE_PY = manageFor('myproj.settings');
describe('discoverDjangoRootUrls', () => {
it('discovers the root urls.py from content-bearing files (no reader)', () => {
const files = [
{ path: 'manage.py', content: MANAGE_PY },
{ path: 'myproj/settings.py', content: `ROOT_URLCONF = 'myproj.urls'\n` },
{ path: 'myproj/urls.py', content: `urlpatterns = []\n` },
];
expect(discoverDjangoRootUrls(files)).toEqual(['myproj/urls.py']);
});
it('discovers the root urls.py via the reader fallback when files carry no content', () => {
const fsMap: Record<string, string> = {
'manage.py': MANAGE_PY,
'myproj/settings.py': `ROOT_URLCONF = 'myproj.urls'\n`,
'myproj/urls.py': `urlpatterns = []\n`,
};
// Only paths are passed (the main-thread pass does this); content is resolved on demand.
const files = Object.keys(fsMap).map((path) => ({ path }));
expect(discoverDjangoRootUrls(files, undefined, makeReader(fsMap))).toEqual(['myproj/urls.py']);
});
it('follows ROOT_URLCONF through a star-imported base settings module via the reader', () => {
const fsMap: Record<string, string> = {
'manage.py': MANAGE_PY,
'myproj/settings.py': `from .base import *\n`,
'myproj/base.py': `DEBUG = True\nROOT_URLCONF = 'myproj.urls'\n`,
'myproj/urls.py': `urlpatterns = []\n`,
};
const files = Object.keys(fsMap).map((path) => ({ path }));
expect(discoverDjangoRootUrls(files, undefined, makeReader(fsMap))).toEqual(['myproj/urls.py']);
});
it('resolves a urls package directory module (urls/__init__.py)', () => {
const fsMap: Record<string, string> = {
'manage.py': MANAGE_PY,
'myproj/settings.py': `ROOT_URLCONF = 'myproj.urls'\n`,
'myproj/urls/__init__.py': `urlpatterns = []\n`,
};
const files = Object.keys(fsMap).map((path) => ({ path }));
expect(discoverDjangoRootUrls(files, undefined, makeReader(fsMap))).toEqual([
'myproj/urls/__init__.py',
]);
});
it('discovers a Django project located in a subdirectory (settings resolved relative to manage.py)', () => {
const fsMap: Record<string, string> = {
'backend/manage.py': MANAGE_PY,
'backend/myproj/settings.py': `ROOT_URLCONF = 'myproj.urls'\n`,
'backend/myproj/urls.py': `urlpatterns = []\n`,
};
const files = Object.keys(fsMap).map((path) => ({ path }));
expect(discoverDjangoRootUrls(files, undefined, makeReader(fsMap))).toEqual([
'backend/myproj/urls.py',
]);
});
it('follows star-imported base settings for a subdirectory project', () => {
const fsMap: Record<string, string> = {
'backend/manage.py': MANAGE_PY,
'backend/myproj/settings.py': `from .base import *\n`,
'backend/myproj/base.py': `ROOT_URLCONF = 'myproj.urls'\n`,
'backend/myproj/urls.py': `urlpatterns = []\n`,
};
const files = Object.keys(fsMap).map((path) => ({ path }));
expect(discoverDjangoRootUrls(files, undefined, makeReader(fsMap))).toEqual([
'backend/myproj/urls.py',
]);
});
it('discovers every project in a monorepo with multiple manage.py files', () => {
const fsMap: Record<string, string> = {
'serviceA/manage.py': manageFor('svca.settings'),
'serviceA/svca/settings.py': `ROOT_URLCONF = 'svca.urls'\n`,
'serviceA/svca/urls.py': `urlpatterns = []\n`,
'serviceB/manage.py': manageFor('svcb.settings'),
'serviceB/svcb/settings.py': `ROOT_URLCONF = 'svcb.urls'\n`,
'serviceB/svcb/urls.py': `urlpatterns = []\n`,
};
const files = Object.keys(fsMap).map((path) => ({ path }));
expect(discoverDjangoRootUrls(files, undefined, makeReader(fsMap))).toEqual([
'serviceA/svca/urls.py',
'serviceB/svcb/urls.py',
]);
});
it('returns an empty array when there is no manage.py', () => {
const fsMap: Record<string, string> = {
'myproj/settings.py': `ROOT_URLCONF = 'myproj.urls'\n`,
'myproj/urls.py': `urlpatterns = []\n`,
};
const files = Object.keys(fsMap).map((path) => ({ path }));
expect(discoverDjangoRootUrls(files, undefined, makeReader(fsMap))).toEqual([]);
});
it('returns an empty array when ROOT_URLCONF cannot be found in settings', () => {
const fsMap: Record<string, string> = {
'manage.py': MANAGE_PY,
'myproj/settings.py': `DEBUG = True\n`,
'myproj/urls.py': `urlpatterns = []\n`,
};
const files = Object.keys(fsMap).map((path) => ({ path }));
expect(discoverDjangoRootUrls(files, undefined, makeReader(fsMap))).toEqual([]);
});
});