contract-driven-delivery 2.1.3 → 2.2.0

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,543 @@
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": "error",
37
+ "contractEndpointNotImplemented": "warning",
38
+ "frontendCallNotInContract": "error"
39
+ },
40
+ "strict": false // escalate all warnings to errors
41
+ }
42
+ """
43
+ import json
44
+ import os
45
+ import re
46
+ import sys
47
+ from pathlib import Path
48
+
49
+ CONTRACT_PATH = Path('contracts/api/api-contract.md')
50
+ CONFIG_PATH = Path('.cdd/conformance.json')
51
+
52
+ VALID_METHODS = {'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'}
53
+
54
+ DEFAULT_CONFIG = {
55
+ 'enabled': False,
56
+ 'apiPrefixes': ['/api'],
57
+ 'sourceRoots': [], # auto-detected when empty
58
+ # No '.rb' by default: Rails routing is a stateful DSL (routes.rb draw block)
59
+ # that a regex heuristic cannot parse honestly, so it is not claimed here.
60
+ 'backendGlobsExt': ['.py', '.js', '.ts', '.mjs', '.cjs', '.go', '.java', '.php'],
61
+ 'frontendGlobsExt': ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.vue', '.svelte'],
62
+ 'excludeDirs': ['node_modules', 'dist', 'build', '.git', '.cdd', 'coverage',
63
+ 'vendor', '__pycache__', '.next', '.nuxt'],
64
+ 'ignorePaths': [],
65
+ 'checks': {
66
+ 'backendRouteNotInContract': 'error',
67
+ 'contractEndpointNotImplemented': 'warning',
68
+ 'frontendCallNotInContract': 'error',
69
+ },
70
+ 'strict': False,
71
+ }
72
+
73
+ AUTO_ROOTS = ['src', 'app', 'lib', 'server', 'backend', 'frontend', 'web', 'api', 'pages', 'packages']
74
+
75
+
76
+ # ── contract parsing (mirrors validate_api_semantic.py table logic) ───────────
77
+
78
+ def strip_frontmatter(text: str) -> str:
79
+ if text.startswith('---'):
80
+ end = text.find('\n---', 3)
81
+ if end != -1:
82
+ return text[end + 4:].lstrip('\n')
83
+ return text
84
+
85
+
86
+ def parse_table_row(line: str) -> list:
87
+ return [cell.strip() for cell in line.strip().strip('|').split('|')]
88
+
89
+
90
+ def is_separator_row(cells: list) -> bool:
91
+ return all(re.match(r'^:?-+:?$', c) for c in cells if c)
92
+
93
+
94
+ def find_contract_endpoints(lines: list) -> set:
95
+ """Return a set of (METHOD, normalized_path) from all '| method |' tables."""
96
+ in_table = False
97
+ sep_seen = False
98
+ endpoints = set()
99
+ for line in lines:
100
+ stripped = line.strip()
101
+ if not stripped or not stripped.startswith('|'):
102
+ continue
103
+ cells = parse_table_row(stripped)
104
+ if not cells:
105
+ continue
106
+ if cells[0].lower() == 'method':
107
+ in_table = True
108
+ sep_seen = False
109
+ continue
110
+ if not in_table:
111
+ continue
112
+ if not sep_seen and is_separator_row(cells):
113
+ sep_seen = True
114
+ continue
115
+ if len(cells) < 2 or not any(cells):
116
+ continue
117
+ method = cells[0].upper()
118
+ path = cells[1]
119
+ if method not in VALID_METHODS or not path.startswith('/'):
120
+ continue
121
+ endpoints.add((method, normalize_path(path)))
122
+ return endpoints
123
+
124
+
125
+ # ── path normalization ────────────────────────────────────────────────────────
126
+
127
+ PARAM_PATTERNS = [
128
+ re.compile(r'\$\{[^}/]*\}'), # ${id} (js template literal) — before {id}
129
+ re.compile(r':[A-Za-z_][\w]*'), # :id (express/rails)
130
+ re.compile(r'\{[^}/]*\}'), # {id} (flask/fastapi/spring)
131
+ re.compile(r'<[^>/]*>'), # <int:id> (django/flask)
132
+ re.compile(r'\*\*?'), # wildcard segments
133
+ ]
134
+
135
+
136
+ def normalize_path(path: str) -> str:
137
+ """Collapse route params and template interpolations to a single token so
138
+ `/users/:id`, `/users/{id}`, and `/users/${x}` all compare equal."""
139
+ # strip query string / hash
140
+ path = path.split('?', 1)[0].split('#', 1)[0]
141
+ for pat in PARAM_PATTERNS:
142
+ path = pat.sub('{}', path)
143
+ # template literal leftovers like /users/`+id+` -> treat remainder as param
144
+ path = re.sub(r'`.*$', '{}', path)
145
+ if not path.startswith('/'):
146
+ path = '/' + path
147
+ if len(path) > 1:
148
+ path = path.rstrip('/')
149
+ # collapse duplicate slashes
150
+ path = re.sub(r'/{2,}', '/', path)
151
+ return path
152
+
153
+
154
+ def matches_ignore(path: str, ignore_list: list) -> bool:
155
+ for ig in ignore_list:
156
+ ig_norm = normalize_path(ig.rstrip('*'))
157
+ if ig.endswith('*'):
158
+ if path == ig_norm or path.startswith(ig_norm.rstrip('/') + '/') or path.startswith(ig_norm):
159
+ return True
160
+ elif path == normalize_path(ig):
161
+ return True
162
+ return False
163
+
164
+
165
+ def under_api_prefix(path: str, prefixes: list) -> bool:
166
+ if not prefixes:
167
+ return True
168
+ for p in prefixes:
169
+ pn = normalize_path(p)
170
+ if path == pn or path.startswith(pn.rstrip('/') + '/'):
171
+ return True
172
+ return False
173
+
174
+
175
+ # ── source scanning ────────────────────────────────────────────────────────────
176
+
177
+ # Backend route patterns grouped by language. Only the patterns for a file's
178
+ # extension are applied to it, so a Python Flask/Django pattern can never match a
179
+ # PHP or JS file (cross-language false matches were polluting results, e.g. the
180
+ # Flask route regex firing on a Laravel `Route::match` line).
181
+ _JS_BACKEND = [
182
+ # Express / Koa / Fastify: app.get('/x'), router.post("/x").
183
+ # Client idioms (api/http/client/request/...) are intentionally excluded —
184
+ # those are frontend calls, not server routes; counting them as backend
185
+ # would silently satisfy `contractEndpointNotImplemented`.
186
+ (re.compile(r'\b(?:app|router|server|fastify|route|routes)\.(get|post|put|delete|patch|options|head|all)\s*\(\s*[\'"`]([^\'"`]+)[\'"`]', re.I), 'verb_first'),
187
+ ]
188
+
189
+ # NestJS: @Controller('users') class prefix + @Get(':id') method decorators.
190
+ # Handled by a dedicated two-pass scanner (scan_nestjs) since the route path is
191
+ # the join of the controller prefix and the per-method decorator argument.
192
+ NEST_CONTROLLER_RE = re.compile(r'@Controller\s*\(\s*[\'"`]?([^\'"`)]*)[\'"`]?\s*\)', re.I)
193
+ NEST_METHOD_RE = re.compile(r'@(Get|Post|Put|Delete|Patch|Options|Head|All)\s*\(\s*[\'"`]?([^\'"`)]*)[\'"`]?\s*\)', re.I)
194
+ _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'),
199
+ # Django urls: path("x/", ...) re_path(r"^x/$", ...)
200
+ (re.compile(r'\b(?:path|re_path|url)\s*\(\s*r?[\'"]([^\'"]+)[\'"]', re.I), 'path_only'),
201
+ ]
202
+ _JAVA_BACKEND = [
203
+ # Spring: @GetMapping("/x")
204
+ (re.compile(r'@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*(?:value\s*=\s*)?[\'"]([^\'"]+)[\'"]', re.I), 'verb_first'),
205
+ # Spring: @RequestMapping(value="/x", method=RequestMethod.POST) — method (if
206
+ # present) is parsed from the call tail so method drift is not wildcarded.
207
+ (re.compile(r'@RequestMapping\s*\(\s*(?:value\s*=\s*)?[\'"]([^\'"]+)[\'"]([^)]*)', re.I), 'spring_request_mapping'),
208
+ ]
209
+ _GO_BACKEND = [
210
+ # chi/gin/echo/mux: r.Get("/x", ...) router.POST("/x", ...) mux.HandleFunc("/x", ...)
211
+ (re.compile(r'\b\w+\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(\s*"([^"]+)"', re.I), 'verb_first'),
212
+ (re.compile(r'\b\w+\.HandleFunc\s*\(\s*"([^"]+)"', re.I), 'path_only'),
213
+ ]
214
+ _PHP_BACKEND = [
215
+ # Laravel verb form: Route::get('/x', ...)
216
+ (re.compile(r'\bRoute::(get|post|put|delete|patch|options|any)\s*\(\s*[\'"]([^\'"]+)[\'"]', re.I), 'verb_first'),
217
+ # Laravel array form: Route::match(['get','post'], '/x', ...)
218
+ (re.compile(r'\bRoute::match\s*\(\s*\[([^\]]*)\]\s*,\s*[\'"]([^\'"]+)[\'"]', re.I), 'laravel_match'),
219
+ ]
220
+
221
+ BACKEND_PATTERNS_BY_EXT = {
222
+ '.js': _JS_BACKEND, '.jsx': _JS_BACKEND, '.mjs': _JS_BACKEND, '.cjs': _JS_BACKEND,
223
+ '.ts': _JS_BACKEND, '.tsx': _JS_BACKEND,
224
+ '.py': _PY_BACKEND,
225
+ '.java': _JAVA_BACKEND,
226
+ '.go': _GO_BACKEND,
227
+ '.php': _PHP_BACKEND,
228
+ }
229
+
230
+ FLASK_METHODS_RE = re.compile(r'methods\s*=\s*\[([^\]]*)\]', re.I)
231
+ SPRING_METHOD_RE = re.compile(r'method\s*=\s*\{?([^)}]*)', re.I)
232
+
233
+ # Frontend HTTP calls -> list of (method_or_None, raw_path). Only scanned in
234
+ # files with a frontend extension. The path capture allows ${...} template
235
+ # params (normalize_path collapses them) but stops at the closing quote/backtick,
236
+ # a paren, or whitespace.
237
+ _FE_PATH = r"([^`'\")\s]+)"
238
+ _FE_EXTS = {'.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.vue', '.svelte'}
239
+ FRONTEND_PATTERNS = [
240
+ # axios.get('/x'), ky.post(`/x`), http.put("/x"), $http.delete('/x'), client.patch('/x')
241
+ (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'),
242
+ # fetch('/x', { method: 'POST' }) — method parsed from the options object below
243
+ (re.compile(r'\bfetch\s*\(\s*[`\'"]' + _FE_PATH, re.I), 'fetch'),
244
+ # axios({ url: '/x', method: 'post' }) — method parsed from a nearby window so
245
+ # config-object method drift is caught instead of wildcarded.
246
+ (re.compile(r'\burl\s*:\s*[`\'"]' + _FE_PATH, re.I), 'config_object'),
247
+ # useFetch('/x') / useSWR('/x') — no method on the call site; method-agnostic.
248
+ (re.compile(r'\b(?:useFetch|useSWR|useQuery)\s*\(\s*[`\'"]' + _FE_PATH, re.I), 'path_only'),
249
+ ]
250
+
251
+ # Look a short window on EITHER side of a config-object `url:` for `method:`
252
+ # (the key order in axios({ method, url }) is not fixed).
253
+ OBJECT_METHOD_RE = re.compile(r'method\s*:\s*[`\'"](\w+)[`\'"]', re.I)
254
+ OBJECT_METHOD_WINDOW = 200
255
+
256
+
257
+ def iter_source_files(roots, exts, exclude_dirs):
258
+ seen = set()
259
+ excl = set(exclude_dirs)
260
+ for root in roots:
261
+ if not os.path.isdir(root):
262
+ continue
263
+ for dirpath, dirnames, filenames in os.walk(root):
264
+ dirnames[:] = [d for d in dirnames if d not in excl and not d.startswith('.')]
265
+ for fn in filenames:
266
+ ext = os.path.splitext(fn)[1]
267
+ if ext not in exts:
268
+ continue
269
+ full = os.path.join(dirpath, fn)
270
+ if full in seen:
271
+ continue
272
+ seen.add(full)
273
+ yield full
274
+
275
+
276
+ def looks_like_test(path: str) -> bool:
277
+ base = os.path.basename(path).lower()
278
+ return ('.test.' in base or '.spec.' in base or base.startswith('test_')
279
+ or '/tests/' in path.replace('\\', '/') or '/__tests__/' in path.replace('\\', '/'))
280
+
281
+
282
+ def _join_route(prefix: str, suffix: str) -> str:
283
+ parts = [p.strip('/') for p in (prefix, suffix) if p and p.strip('/')]
284
+ return normalize_path('/' + '/'.join(parts)) if parts else normalize_path('/')
285
+
286
+
287
+ def scan_nestjs(text: str):
288
+ """Yield (METHOD, normalized_path) for NestJS controllers in one file.
289
+
290
+ Each method decorator's path is joined with the nearest preceding
291
+ @Controller(prefix). This is the documented NestJS shape; it deliberately
292
+ does not try to resolve dynamic prefixes or RouterModule registrations."""
293
+ controllers = [(m.start(), m.group(1) or '') for m in NEST_CONTROLLER_RE.finditer(text)]
294
+ if not controllers:
295
+ return
296
+ for mm in NEST_METHOD_RE.finditer(text):
297
+ prefix = ''
298
+ for pos, pfx in controllers:
299
+ if pos < mm.start():
300
+ prefix = pfx
301
+ else:
302
+ break
303
+ method = mm.group(1).upper()
304
+ method = 'ANY' if method == 'ALL' else method
305
+ yield (method, _join_route(prefix, mm.group(2) or ''))
306
+
307
+
308
+ def scan_backend(roots, exts, exclude_dirs):
309
+ """Return set of (METHOD, normalized_path). METHOD may be 'ANY'."""
310
+ routes = set()
311
+ for path in iter_source_files(roots, exts, exclude_dirs):
312
+ if looks_like_test(path):
313
+ continue
314
+ ext = os.path.splitext(path)[1].lower()
315
+ patterns = BACKEND_PATTERNS_BY_EXT.get(ext)
316
+ if not patterns:
317
+ continue
318
+ try:
319
+ text = Path(path).read_text(encoding='utf-8', errors='ignore')
320
+ except OSError:
321
+ continue
322
+ if ext in ('.ts', '.tsx', '.js', '.mjs', '.cjs'):
323
+ routes.update(scan_nestjs(text))
324
+ for pat, kind in patterns:
325
+ for m in pat.finditer(text):
326
+ if kind == 'verb_first':
327
+ method = m.group(1).upper()
328
+ raw = m.group(2)
329
+ method = 'ANY' if method == 'ALL' else method
330
+ routes.add((method, normalize_path(raw)))
331
+ elif kind == 'flask_route':
332
+ raw = m.group(1)
333
+ tail = m.group(2) or ''
334
+ mm = FLASK_METHODS_RE.search(tail)
335
+ if mm:
336
+ for meth in re.findall(r'[A-Za-z]+', mm.group(1)):
337
+ if meth.upper() in VALID_METHODS:
338
+ routes.add((meth.upper(), normalize_path(raw)))
339
+ else:
340
+ routes.add(('GET', normalize_path(raw)))
341
+ elif kind == 'path_only':
342
+ routes.add(('ANY', normalize_path(m.group(1))))
343
+ elif kind == 'laravel_match':
344
+ methods_raw = m.group(1)
345
+ raw = m.group(2)
346
+ found = [meth.upper() for meth in re.findall(r'[A-Za-z]+', methods_raw)
347
+ if meth.upper() in VALID_METHODS]
348
+ for meth in (found or ['ANY']):
349
+ routes.add((meth, normalize_path(raw)))
350
+ elif kind == 'spring_request_mapping':
351
+ raw = m.group(1)
352
+ tail = m.group(2) or ''
353
+ mm = SPRING_METHOD_RE.search(tail)
354
+ methods = []
355
+ if mm:
356
+ # method=RequestMethod.POST or method={RequestMethod.GET, RequestMethod.POST}
357
+ methods = [tok.upper() for tok in re.findall(r'[A-Za-z]+', mm.group(1))
358
+ if tok.upper() in VALID_METHODS]
359
+ for meth in (methods or ['ANY']):
360
+ routes.add((meth, normalize_path(raw)))
361
+ return routes
362
+
363
+
364
+ def scan_frontend(roots, exts, exclude_dirs):
365
+ calls = set()
366
+ for path in iter_source_files(roots, exts, exclude_dirs):
367
+ if looks_like_test(path):
368
+ continue
369
+ if os.path.splitext(path)[1].lower() not in _FE_EXTS:
370
+ continue
371
+ try:
372
+ text = Path(path).read_text(encoding='utf-8', errors='ignore')
373
+ except OSError:
374
+ continue
375
+ for pat, kind in FRONTEND_PATTERNS:
376
+ for m in pat.finditer(text):
377
+ if kind == 'verb_first':
378
+ method = m.group(1).upper()
379
+ raw = m.group(2)
380
+ elif kind == 'fetch':
381
+ raw = m.group(1)
382
+ # Per the fetch spec the default method is GET; look a short
383
+ # window past the URL for an explicit `method:` so method
384
+ # drift (e.g. DELETE on a GET-only endpoint) is caught.
385
+ window = text[m.end():m.end() + OBJECT_METHOD_WINDOW]
386
+ mm = OBJECT_METHOD_RE.search(window)
387
+ method = mm.group(1).upper() if mm else 'GET'
388
+ elif kind == 'config_object':
389
+ raw = m.group(1)
390
+ # axios({ url, method }) — key order is not fixed, so scan a
391
+ # window on both sides of the url: token for method:.
392
+ lo = max(0, m.start() - OBJECT_METHOD_WINDOW)
393
+ window = text[lo:m.end() + OBJECT_METHOD_WINDOW]
394
+ mm = OBJECT_METHOD_RE.search(window)
395
+ method = mm.group(1).upper() if mm else 'ANY'
396
+ else: # path_only
397
+ method = 'ANY'
398
+ raw = m.group(1)
399
+ if not raw.startswith('/'):
400
+ continue # skip absolute URLs / relative non-rooted strings
401
+ calls.add((method, normalize_path(raw)))
402
+ return calls
403
+
404
+
405
+ # ── contract matching ──────────────────────────────────────────────────────────
406
+
407
+ def contract_has(method: str, path: str, contract: set) -> bool:
408
+ """A code endpoint conforms if some entry matches the path and the method
409
+ matches OR *either* side is method-agnostic ('ANY'). Path-only declarations
410
+ (Go HandleFunc, Django path(), Spring @RequestMapping) register as 'ANY' and
411
+ must therefore satisfy a concrete contract method, and vice versa."""
412
+ for c_method, c_path in contract:
413
+ if c_path != path:
414
+ continue
415
+ if method == 'ANY' or c_method == 'ANY' or c_method == method:
416
+ return True
417
+ return False
418
+
419
+
420
+ def load_config():
421
+ cfg = dict(DEFAULT_CONFIG)
422
+ if not CONFIG_PATH.exists():
423
+ return cfg, False
424
+ try:
425
+ user = json.loads(CONFIG_PATH.read_text(encoding='utf-8'))
426
+ except (OSError, ValueError) as e:
427
+ print(f'API conformance: .cdd/conformance.json is not valid JSON: {e}')
428
+ sys.exit(1)
429
+ cfg.update({k: v for k, v in user.items() if k != 'checks'})
430
+ if isinstance(user.get('checks'), dict):
431
+ merged = dict(DEFAULT_CONFIG['checks'])
432
+ merged.update(user['checks'])
433
+ cfg['checks'] = merged
434
+ return cfg, True
435
+
436
+
437
+ def resolve_roots(cfg):
438
+ roots = cfg.get('sourceRoots') or []
439
+ if roots:
440
+ return [r for r in roots if os.path.isdir(r)]
441
+ return [r for r in AUTO_ROOTS if os.path.isdir(r)]
442
+
443
+
444
+ def severity(check_name, cfg):
445
+ sev = cfg['checks'].get(check_name, 'error')
446
+ if cfg.get('strict') and sev == 'warning':
447
+ return 'error'
448
+ return sev
449
+
450
+
451
+ def main() -> None:
452
+ cfg, present = load_config()
453
+
454
+ if not present:
455
+ print('API conformance: skipped (no .cdd/conformance.json; '
456
+ 'add one with "enabled": true to enforce code-vs-contract checks).')
457
+ sys.exit(0)
458
+ if not cfg.get('enabled'):
459
+ print('API conformance: skipped (.cdd/conformance.json has "enabled": false).')
460
+ sys.exit(0)
461
+
462
+ if not CONTRACT_PATH.exists():
463
+ print(f'API conformance: contract not found: {CONTRACT_PATH}')
464
+ sys.exit(1)
465
+
466
+ body = strip_frontmatter(CONTRACT_PATH.read_text(encoding='utf-8', errors='ignore'))
467
+ contract = find_contract_endpoints(body.splitlines())
468
+ if not contract:
469
+ print('API conformance: no endpoint table found in contract; nothing to check.')
470
+ sys.exit(0)
471
+
472
+ roots = resolve_roots(cfg)
473
+ if not roots:
474
+ # Enabled but nothing to scan almost always means a mistyped/missing
475
+ # sourceRoots. Failing (not exit 0) prevents a config mistake from
476
+ # silently disabling the drift net while the gate stays green.
477
+ print('API conformance validation failed:')
478
+ print(' conformance is enabled but no source roots were found to scan; '
479
+ 'set "sourceRoots" in .cdd/conformance.json to existing directories.')
480
+ sys.exit(1)
481
+
482
+ exclude = cfg['excludeDirs']
483
+ ignore = cfg['ignorePaths']
484
+ prefixes = cfg['apiPrefixes']
485
+
486
+ backend = scan_backend(roots, set(cfg['backendGlobsExt']), exclude)
487
+ frontend = scan_frontend(roots, set(cfg['frontendGlobsExt']), exclude)
488
+
489
+ errors = []
490
+ warnings = []
491
+
492
+ def emit(check_name, message):
493
+ (errors if severity(check_name, cfg) == 'error' else warnings).append(message)
494
+
495
+ # 1. Backend routes that are not documented in the contract.
496
+ for method, path in sorted(backend):
497
+ if matches_ignore(path, ignore):
498
+ continue
499
+ if not under_api_prefix(path, prefixes):
500
+ continue
501
+ if not contract_has(method, path, contract):
502
+ emit('backendRouteNotInContract',
503
+ f'backend route {method} {path} is not in the API contract')
504
+
505
+ # 2. Contract endpoints with no backend implementation found.
506
+ for method, path in sorted(contract):
507
+ if matches_ignore(path, ignore):
508
+ continue
509
+ if not contract_has(method, path, backend):
510
+ emit('contractEndpointNotImplemented',
511
+ f'contract endpoint {method} {path} has no backend route in scanned source')
512
+
513
+ # 3. Frontend calls to paths not in the contract (the FE/BE drift case).
514
+ for method, path in sorted(frontend):
515
+ if matches_ignore(path, ignore):
516
+ continue
517
+ if not under_api_prefix(path, prefixes):
518
+ continue
519
+ if not contract_has(method, path, contract):
520
+ label = path if method == 'ANY' else f'{method} {path}'
521
+ emit('frontendCallNotInContract',
522
+ f'frontend calls {label} which is not in the API contract')
523
+
524
+ print(f'API conformance: contract={len(contract)} endpoint(s), '
525
+ f'backend={len(backend)} route(s), frontend={len(frontend)} call(s) '
526
+ f'across roots: {", ".join(roots)}')
527
+
528
+ if warnings:
529
+ print('API conformance warnings:')
530
+ for w in warnings:
531
+ print(f' {w}')
532
+
533
+ if errors:
534
+ print('API conformance validation failed:')
535
+ for e in errors:
536
+ print(f' {e}')
537
+ sys.exit(1)
538
+
539
+ print('API conformance validation passed.')
540
+
541
+
542
+ if __name__ == '__main__':
543
+ main()
@@ -45,6 +45,8 @@ def find_endpoint_table(lines: list[str]) -> list[tuple[int, str]]:
45
45
  Find all data rows across ALL '| method |' tables in the document.
46
46
  Blank lines and prose between rows do not end collection, making this
47
47
  robust to files where content is appended after the original table block.
48
+ A new markdown heading does end the active table so ADR 0002 schema field
49
+ tables under `## Schemas` are not misread as endpoint rows.
48
50
  """
49
51
  in_table = False
50
52
  sep_seen = False
@@ -53,6 +55,11 @@ def find_endpoint_table(lines: list[str]) -> list[tuple[int, str]]:
53
55
  for i, line in enumerate(lines):
54
56
  stripped = line.strip()
55
57
 
58
+ if re.match(r'^#{2,}\s+', stripped):
59
+ in_table = False
60
+ sep_seen = False
61
+ continue
62
+
56
63
  if not stripped:
57
64
  continue # blank lines never end table mode
58
65
 
@@ -66,7 +73,7 @@ def find_endpoint_table(lines: list[str]) -> list[tuple[int, str]]:
66
73
  continue
67
74
 
68
75
  # A header row for an endpoint table
69
- if cells[0].lower() == 'method':
76
+ if cells[0].lower() == 'method' and len(cells) > 1 and cells[1].lower() == 'path':
70
77
  in_table = True
71
78
  sep_seen = False
72
79
  continue # skip header row
@@ -132,6 +132,8 @@ def git_available(root: Path) -> bool:
132
132
  ['git', 'rev-parse', '--git-dir'],
133
133
  capture_output=True,
134
134
  text=True,
135
+ encoding='utf-8',
136
+ errors='replace',
135
137
  cwd=str(root),
136
138
  )
137
139
  return r.returncode == 0
@@ -150,6 +152,8 @@ def git_show_head(root: Path, rel_path: str) -> str | None:
150
152
  ['git', 'show', f'HEAD:{rel_path}'],
151
153
  capture_output=True,
152
154
  text=True,
155
+ encoding='utf-8',
156
+ errors='replace',
153
157
  cwd=str(root),
154
158
  )
155
159
  if r.returncode == 0: