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": "error",
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": "error",
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
- 'backendRouteNotInContract': 'error',
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 / APIRouter decorators: @app.get("/x"), @router.post('/x')
196
- (re.compile(r'@(?:app|router|\w+)\.(get|post|put|delete|patch|options|head)\s*\(\s*[\'"]([^\'"]+)[\'"]', re.I), 'verb_first'),
197
- # Flask: @app.route("/x", methods=["POST"]) (methods captured separately below)
198
- (re.compile(r'@(?:app|bp|blueprint|\w+)\.route\s*\(\s*[\'"]([^\'"]+)[\'"]([^)]*)', re.I), 'flask_route'),
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
- try:
319
- text = Path(path).read_text(encoding='utf-8', errors='ignore')
320
- except OSError:
321
- continue
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
- raw = m.group(1)
333
- tail = m.group(2) or ''
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(), normalize_path(raw)))
466
+ routes.add((meth.upper(), full))
339
467
  else:
340
- routes.add(('GET', normalize_path(raw)))
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':
@@ -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 | error |
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. If you use mounted routers, either declare the unprefixed paths in the
53
- contract, add the mount prefix in the route literal, or set
54
- `contractEndpointNotImplemented` to `off`.
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": "error",
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.0",
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",