contract-driven-delivery 2.2.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@
|
|
|
4
4
|
|
|
5
5
|
_No unreleased changes yet._
|
|
6
6
|
|
|
7
|
+
## [2.2.1] - 2026-06-03
|
|
8
|
+
|
|
9
|
+
Fix a class of false positives in the 2.2.0 API conformance validator that broke
|
|
10
|
+
CI on correct contracts (issue #15), and stop a heuristic blind spot from being
|
|
11
|
+
fatal by default.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Resolve Flask Blueprint `url_prefix` / FastAPI APIRouter `prefix` across
|
|
16
|
+
files (`validate_api_conformance.py`).** A route declared as
|
|
17
|
+
`@admin_bp.route("/api/logs")` on a `Blueprint(..., url_prefix="/admin")` (or a
|
|
18
|
+
`register_blueprint(bp, url_prefix=...)` in another file) was recorded as
|
|
19
|
+
`/api/logs`, so every prefixed route was flagged `backendRouteNotInContract`
|
|
20
|
+
while the matching contract endpoint was flagged
|
|
21
|
+
`contractEndpointNotImplemented` — two false errors per route against a contract
|
|
22
|
+
that was actually correct. The validator now resolves constructor prefixes per
|
|
23
|
+
file and registration prefixes across files (registration winning) and folds
|
|
24
|
+
them into the route path. Constructor scoping is **per file**, so a bare
|
|
25
|
+
`router` name reused across modules cannot collide; registration prefixes are
|
|
26
|
+
matched across files with each framework's semantics — Flask
|
|
27
|
+
`register_blueprint(url_prefix=...)` **overrides** the Blueprint's own prefix
|
|
28
|
+
while FastAPI `include_router(prefix=...)` is **additive** with the
|
|
29
|
+
`APIRouter(prefix=...)` (served as `<include>/<router>/<route>`). A name
|
|
30
|
+
registered under conflicting prefixes across files is detected and dropped (the
|
|
31
|
+
per-file constructor prefix decides) rather than guessed. The constructor regex
|
|
32
|
+
tolerates a nested-paren kwarg (`APIRouter(dependencies=[Depends(x)],
|
|
33
|
+
prefix=...)`), a module-qualified call (`flask.Blueprint(...)`,
|
|
34
|
+
`fastapi.APIRouter(...)`), and a type-annotated assignment (`router: APIRouter =
|
|
35
|
+
APIRouter(...)`); Flask 2.0 `@bp.get(...)` shorthand is covered too. An explicit
|
|
36
|
+
empty registration prefix (`register_blueprint(bp, url_prefix="")`, a deliberate
|
|
37
|
+
root mount) is preserved and overrides the constructor prefix rather than being
|
|
38
|
+
discarded as falsy. (Issue #15; hardened over three rounds of Codex/Sourcery PR
|
|
39
|
+
review.)
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **`backendRouteNotInContract` now defaults to `warning`, not `error`.** Regex
|
|
44
|
+
scanning cannot resolve every cross-file route prefix (aliased routers, the
|
|
45
|
+
Express `app.use` mount form, module-qualified `include_router(pkg.router, …)`),
|
|
46
|
+
so a scanner blind spot must not break CI on a contract that is correct. Raise it to
|
|
47
|
+
`error` (or set `"strict": true`) to enforce once a project's routing shape is
|
|
48
|
+
known to resolve cleanly. Updated in `DEFAULT_CONFIG`, the scaffolded
|
|
49
|
+
`.cdd/conformance.json`, and `docs/api-conformance.md`.
|
|
50
|
+
|
|
7
51
|
## [2.2.0] - 2026-06-02
|
|
8
52
|
|
|
9
53
|
Make enforcement live by default, add a mechanical risk-tier safety net under the
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"excludeDirs": ["node_modules", "dist", "build", ".git", ".cdd", "coverage", "vendor", "__pycache__", ".next", ".nuxt"],
|
|
9
9
|
"ignorePaths": ["/health", "/metrics"],
|
|
10
10
|
"checks": {
|
|
11
|
-
"backendRouteNotInContract": "
|
|
11
|
+
"backendRouteNotInContract": "warning",
|
|
12
12
|
"contractEndpointNotImplemented": "warning",
|
|
13
13
|
"frontendCallNotInContract": "error"
|
|
14
14
|
},
|
|
@@ -33,12 +33,25 @@ Config (.cdd/conformance.json), all keys optional:
|
|
|
33
33
|
"excludeDirs": ["node_modules", "dist", "build", ".git", "tests", "__tests__"],
|
|
34
34
|
"ignorePaths": ["/health", "/metrics"], // contract+code paths to ignore (supports trailing *)
|
|
35
35
|
"checks": {
|
|
36
|
-
"backendRouteNotInContract": "
|
|
36
|
+
"backendRouteNotInContract": "warning",
|
|
37
37
|
"contractEndpointNotImplemented": "warning",
|
|
38
38
|
"frontendCallNotInContract": "error"
|
|
39
39
|
},
|
|
40
40
|
"strict": false // escalate all warnings to errors
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
Cross-file prefix resolution: Flask Blueprint `url_prefix` and FastAPI
|
|
44
|
+
APIRouter `prefix` are declared on the router object and the routes hang off
|
|
45
|
+
that object. Constructor prefixes (`Blueprint(url_prefix=...)`,
|
|
46
|
+
`APIRouter(prefix=...)`) are resolved per file — scoped to the file so a bare
|
|
47
|
+
`router` name reused across modules cannot collide — while registration
|
|
48
|
+
prefixes (`register_blueprint(bp, url_prefix=...)`, `include_router(router,
|
|
49
|
+
prefix=...)`) are resolved across files, registration winning. The route path
|
|
50
|
+
then has the resolved prefix folded in. This keys on the local variable name,
|
|
51
|
+
so a router imported under an alias, a module-qualified registration
|
|
52
|
+
(`include_router(users.router, ...)`), or a dynamically assembled prefix is
|
|
53
|
+
not resolved; that residual heuristic gap is why `backendRouteNotInContract`
|
|
54
|
+
defaults to "warning" rather than "error".
|
|
42
55
|
"""
|
|
43
56
|
import json
|
|
44
57
|
import os
|
|
@@ -63,7 +76,12 @@ DEFAULT_CONFIG = {
|
|
|
63
76
|
'vendor', '__pycache__', '.next', '.nuxt'],
|
|
64
77
|
'ignorePaths': [],
|
|
65
78
|
'checks': {
|
|
66
|
-
|
|
79
|
+
# Heuristic regex scanning cannot resolve every cross-file route prefix
|
|
80
|
+
# (aliased routers, dynamic mounts). Defaulting this to "warning" keeps a
|
|
81
|
+
# scanner blind spot from breaking CI on a contract that is actually
|
|
82
|
+
# correct; set it to "error" (or "strict": true) to enforce once the
|
|
83
|
+
# project's routing shape is known to be fully resolvable.
|
|
84
|
+
'backendRouteNotInContract': 'warning',
|
|
67
85
|
'contractEndpointNotImplemented': 'warning',
|
|
68
86
|
'frontendCallNotInContract': 'error',
|
|
69
87
|
},
|
|
@@ -192,10 +210,13 @@ _JS_BACKEND = [
|
|
|
192
210
|
NEST_CONTROLLER_RE = re.compile(r'@Controller\s*\(\s*[\'"`]?([^\'"`)]*)[\'"`]?\s*\)', re.I)
|
|
193
211
|
NEST_METHOD_RE = re.compile(r'@(Get|Post|Put|Delete|Patch|Options|Head|All)\s*\(\s*[\'"`]?([^\'"`)]*)[\'"`]?\s*\)', re.I)
|
|
194
212
|
_PY_BACKEND = [
|
|
195
|
-
# FastAPI
|
|
196
|
-
|
|
197
|
-
#
|
|
198
|
-
(re.compile(r'@(
|
|
213
|
+
# FastAPI/APIRouter and Flask 2.0 shorthand: @app.get("/x"), @router.post('/x'),
|
|
214
|
+
# @bp.get('/x'). The receiver (group 1) is captured so a Blueprint/APIRouter
|
|
215
|
+
# url_prefix/prefix can be folded in by _apply_prefix.
|
|
216
|
+
(re.compile(r'@(\w+)\.(get|post|put|delete|patch|options|head)\s*\(\s*[\'"]([^\'"]+)[\'"]', re.I), 'py_verb_first'),
|
|
217
|
+
# Flask: @app.route("/x", methods=["POST"]) (methods captured separately below).
|
|
218
|
+
# Group 1 is the receiver (app/bp/...), group 2 the path, group 3 the call tail.
|
|
219
|
+
(re.compile(r'@(\w+)\.route\s*\(\s*[\'"]([^\'"]+)[\'"]([^)]*)', re.I), 'flask_route'),
|
|
199
220
|
# Django urls: path("x/", ...) re_path(r"^x/$", ...)
|
|
200
221
|
(re.compile(r'\b(?:path|re_path|url)\s*\(\s*r?[\'"]([^\'"]+)[\'"]', re.I), 'path_only'),
|
|
201
222
|
]
|
|
@@ -230,6 +251,27 @@ BACKEND_PATTERNS_BY_EXT = {
|
|
|
230
251
|
FLASK_METHODS_RE = re.compile(r'methods\s*=\s*\[([^\]]*)\]', re.I)
|
|
231
252
|
SPRING_METHOD_RE = re.compile(r'method\s*=\s*\{?([^)}]*)', re.I)
|
|
232
253
|
|
|
254
|
+
# Cross-file route-prefix resolution for Flask Blueprint / FastAPI APIRouter.
|
|
255
|
+
# The argument capture `(?:[^()]|\([^()]*\))*` tolerates one level of nested
|
|
256
|
+
# parens so a kwarg like `dependencies=[Depends(auth)]` before `prefix=` does not
|
|
257
|
+
# truncate the capture. `(?:\w+\.)*` accepts qualified calls (flask.Blueprint,
|
|
258
|
+
# fastapi.APIRouter). `(?::[^=\n]+)?` tolerates an annotated assignment
|
|
259
|
+
# (`router: APIRouter = APIRouter(...)`) so the prefix is keyed to the receiver
|
|
260
|
+
# (`router`) rather than the type name. Constructor prefixes are resolved per
|
|
261
|
+
# file (see scan_backend) so a bare `router` reused across modules cannot collide.
|
|
262
|
+
# Constructor: admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
|
263
|
+
# router: APIRouter = APIRouter(prefix="/admin")
|
|
264
|
+
_CALL_ARGS = r'((?:[^()]|\([^()]*\))*)'
|
|
265
|
+
BLUEPRINT_CTOR_RE = re.compile(r'(\w+)\s*(?::[^=\n]+)?=\s*(?:\w+\.)*(?:Blueprint|APIRouter)\s*\(' + _CALL_ARGS + r'\)', re.S)
|
|
266
|
+
# Registration (cross-file): group 1 is the verb (register_blueprint = Flask,
|
|
267
|
+
# url_prefix OVERRIDES the Blueprint's own; include_router = FastAPI, prefix is
|
|
268
|
+
# ADDITIVE with the APIRouter prefix), group 2 the receiver, group 3 the args.
|
|
269
|
+
# app.register_blueprint(admin_bp, url_prefix="/admin")
|
|
270
|
+
# app.include_router(router, prefix="/admin")
|
|
271
|
+
BLUEPRINT_REG_RE = re.compile(r'\b(register_blueprint|include_router)\s*\(\s*(\w+)\s*' + _CALL_ARGS + r'\)', re.S)
|
|
272
|
+
# url_prefix="/x" or prefix='/x' kwarg, read out of either call's argument list.
|
|
273
|
+
PREFIX_KWARG_RE = re.compile(r'(?:url_prefix|prefix)\s*=\s*[\'"]([^\'"]*)[\'"]')
|
|
274
|
+
|
|
233
275
|
# Frontend HTTP calls -> list of (method_or_None, raw_path). Only scanned in
|
|
234
276
|
# files with a frontend extension. The path capture allows ${...} template
|
|
235
277
|
# params (normalize_path collapses them) but stops at the closing quote/backtick,
|
|
@@ -284,6 +326,50 @@ def _join_route(prefix: str, suffix: str) -> str:
|
|
|
284
326
|
return normalize_path('/' + '/'.join(parts)) if parts else normalize_path('/')
|
|
285
327
|
|
|
286
328
|
|
|
329
|
+
def _prefix_kwargs(text: str, pattern: re.Pattern) -> dict:
|
|
330
|
+
"""Collect {var: prefix} from constructor calls in `text`.
|
|
331
|
+
|
|
332
|
+
Presence-checks the kwarg rather than truthiness so an explicit empty prefix
|
|
333
|
+
(`url_prefix=""` / `prefix=""`, a deliberate root mount) is preserved and can
|
|
334
|
+
override a value elsewhere, instead of being silently discarded."""
|
|
335
|
+
out = {}
|
|
336
|
+
for m in pattern.finditer(text):
|
|
337
|
+
km = PREFIX_KWARG_RE.search(m.group(2))
|
|
338
|
+
if km is not None:
|
|
339
|
+
out[m.group(1)] = km.group(1)
|
|
340
|
+
return out
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _apply_prefix(reg_prefixes: dict, local_ctor: dict, receiver: str, raw: str) -> str:
|
|
344
|
+
"""Fold a resolved Blueprint/APIRouter prefix into a route path.
|
|
345
|
+
|
|
346
|
+
Combines a *file-local* constructor prefix (so a bare `router` reused in
|
|
347
|
+
another module cannot fold these routes under the wrong prefix) with a
|
|
348
|
+
cross-file registration prefix, respecting each framework's semantics:
|
|
349
|
+
- Flask `register_blueprint(bp, url_prefix=...)` OVERRIDES the Blueprint's
|
|
350
|
+
own `url_prefix`.
|
|
351
|
+
- FastAPI `include_router(router, prefix=...)` is ADDITIVE — the served
|
|
352
|
+
path is `<include prefix>/<APIRouter prefix>/<route>`.
|
|
353
|
+
`reg_prefixes` maps receiver -> (verb, prefix); ambiguous receivers (the same
|
|
354
|
+
name registered with conflicting prefixes across files) are dropped upstream
|
|
355
|
+
so the per-file constructor prefix wins instead of a guessed one. An explicit
|
|
356
|
+
empty registration prefix (`register_blueprint(bp, url_prefix="")`) is a
|
|
357
|
+
deliberate root mount and still shadows the constructor prefix — the route
|
|
358
|
+
resolves to root, not the constructor's value. Receivers with no resolved
|
|
359
|
+
prefix (the Flask `app` itself, or a shape the heuristic cannot follow) pass
|
|
360
|
+
through unchanged — that residual gap is why backendRouteNotInContract
|
|
361
|
+
defaults to a warning."""
|
|
362
|
+
ctor = local_ctor.get(receiver)
|
|
363
|
+
reg = reg_prefixes.get(receiver)
|
|
364
|
+
if reg is not None:
|
|
365
|
+
verb, rprefix = reg
|
|
366
|
+
# FastAPI: include_router prefix + APIRouter prefix. Flask: override.
|
|
367
|
+
prefix = _join_route(rprefix, ctor) if (verb == 'include_router' and ctor) else rprefix
|
|
368
|
+
else:
|
|
369
|
+
prefix = ctor
|
|
370
|
+
return _join_route(prefix, raw) if prefix else normalize_path(raw)
|
|
371
|
+
|
|
372
|
+
|
|
287
373
|
def scan_nestjs(text: str):
|
|
288
374
|
"""Yield (METHOD, normalized_path) for NestJS controllers in one file.
|
|
289
375
|
|
|
@@ -308,6 +394,36 @@ def scan_nestjs(text: str):
|
|
|
308
394
|
def scan_backend(roots, exts, exclude_dirs):
|
|
309
395
|
"""Return set of (METHOD, normalized_path). METHOD may be 'ANY'."""
|
|
310
396
|
routes = set()
|
|
397
|
+
# Pre-pass: read every Python file once (caching the text so the main loop
|
|
398
|
+
# below does not re-read it — these two passes used to double the .py IO) and
|
|
399
|
+
# resolve cross-file *registration* prefixes (register_blueprint /
|
|
400
|
+
# include_router). Constructor prefixes are resolved per file in the main
|
|
401
|
+
# loop so a bare `router` name reused across modules cannot collide.
|
|
402
|
+
py_text = {}
|
|
403
|
+
reg_seen = {} # receiver -> {(verb, prefix), ...} (collision detection)
|
|
404
|
+
for path in iter_source_files(roots, {'.py'}, exclude_dirs):
|
|
405
|
+
if looks_like_test(path):
|
|
406
|
+
continue
|
|
407
|
+
try:
|
|
408
|
+
text = Path(path).read_text(encoding='utf-8', errors='ignore')
|
|
409
|
+
except OSError:
|
|
410
|
+
continue
|
|
411
|
+
py_text[path] = text
|
|
412
|
+
for m in BLUEPRINT_REG_RE.finditer(text):
|
|
413
|
+
km = PREFIX_KWARG_RE.search(m.group(3))
|
|
414
|
+
if km is not None: # presence, not truthiness: keep an explicit ""
|
|
415
|
+
reg_seen.setdefault(m.group(2), set()).add((m.group(1).lower(), km.group(1)))
|
|
416
|
+
# Keep only receivers registered with a single, unambiguous prefix. A name
|
|
417
|
+
# registered under conflicting prefixes across files (e.g. two modules each
|
|
418
|
+
# `include_router(router, prefix=...)`) is dropped so the per-file
|
|
419
|
+
# constructor prefix decides rather than whichever was scanned last. When the
|
|
420
|
+
# routers carry no constructor prefix either, that receiver is left
|
|
421
|
+
# unresolved (a warning under the default) rather than mis-attributed —
|
|
422
|
+
# resolving it would need import tracking (which module each bare `router`
|
|
423
|
+
# came from), which this regex heuristic deliberately does not attempt.
|
|
424
|
+
reg_prefixes = {var: next(iter(entries)) for var, entries in reg_seen.items()
|
|
425
|
+
if len({prefix for _, prefix in entries}) == 1}
|
|
426
|
+
|
|
311
427
|
for path in iter_source_files(roots, exts, exclude_dirs):
|
|
312
428
|
if looks_like_test(path):
|
|
313
429
|
continue
|
|
@@ -315,10 +431,14 @@ def scan_backend(roots, exts, exclude_dirs):
|
|
|
315
431
|
patterns = BACKEND_PATTERNS_BY_EXT.get(ext)
|
|
316
432
|
if not patterns:
|
|
317
433
|
continue
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
434
|
+
text = py_text.get(path)
|
|
435
|
+
if text is None:
|
|
436
|
+
try:
|
|
437
|
+
text = Path(path).read_text(encoding='utf-8', errors='ignore')
|
|
438
|
+
except OSError:
|
|
439
|
+
continue
|
|
440
|
+
# Constructor prefixes are scoped to this file (see _apply_prefix).
|
|
441
|
+
local_ctor = _prefix_kwargs(text, BLUEPRINT_CTOR_RE) if ext == '.py' else {}
|
|
322
442
|
if ext in ('.ts', '.tsx', '.js', '.mjs', '.cjs'):
|
|
323
443
|
routes.update(scan_nestjs(text))
|
|
324
444
|
for pat, kind in patterns:
|
|
@@ -328,16 +448,24 @@ def scan_backend(roots, exts, exclude_dirs):
|
|
|
328
448
|
raw = m.group(2)
|
|
329
449
|
method = 'ANY' if method == 'ALL' else method
|
|
330
450
|
routes.add((method, normalize_path(raw)))
|
|
451
|
+
elif kind == 'py_verb_first':
|
|
452
|
+
receiver = m.group(1)
|
|
453
|
+
method = m.group(2).upper()
|
|
454
|
+
raw = m.group(3)
|
|
455
|
+
method = 'ANY' if method == 'ALL' else method
|
|
456
|
+
routes.add((method, _apply_prefix(reg_prefixes, local_ctor, receiver, raw)))
|
|
331
457
|
elif kind == 'flask_route':
|
|
332
|
-
|
|
333
|
-
|
|
458
|
+
receiver = m.group(1)
|
|
459
|
+
raw = m.group(2)
|
|
460
|
+
tail = m.group(3) or ''
|
|
461
|
+
full = _apply_prefix(reg_prefixes, local_ctor, receiver, raw)
|
|
334
462
|
mm = FLASK_METHODS_RE.search(tail)
|
|
335
463
|
if mm:
|
|
336
464
|
for meth in re.findall(r'[A-Za-z]+', mm.group(1)):
|
|
337
465
|
if meth.upper() in VALID_METHODS:
|
|
338
|
-
routes.add((meth.upper(),
|
|
466
|
+
routes.add((meth.upper(), full))
|
|
339
467
|
else:
|
|
340
|
-
routes.add(('GET',
|
|
468
|
+
routes.add(('GET', full))
|
|
341
469
|
elif kind == 'path_only':
|
|
342
470
|
routes.add(('ANY', normalize_path(m.group(1))))
|
|
343
471
|
elif kind == 'laravel_match':
|
package/docs/api-conformance.md
CHANGED
|
@@ -18,7 +18,7 @@ backend routes and frontend call sites and diffs them against the contract.
|
|
|
18
18
|
|
|
19
19
|
| Check | Meaning | Default severity |
|
|
20
20
|
|---|---|---|
|
|
21
|
-
| `backendRouteNotInContract` | a route is declared in backend code but not in the contract |
|
|
21
|
+
| `backendRouteNotInContract` | a route is declared in backend code but not in the contract | warning |
|
|
22
22
|
| `contractEndpointNotImplemented` | a contract endpoint has no backend route in scanned source | warning |
|
|
23
23
|
| `frontendCallNotInContract` | the frontend calls a path/method that is not in the contract | error |
|
|
24
24
|
|
|
@@ -34,6 +34,12 @@ a parser for every framework. It recognizes:
|
|
|
34
34
|
(`@GetMapping`, and `@RequestMapping(..., method=...)` with the method parsed),
|
|
35
35
|
Go (chi/gin/echo/mux + `HandleFunc`), and Laravel (`Route::get` and
|
|
36
36
|
`Route::match([...])`).
|
|
37
|
+
- **Flask Blueprint / FastAPI APIRouter prefixes** are resolved across files: a
|
|
38
|
+
pre-pass maps each router variable to its prefix — from the constructor kwarg
|
|
39
|
+
(`Blueprint(..., url_prefix="/admin")`, `APIRouter(prefix="/admin")`) and/or the
|
|
40
|
+
registration call (`register_blueprint(bp, url_prefix=...)`,
|
|
41
|
+
`include_router(router, prefix=...)`) — and folds it into every route on that
|
|
42
|
+
router. The registration-site prefix wins over the constructor's.
|
|
37
43
|
- **Frontend**: `fetch` (method read from the options object; defaults to GET),
|
|
38
44
|
`axios`/`ky`/`$http`/`client`/`http`/`api.*` verb calls, the
|
|
39
45
|
`axios({ url, method })` config-object form, and `useFetch`/`useSWR`/`useQuery`.
|
|
@@ -49,12 +55,43 @@ proof.
|
|
|
49
55
|
`backendGlobsExt` and Rails routes are not claimed.
|
|
50
56
|
- **Mounted Express routers** (`app.use('/api', router)` + `router.get('/users')`)
|
|
51
57
|
record only `/users`; the validator does not resolve the mount prefix across
|
|
52
|
-
files.
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
files. (Flask Blueprint and FastAPI APIRouter prefixes *are* resolved — see
|
|
59
|
+
above — but the Express `app.use` mount form is not.) If you use mounted
|
|
60
|
+
routers, either declare the unprefixed paths in the contract, add the mount
|
|
61
|
+
prefix in the route literal, or set `contractEndpointNotImplemented` to `off`.
|
|
62
|
+
- **Prefix resolution keys on the local variable name.** Constructor prefixes
|
|
63
|
+
(`Blueprint(url_prefix=...)`, `APIRouter(prefix=...)`) are scoped per file, so a
|
|
64
|
+
`router` reused across modules does not collide; registration prefixes
|
|
65
|
+
(`register_blueprint`/`include_router`) are matched across files, with Flask's
|
|
66
|
+
override and FastAPI's additive (`include_router` prefix + `APIRouter` prefix)
|
|
67
|
+
semantics each respected. What is **not** resolved: a router imported under an
|
|
68
|
+
alias (`from x import router as r`), or a name registered under conflicting
|
|
69
|
+
prefixes across files — the latter is detected and dropped (so the per-file
|
|
70
|
+
constructor prefix decides) rather than guessed.
|
|
71
|
+
- **Registrations resolved by a shared bare receiver name can cross modules.**
|
|
72
|
+
Because a registration is keyed by the variable name (`router`, `bp`), not the
|
|
73
|
+
module it was imported from, the following are not resolved — they all need
|
|
74
|
+
import tracking the regex heuristic deliberately does not attempt:
|
|
75
|
+
- **Module-qualified registration**: `include_router(users.router,
|
|
76
|
+
prefix="/api")` (router referenced through an imported module) is not matched.
|
|
77
|
+
- **Same name, conflicting prefixes**: two modules each `include_router(router,
|
|
78
|
+
prefix=...)` under different prefixes — the ambiguous registration is dropped,
|
|
79
|
+
so those routes are left unresolved (a warning) rather than mis-attributed.
|
|
80
|
+
- **One registration leaking onto a same-named local router**: if `users.py`
|
|
81
|
+
exports `router` mounted under `/api`, while `admin.py` has its own file-local
|
|
82
|
+
`router = APIRouter(prefix="/admin")` mounted without a prefix, the `/api`
|
|
83
|
+
registration is applied to `admin.py`'s routes too (scanned as `/api/admin/…`
|
|
84
|
+
though FastAPI serves `/admin/…`). To avoid this, give routers distinct names
|
|
85
|
+
or set `backendRouteNotInContract`/`contractEndpointNotImplemented` to
|
|
86
|
+
`warning` (the default) so it does not fail CI.
|
|
55
87
|
- **Dynamic routes** built from variables or registered via framework modules
|
|
56
88
|
(NestJS `RouterModule`, dynamic prefixes) are not detected.
|
|
57
89
|
|
|
90
|
+
Because of these residual blind spots, `backendRouteNotInContract` **defaults to
|
|
91
|
+
`warning`**: a route the scanner mislocates must not break CI on a contract that
|
|
92
|
+
is actually correct. Raise it to `error` (or set `"strict": true`) once your
|
|
93
|
+
project's routing shape is known to resolve cleanly.
|
|
94
|
+
|
|
58
95
|
## Enabling it
|
|
59
96
|
|
|
60
97
|
It is **off unless `.cdd/conformance.json` exists with `"enabled": true`**, so it
|
|
@@ -68,7 +105,7 @@ flip it on:
|
|
|
68
105
|
"sourceRoots": ["src", "app"],
|
|
69
106
|
"ignorePaths": ["/health", "/metrics"],
|
|
70
107
|
"checks": {
|
|
71
|
-
"backendRouteNotInContract": "
|
|
108
|
+
"backendRouteNotInContract": "warning",
|
|
72
109
|
"contractEndpointNotImplemented": "warning",
|
|
73
110
|
"frontendCallNotInContract": "error"
|
|
74
111
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contract-driven-delivery",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Contract-driven delivery kit for AI coding agents with deterministic context indexes, manifest-backed read-scope governance, and orchestrated contracts-first delivery.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"contract-driven",
|