contract-driven-delivery 2.1.3 → 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.
@@ -0,0 +1,671 @@
1
+ #!/usr/bin/env python3
2
+ """Code-vs-contract API conformance check.
3
+
4
+ The other API validator (validate_api_semantic.py) only checks that the
5
+ contract *document* is internally well formed. It never looks at code, so
6
+ frontend and backend can both drift away from the contract without anything
7
+ failing. In a workflow where no human reviews the contract by hand, that
8
+ markdown is only worth what a machine can enforce against real code.
9
+
10
+ This validator closes that gap. It:
11
+ 1. reads the authoritative endpoint table from contracts/api/api-contract.md
12
+ 2. scans backend source for route declarations (Express/Koa/Fastify/NestJS,
13
+ Flask/FastAPI/Django, Spring, Go net/http & chi/gin, Laravel, Rails-ish)
14
+ 3. scans frontend source for HTTP call sites (fetch/axios/ky/$http/api.*)
15
+ 4. diffs both against the contract and reports drift
16
+
17
+ It is intentionally heuristic and stack-agnostic (regex, no per-framework
18
+ parser). To avoid false positives on the many repos that ship this kit, it is
19
+ OFF unless `.cdd/conformance.json` exists with `"enabled": true`. When the
20
+ config is absent it prints a one-line skip notice and exits 0.
21
+
22
+ Exit codes:
23
+ 0 conformance OK, or skipped (no/disabled config)
24
+ 1 drift found (or, in strict mode, warnings escalated to errors)
25
+
26
+ Config (.cdd/conformance.json), all keys optional:
27
+ {
28
+ "enabled": true,
29
+ "apiPrefixes": ["/api"], // only FE calls under these prefixes are checked
30
+ "sourceRoots": ["src", "app"], // dirs to scan; default: common roots that exist
31
+ "backendGlobsExt": [".py", ".js", ".ts", ".go", ".java", ".php"],
32
+ "frontendGlobsExt": [".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"],
33
+ "excludeDirs": ["node_modules", "dist", "build", ".git", "tests", "__tests__"],
34
+ "ignorePaths": ["/health", "/metrics"], // contract+code paths to ignore (supports trailing *)
35
+ "checks": {
36
+ "backendRouteNotInContract": "warning",
37
+ "contractEndpointNotImplemented": "warning",
38
+ "frontendCallNotInContract": "error"
39
+ },
40
+ "strict": false // escalate all warnings to errors
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".
55
+ """
56
+ import json
57
+ import os
58
+ import re
59
+ import sys
60
+ from pathlib import Path
61
+
62
+ CONTRACT_PATH = Path('contracts/api/api-contract.md')
63
+ CONFIG_PATH = Path('.cdd/conformance.json')
64
+
65
+ VALID_METHODS = {'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'}
66
+
67
+ DEFAULT_CONFIG = {
68
+ 'enabled': False,
69
+ 'apiPrefixes': ['/api'],
70
+ 'sourceRoots': [], # auto-detected when empty
71
+ # No '.rb' by default: Rails routing is a stateful DSL (routes.rb draw block)
72
+ # that a regex heuristic cannot parse honestly, so it is not claimed here.
73
+ 'backendGlobsExt': ['.py', '.js', '.ts', '.mjs', '.cjs', '.go', '.java', '.php'],
74
+ 'frontendGlobsExt': ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.vue', '.svelte'],
75
+ 'excludeDirs': ['node_modules', 'dist', 'build', '.git', '.cdd', 'coverage',
76
+ 'vendor', '__pycache__', '.next', '.nuxt'],
77
+ 'ignorePaths': [],
78
+ 'checks': {
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',
85
+ 'contractEndpointNotImplemented': 'warning',
86
+ 'frontendCallNotInContract': 'error',
87
+ },
88
+ 'strict': False,
89
+ }
90
+
91
+ AUTO_ROOTS = ['src', 'app', 'lib', 'server', 'backend', 'frontend', 'web', 'api', 'pages', 'packages']
92
+
93
+
94
+ # ── contract parsing (mirrors validate_api_semantic.py table logic) ───────────
95
+
96
+ def strip_frontmatter(text: str) -> str:
97
+ if text.startswith('---'):
98
+ end = text.find('\n---', 3)
99
+ if end != -1:
100
+ return text[end + 4:].lstrip('\n')
101
+ return text
102
+
103
+
104
+ def parse_table_row(line: str) -> list:
105
+ return [cell.strip() for cell in line.strip().strip('|').split('|')]
106
+
107
+
108
+ def is_separator_row(cells: list) -> bool:
109
+ return all(re.match(r'^:?-+:?$', c) for c in cells if c)
110
+
111
+
112
+ def find_contract_endpoints(lines: list) -> set:
113
+ """Return a set of (METHOD, normalized_path) from all '| method |' tables."""
114
+ in_table = False
115
+ sep_seen = False
116
+ endpoints = set()
117
+ for line in lines:
118
+ stripped = line.strip()
119
+ if not stripped or not stripped.startswith('|'):
120
+ continue
121
+ cells = parse_table_row(stripped)
122
+ if not cells:
123
+ continue
124
+ if cells[0].lower() == 'method':
125
+ in_table = True
126
+ sep_seen = False
127
+ continue
128
+ if not in_table:
129
+ continue
130
+ if not sep_seen and is_separator_row(cells):
131
+ sep_seen = True
132
+ continue
133
+ if len(cells) < 2 or not any(cells):
134
+ continue
135
+ method = cells[0].upper()
136
+ path = cells[1]
137
+ if method not in VALID_METHODS or not path.startswith('/'):
138
+ continue
139
+ endpoints.add((method, normalize_path(path)))
140
+ return endpoints
141
+
142
+
143
+ # ── path normalization ────────────────────────────────────────────────────────
144
+
145
+ PARAM_PATTERNS = [
146
+ re.compile(r'\$\{[^}/]*\}'), # ${id} (js template literal) — before {id}
147
+ re.compile(r':[A-Za-z_][\w]*'), # :id (express/rails)
148
+ re.compile(r'\{[^}/]*\}'), # {id} (flask/fastapi/spring)
149
+ re.compile(r'<[^>/]*>'), # <int:id> (django/flask)
150
+ re.compile(r'\*\*?'), # wildcard segments
151
+ ]
152
+
153
+
154
+ def normalize_path(path: str) -> str:
155
+ """Collapse route params and template interpolations to a single token so
156
+ `/users/:id`, `/users/{id}`, and `/users/${x}` all compare equal."""
157
+ # strip query string / hash
158
+ path = path.split('?', 1)[0].split('#', 1)[0]
159
+ for pat in PARAM_PATTERNS:
160
+ path = pat.sub('{}', path)
161
+ # template literal leftovers like /users/`+id+` -> treat remainder as param
162
+ path = re.sub(r'`.*$', '{}', path)
163
+ if not path.startswith('/'):
164
+ path = '/' + path
165
+ if len(path) > 1:
166
+ path = path.rstrip('/')
167
+ # collapse duplicate slashes
168
+ path = re.sub(r'/{2,}', '/', path)
169
+ return path
170
+
171
+
172
+ def matches_ignore(path: str, ignore_list: list) -> bool:
173
+ for ig in ignore_list:
174
+ ig_norm = normalize_path(ig.rstrip('*'))
175
+ if ig.endswith('*'):
176
+ if path == ig_norm or path.startswith(ig_norm.rstrip('/') + '/') or path.startswith(ig_norm):
177
+ return True
178
+ elif path == normalize_path(ig):
179
+ return True
180
+ return False
181
+
182
+
183
+ def under_api_prefix(path: str, prefixes: list) -> bool:
184
+ if not prefixes:
185
+ return True
186
+ for p in prefixes:
187
+ pn = normalize_path(p)
188
+ if path == pn or path.startswith(pn.rstrip('/') + '/'):
189
+ return True
190
+ return False
191
+
192
+
193
+ # ── source scanning ────────────────────────────────────────────────────────────
194
+
195
+ # Backend route patterns grouped by language. Only the patterns for a file's
196
+ # extension are applied to it, so a Python Flask/Django pattern can never match a
197
+ # PHP or JS file (cross-language false matches were polluting results, e.g. the
198
+ # Flask route regex firing on a Laravel `Route::match` line).
199
+ _JS_BACKEND = [
200
+ # Express / Koa / Fastify: app.get('/x'), router.post("/x").
201
+ # Client idioms (api/http/client/request/...) are intentionally excluded —
202
+ # those are frontend calls, not server routes; counting them as backend
203
+ # would silently satisfy `contractEndpointNotImplemented`.
204
+ (re.compile(r'\b(?:app|router|server|fastify|route|routes)\.(get|post|put|delete|patch|options|head|all)\s*\(\s*[\'"`]([^\'"`]+)[\'"`]', re.I), 'verb_first'),
205
+ ]
206
+
207
+ # NestJS: @Controller('users') class prefix + @Get(':id') method decorators.
208
+ # Handled by a dedicated two-pass scanner (scan_nestjs) since the route path is
209
+ # the join of the controller prefix and the per-method decorator argument.
210
+ NEST_CONTROLLER_RE = re.compile(r'@Controller\s*\(\s*[\'"`]?([^\'"`)]*)[\'"`]?\s*\)', re.I)
211
+ NEST_METHOD_RE = re.compile(r'@(Get|Post|Put|Delete|Patch|Options|Head|All)\s*\(\s*[\'"`]?([^\'"`)]*)[\'"`]?\s*\)', re.I)
212
+ _PY_BACKEND = [
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'),
220
+ # Django urls: path("x/", ...) re_path(r"^x/$", ...)
221
+ (re.compile(r'\b(?:path|re_path|url)\s*\(\s*r?[\'"]([^\'"]+)[\'"]', re.I), 'path_only'),
222
+ ]
223
+ _JAVA_BACKEND = [
224
+ # Spring: @GetMapping("/x")
225
+ (re.compile(r'@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*(?:value\s*=\s*)?[\'"]([^\'"]+)[\'"]', re.I), 'verb_first'),
226
+ # Spring: @RequestMapping(value="/x", method=RequestMethod.POST) — method (if
227
+ # present) is parsed from the call tail so method drift is not wildcarded.
228
+ (re.compile(r'@RequestMapping\s*\(\s*(?:value\s*=\s*)?[\'"]([^\'"]+)[\'"]([^)]*)', re.I), 'spring_request_mapping'),
229
+ ]
230
+ _GO_BACKEND = [
231
+ # chi/gin/echo/mux: r.Get("/x", ...) router.POST("/x", ...) mux.HandleFunc("/x", ...)
232
+ (re.compile(r'\b\w+\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(\s*"([^"]+)"', re.I), 'verb_first'),
233
+ (re.compile(r'\b\w+\.HandleFunc\s*\(\s*"([^"]+)"', re.I), 'path_only'),
234
+ ]
235
+ _PHP_BACKEND = [
236
+ # Laravel verb form: Route::get('/x', ...)
237
+ (re.compile(r'\bRoute::(get|post|put|delete|patch|options|any)\s*\(\s*[\'"]([^\'"]+)[\'"]', re.I), 'verb_first'),
238
+ # Laravel array form: Route::match(['get','post'], '/x', ...)
239
+ (re.compile(r'\bRoute::match\s*\(\s*\[([^\]]*)\]\s*,\s*[\'"]([^\'"]+)[\'"]', re.I), 'laravel_match'),
240
+ ]
241
+
242
+ BACKEND_PATTERNS_BY_EXT = {
243
+ '.js': _JS_BACKEND, '.jsx': _JS_BACKEND, '.mjs': _JS_BACKEND, '.cjs': _JS_BACKEND,
244
+ '.ts': _JS_BACKEND, '.tsx': _JS_BACKEND,
245
+ '.py': _PY_BACKEND,
246
+ '.java': _JAVA_BACKEND,
247
+ '.go': _GO_BACKEND,
248
+ '.php': _PHP_BACKEND,
249
+ }
250
+
251
+ FLASK_METHODS_RE = re.compile(r'methods\s*=\s*\[([^\]]*)\]', re.I)
252
+ SPRING_METHOD_RE = re.compile(r'method\s*=\s*\{?([^)}]*)', re.I)
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
+
275
+ # Frontend HTTP calls -> list of (method_or_None, raw_path). Only scanned in
276
+ # files with a frontend extension. The path capture allows ${...} template
277
+ # params (normalize_path collapses them) but stops at the closing quote/backtick,
278
+ # a paren, or whitespace.
279
+ _FE_PATH = r"([^`'\")\s]+)"
280
+ _FE_EXTS = {'.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.vue', '.svelte'}
281
+ FRONTEND_PATTERNS = [
282
+ # axios.get('/x'), ky.post(`/x`), http.put("/x"), $http.delete('/x'), client.patch('/x')
283
+ (re.compile(r'\b(?:axios|ky|http|\$http|api|client|request|httpClient|fetcher)\.(get|post|put|delete|patch|head|options)\s*\(\s*[`\'"]' + _FE_PATH, re.I), 'verb_first'),
284
+ # fetch('/x', { method: 'POST' }) — method parsed from the options object below
285
+ (re.compile(r'\bfetch\s*\(\s*[`\'"]' + _FE_PATH, re.I), 'fetch'),
286
+ # axios({ url: '/x', method: 'post' }) — method parsed from a nearby window so
287
+ # config-object method drift is caught instead of wildcarded.
288
+ (re.compile(r'\burl\s*:\s*[`\'"]' + _FE_PATH, re.I), 'config_object'),
289
+ # useFetch('/x') / useSWR('/x') — no method on the call site; method-agnostic.
290
+ (re.compile(r'\b(?:useFetch|useSWR|useQuery)\s*\(\s*[`\'"]' + _FE_PATH, re.I), 'path_only'),
291
+ ]
292
+
293
+ # Look a short window on EITHER side of a config-object `url:` for `method:`
294
+ # (the key order in axios({ method, url }) is not fixed).
295
+ OBJECT_METHOD_RE = re.compile(r'method\s*:\s*[`\'"](\w+)[`\'"]', re.I)
296
+ OBJECT_METHOD_WINDOW = 200
297
+
298
+
299
+ def iter_source_files(roots, exts, exclude_dirs):
300
+ seen = set()
301
+ excl = set(exclude_dirs)
302
+ for root in roots:
303
+ if not os.path.isdir(root):
304
+ continue
305
+ for dirpath, dirnames, filenames in os.walk(root):
306
+ dirnames[:] = [d for d in dirnames if d not in excl and not d.startswith('.')]
307
+ for fn in filenames:
308
+ ext = os.path.splitext(fn)[1]
309
+ if ext not in exts:
310
+ continue
311
+ full = os.path.join(dirpath, fn)
312
+ if full in seen:
313
+ continue
314
+ seen.add(full)
315
+ yield full
316
+
317
+
318
+ def looks_like_test(path: str) -> bool:
319
+ base = os.path.basename(path).lower()
320
+ return ('.test.' in base or '.spec.' in base or base.startswith('test_')
321
+ or '/tests/' in path.replace('\\', '/') or '/__tests__/' in path.replace('\\', '/'))
322
+
323
+
324
+ def _join_route(prefix: str, suffix: str) -> str:
325
+ parts = [p.strip('/') for p in (prefix, suffix) if p and p.strip('/')]
326
+ return normalize_path('/' + '/'.join(parts)) if parts else normalize_path('/')
327
+
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
+
373
+ def scan_nestjs(text: str):
374
+ """Yield (METHOD, normalized_path) for NestJS controllers in one file.
375
+
376
+ Each method decorator's path is joined with the nearest preceding
377
+ @Controller(prefix). This is the documented NestJS shape; it deliberately
378
+ does not try to resolve dynamic prefixes or RouterModule registrations."""
379
+ controllers = [(m.start(), m.group(1) or '') for m in NEST_CONTROLLER_RE.finditer(text)]
380
+ if not controllers:
381
+ return
382
+ for mm in NEST_METHOD_RE.finditer(text):
383
+ prefix = ''
384
+ for pos, pfx in controllers:
385
+ if pos < mm.start():
386
+ prefix = pfx
387
+ else:
388
+ break
389
+ method = mm.group(1).upper()
390
+ method = 'ANY' if method == 'ALL' else method
391
+ yield (method, _join_route(prefix, mm.group(2) or ''))
392
+
393
+
394
+ def scan_backend(roots, exts, exclude_dirs):
395
+ """Return set of (METHOD, normalized_path). METHOD may be 'ANY'."""
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
+
427
+ for path in iter_source_files(roots, exts, exclude_dirs):
428
+ if looks_like_test(path):
429
+ continue
430
+ ext = os.path.splitext(path)[1].lower()
431
+ patterns = BACKEND_PATTERNS_BY_EXT.get(ext)
432
+ if not patterns:
433
+ 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 {}
442
+ if ext in ('.ts', '.tsx', '.js', '.mjs', '.cjs'):
443
+ routes.update(scan_nestjs(text))
444
+ for pat, kind in patterns:
445
+ for m in pat.finditer(text):
446
+ if kind == 'verb_first':
447
+ method = m.group(1).upper()
448
+ raw = m.group(2)
449
+ method = 'ANY' if method == 'ALL' else method
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)))
457
+ elif kind == 'flask_route':
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)
462
+ mm = FLASK_METHODS_RE.search(tail)
463
+ if mm:
464
+ for meth in re.findall(r'[A-Za-z]+', mm.group(1)):
465
+ if meth.upper() in VALID_METHODS:
466
+ routes.add((meth.upper(), full))
467
+ else:
468
+ routes.add(('GET', full))
469
+ elif kind == 'path_only':
470
+ routes.add(('ANY', normalize_path(m.group(1))))
471
+ elif kind == 'laravel_match':
472
+ methods_raw = m.group(1)
473
+ raw = m.group(2)
474
+ found = [meth.upper() for meth in re.findall(r'[A-Za-z]+', methods_raw)
475
+ if meth.upper() in VALID_METHODS]
476
+ for meth in (found or ['ANY']):
477
+ routes.add((meth, normalize_path(raw)))
478
+ elif kind == 'spring_request_mapping':
479
+ raw = m.group(1)
480
+ tail = m.group(2) or ''
481
+ mm = SPRING_METHOD_RE.search(tail)
482
+ methods = []
483
+ if mm:
484
+ # method=RequestMethod.POST or method={RequestMethod.GET, RequestMethod.POST}
485
+ methods = [tok.upper() for tok in re.findall(r'[A-Za-z]+', mm.group(1))
486
+ if tok.upper() in VALID_METHODS]
487
+ for meth in (methods or ['ANY']):
488
+ routes.add((meth, normalize_path(raw)))
489
+ return routes
490
+
491
+
492
+ def scan_frontend(roots, exts, exclude_dirs):
493
+ calls = set()
494
+ for path in iter_source_files(roots, exts, exclude_dirs):
495
+ if looks_like_test(path):
496
+ continue
497
+ if os.path.splitext(path)[1].lower() not in _FE_EXTS:
498
+ continue
499
+ try:
500
+ text = Path(path).read_text(encoding='utf-8', errors='ignore')
501
+ except OSError:
502
+ continue
503
+ for pat, kind in FRONTEND_PATTERNS:
504
+ for m in pat.finditer(text):
505
+ if kind == 'verb_first':
506
+ method = m.group(1).upper()
507
+ raw = m.group(2)
508
+ elif kind == 'fetch':
509
+ raw = m.group(1)
510
+ # Per the fetch spec the default method is GET; look a short
511
+ # window past the URL for an explicit `method:` so method
512
+ # drift (e.g. DELETE on a GET-only endpoint) is caught.
513
+ window = text[m.end():m.end() + OBJECT_METHOD_WINDOW]
514
+ mm = OBJECT_METHOD_RE.search(window)
515
+ method = mm.group(1).upper() if mm else 'GET'
516
+ elif kind == 'config_object':
517
+ raw = m.group(1)
518
+ # axios({ url, method }) — key order is not fixed, so scan a
519
+ # window on both sides of the url: token for method:.
520
+ lo = max(0, m.start() - OBJECT_METHOD_WINDOW)
521
+ window = text[lo:m.end() + OBJECT_METHOD_WINDOW]
522
+ mm = OBJECT_METHOD_RE.search(window)
523
+ method = mm.group(1).upper() if mm else 'ANY'
524
+ else: # path_only
525
+ method = 'ANY'
526
+ raw = m.group(1)
527
+ if not raw.startswith('/'):
528
+ continue # skip absolute URLs / relative non-rooted strings
529
+ calls.add((method, normalize_path(raw)))
530
+ return calls
531
+
532
+
533
+ # ── contract matching ──────────────────────────────────────────────────────────
534
+
535
+ def contract_has(method: str, path: str, contract: set) -> bool:
536
+ """A code endpoint conforms if some entry matches the path and the method
537
+ matches OR *either* side is method-agnostic ('ANY'). Path-only declarations
538
+ (Go HandleFunc, Django path(), Spring @RequestMapping) register as 'ANY' and
539
+ must therefore satisfy a concrete contract method, and vice versa."""
540
+ for c_method, c_path in contract:
541
+ if c_path != path:
542
+ continue
543
+ if method == 'ANY' or c_method == 'ANY' or c_method == method:
544
+ return True
545
+ return False
546
+
547
+
548
+ def load_config():
549
+ cfg = dict(DEFAULT_CONFIG)
550
+ if not CONFIG_PATH.exists():
551
+ return cfg, False
552
+ try:
553
+ user = json.loads(CONFIG_PATH.read_text(encoding='utf-8'))
554
+ except (OSError, ValueError) as e:
555
+ print(f'API conformance: .cdd/conformance.json is not valid JSON: {e}')
556
+ sys.exit(1)
557
+ cfg.update({k: v for k, v in user.items() if k != 'checks'})
558
+ if isinstance(user.get('checks'), dict):
559
+ merged = dict(DEFAULT_CONFIG['checks'])
560
+ merged.update(user['checks'])
561
+ cfg['checks'] = merged
562
+ return cfg, True
563
+
564
+
565
+ def resolve_roots(cfg):
566
+ roots = cfg.get('sourceRoots') or []
567
+ if roots:
568
+ return [r for r in roots if os.path.isdir(r)]
569
+ return [r for r in AUTO_ROOTS if os.path.isdir(r)]
570
+
571
+
572
+ def severity(check_name, cfg):
573
+ sev = cfg['checks'].get(check_name, 'error')
574
+ if cfg.get('strict') and sev == 'warning':
575
+ return 'error'
576
+ return sev
577
+
578
+
579
+ def main() -> None:
580
+ cfg, present = load_config()
581
+
582
+ if not present:
583
+ print('API conformance: skipped (no .cdd/conformance.json; '
584
+ 'add one with "enabled": true to enforce code-vs-contract checks).')
585
+ sys.exit(0)
586
+ if not cfg.get('enabled'):
587
+ print('API conformance: skipped (.cdd/conformance.json has "enabled": false).')
588
+ sys.exit(0)
589
+
590
+ if not CONTRACT_PATH.exists():
591
+ print(f'API conformance: contract not found: {CONTRACT_PATH}')
592
+ sys.exit(1)
593
+
594
+ body = strip_frontmatter(CONTRACT_PATH.read_text(encoding='utf-8', errors='ignore'))
595
+ contract = find_contract_endpoints(body.splitlines())
596
+ if not contract:
597
+ print('API conformance: no endpoint table found in contract; nothing to check.')
598
+ sys.exit(0)
599
+
600
+ roots = resolve_roots(cfg)
601
+ if not roots:
602
+ # Enabled but nothing to scan almost always means a mistyped/missing
603
+ # sourceRoots. Failing (not exit 0) prevents a config mistake from
604
+ # silently disabling the drift net while the gate stays green.
605
+ print('API conformance validation failed:')
606
+ print(' conformance is enabled but no source roots were found to scan; '
607
+ 'set "sourceRoots" in .cdd/conformance.json to existing directories.')
608
+ sys.exit(1)
609
+
610
+ exclude = cfg['excludeDirs']
611
+ ignore = cfg['ignorePaths']
612
+ prefixes = cfg['apiPrefixes']
613
+
614
+ backend = scan_backend(roots, set(cfg['backendGlobsExt']), exclude)
615
+ frontend = scan_frontend(roots, set(cfg['frontendGlobsExt']), exclude)
616
+
617
+ errors = []
618
+ warnings = []
619
+
620
+ def emit(check_name, message):
621
+ (errors if severity(check_name, cfg) == 'error' else warnings).append(message)
622
+
623
+ # 1. Backend routes that are not documented in the contract.
624
+ for method, path in sorted(backend):
625
+ if matches_ignore(path, ignore):
626
+ continue
627
+ if not under_api_prefix(path, prefixes):
628
+ continue
629
+ if not contract_has(method, path, contract):
630
+ emit('backendRouteNotInContract',
631
+ f'backend route {method} {path} is not in the API contract')
632
+
633
+ # 2. Contract endpoints with no backend implementation found.
634
+ for method, path in sorted(contract):
635
+ if matches_ignore(path, ignore):
636
+ continue
637
+ if not contract_has(method, path, backend):
638
+ emit('contractEndpointNotImplemented',
639
+ f'contract endpoint {method} {path} has no backend route in scanned source')
640
+
641
+ # 3. Frontend calls to paths not in the contract (the FE/BE drift case).
642
+ for method, path in sorted(frontend):
643
+ if matches_ignore(path, ignore):
644
+ continue
645
+ if not under_api_prefix(path, prefixes):
646
+ continue
647
+ if not contract_has(method, path, contract):
648
+ label = path if method == 'ANY' else f'{method} {path}'
649
+ emit('frontendCallNotInContract',
650
+ f'frontend calls {label} which is not in the API contract')
651
+
652
+ print(f'API conformance: contract={len(contract)} endpoint(s), '
653
+ f'backend={len(backend)} route(s), frontend={len(frontend)} call(s) '
654
+ f'across roots: {", ".join(roots)}')
655
+
656
+ if warnings:
657
+ print('API conformance warnings:')
658
+ for w in warnings:
659
+ print(f' {w}')
660
+
661
+ if errors:
662
+ print('API conformance validation failed:')
663
+ for e in errors:
664
+ print(f' {e}')
665
+ sys.exit(1)
666
+
667
+ print('API conformance validation passed.')
668
+
669
+
670
+ if __name__ == '__main__':
671
+ main()