contract-driven-delivery 2.1.2 → 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.
- package/CHANGELOG.md +188 -0
- package/README.md +159 -23
- package/assets/CLAUDE.template.md +21 -9
- package/assets/CODEX.template.md +8 -9
- package/assets/agents/backend-engineer.md +3 -1
- package/assets/agents/frontend-engineer.md +3 -2
- package/assets/cdd/conformance.json +16 -0
- package/assets/cdd/tier-policy.json +35 -0
- package/assets/contracts/api/api-contract.md +26 -0
- package/assets/hooks/pre-tool-use-graph-first.sh +65 -0
- package/assets/skills/contract-driven-delivery/scripts/validate_api_conformance.py +543 -0
- package/assets/skills/contract-driven-delivery/scripts/validate_api_semantic.py +8 -1
- package/assets/skills/contract-driven-delivery/scripts/validate_contract_versions.py +4 -0
- package/dist/cli/index.js +2122 -494
- package/docs/adr/0001-contract-to-openapi-export.md +142 -0
- package/docs/adr/0002-schema-carrying-contract-format.md +277 -0
- package/docs/adr/0003-code-intelligence-indexing-strategy.md +110 -0
- package/docs/api-conformance.md +108 -0
- package/docs/openapi-export.md +157 -0
- package/package.json +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_README": "Mechanical risk-tier floor. A classifier (or any agent) proposes a tier in change-classification.md / tasks.yml; this policy is the safety net that prevents a high-risk surface from being silently under-classified. `cdd-kit gate` and `cdd-kit classify-check` scan change-request.md against ALL rules, and the change's git paths against the critical (maxTier 0) rules only (file paths are noisier than prose) — gate scans the STAGED change (what is about to be committed), classify-check scans the whole worktree (the in-progress change is not yet committed) — then require the declared tier to be at least as strict as the matched floor. Lower tier number = stricter. Set enabled:false to disable, or record `tier-floor-override: \"<reason>\"` in a change's tasks.yml frontmatter to bypass for one change with an audit trail.",
|
|
3
|
+
"enabled": true,
|
|
4
|
+
"schema-version": "0.1.0",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"maxTier": 0,
|
|
8
|
+
"label": "critical surface (auth / payments / data migration / concurrency / secrets)",
|
|
9
|
+
"patterns": [
|
|
10
|
+
"auth", "authn", "authz",
|
|
11
|
+
"authentication", "authorization", "authenticate", "authorize",
|
|
12
|
+
"authenticated", "authorized",
|
|
13
|
+
"login", "logout", "sign-?in", "sign-?up",
|
|
14
|
+
"passwords?", "passwd", "credentials?", "secrets?", "api[- ]?keys?",
|
|
15
|
+
"(access|api|auth|session|bearer|refresh|csrf|id|reset)[- ]?tokens?",
|
|
16
|
+
"jwt", "oauth", "oidc", "saml", "sessions?", "cookies?",
|
|
17
|
+
"payments?", "billing", "invoices?", "charges?", "refunds?", "checkout", "stripe", "paypal",
|
|
18
|
+
"migrations?", "migrate", "alter table", "drop table", "drop column", "schema change",
|
|
19
|
+
"concurrency", "race condition", "mutex", "deadlock", "transaction isolation",
|
|
20
|
+
"encrypt", "decrypt", "crypto", "hashing", "rbac", "permissions?", "access control",
|
|
21
|
+
"privileges?", "pii", "gdpr", "hipaa", "rate limit", "csrf", "xss", "sql injection"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"maxTier": 2,
|
|
26
|
+
"label": "behavioral surface (api / data shape / queue / cache / external integration)",
|
|
27
|
+
"patterns": [
|
|
28
|
+
"endpoint", "route", "api contract", "request schema", "response schema",
|
|
29
|
+
"pagination", "queue", "worker", "cron", "scheduler", "webhook",
|
|
30
|
+
"cache", "redis", "database", "query", "index", "external service",
|
|
31
|
+
"third[- ]?party", "integration", "data shape", "nullable", "breaking change"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -21,6 +21,32 @@ breaking-change-policy: deprecate-2-minors
|
|
|
21
21
|
| method | path | auth | request schema | response schema | errors | tests |
|
|
22
22
|
|---|---|---|---|---|---|---|
|
|
23
23
|
|
|
24
|
+
## Schemas
|
|
25
|
+
|
|
26
|
+
<!--
|
|
27
|
+
Optional. Add named schemas here when request/response bodies should become
|
|
28
|
+
machine-typed in `cdd-kit openapi export`. Reference a schema by name in the
|
|
29
|
+
endpoint table's "request schema" / "response schema" cell (use `Name[]` for an
|
|
30
|
+
array). A schema is defined ONE of two ways — never both:
|
|
31
|
+
|
|
32
|
+
Tier A — a field table (preferred; readable, diffable):
|
|
33
|
+
|
|
34
|
+
### ExampleRequest
|
|
35
|
+
| field | type | required | format | notes |
|
|
36
|
+
|---|---|---|---|---|
|
|
37
|
+
| email | string | yes | email | login identity |
|
|
38
|
+
| status | enum(active, disabled) | no | | lifecycle state |
|
|
39
|
+
| owner | ExampleUser | no | | reference another schema by name |
|
|
40
|
+
|
|
41
|
+
Tier B — a raw JSON Schema, for shapes Tier A can't express (oneOf, etc.).
|
|
42
|
+
The fence MUST be tagged `json-schema` (NOT `json`) or export fails fast:
|
|
43
|
+
|
|
44
|
+
### ExampleEvent
|
|
45
|
+
```json-schema
|
|
46
|
+
{ "type": "object", "oneOf": [ { "required": ["createdAt"] }, { "required": ["deletedAt"] } ] }
|
|
47
|
+
```
|
|
48
|
+
-->
|
|
49
|
+
|
|
24
50
|
## Error Format
|
|
25
51
|
|
|
26
52
|
## Compatibility Policy
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# cdd-kit PreToolUse hook (opt-in): steer agents to graph-first exploration.
|
|
3
|
+
#
|
|
4
|
+
# Prose in agent prompts ("run cdd-kit index query before reading") is a soft
|
|
5
|
+
# preference that loses to the model's built-in habit of reaching for Read.
|
|
6
|
+
# This hook turns that preference into an actual chokepoint: when an agent is
|
|
7
|
+
# about to Read a *source* file and a code-map exists, it reminds the agent to
|
|
8
|
+
# use `cdd-kit index query "<symbol>" --with-source` (which returns the code
|
|
9
|
+
# inline, so the Read is usually unnecessary).
|
|
10
|
+
#
|
|
11
|
+
# Default mode is ADVISORY: it prints guidance to stderr and allows the Read.
|
|
12
|
+
# Set CDD_GRAPH_FIRST_STRICT=1 to BLOCK the Read instead (exit 2), forcing the
|
|
13
|
+
# graph-first path. Contract/spec/markdown/Read of .cdd/code-map.yml itself are
|
|
14
|
+
# always allowed.
|
|
15
|
+
#
|
|
16
|
+
# Wire into Claude Code (~/.claude/settings.json):
|
|
17
|
+
#
|
|
18
|
+
# {
|
|
19
|
+
# "hooks": {
|
|
20
|
+
# "PreToolUse": [
|
|
21
|
+
# { "matcher": "Read", "command": "/path/to/hooks/pre-tool-use-graph-first.sh" }
|
|
22
|
+
# ]
|
|
23
|
+
# }
|
|
24
|
+
# }
|
|
25
|
+
#
|
|
26
|
+
# The hook receives the tool-call payload as JSON on stdin.
|
|
27
|
+
|
|
28
|
+
set -eu
|
|
29
|
+
|
|
30
|
+
# No code-map → nothing to steer toward; allow.
|
|
31
|
+
[ -f ".cdd/code-map.yml" ] || exit 0
|
|
32
|
+
|
|
33
|
+
payload="$(cat || true)"
|
|
34
|
+
[ -z "$payload" ] && exit 0
|
|
35
|
+
|
|
36
|
+
# Extract the Read target path.
|
|
37
|
+
path_value=""
|
|
38
|
+
if command -v jq >/dev/null 2>&1; then
|
|
39
|
+
path_value="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)"
|
|
40
|
+
fi
|
|
41
|
+
if [ -z "$path_value" ]; then
|
|
42
|
+
path_value="$(printf '%s' "$payload" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 | sed -E 's/.*"file_path"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')"
|
|
43
|
+
fi
|
|
44
|
+
[ -z "$path_value" ] && exit 0
|
|
45
|
+
|
|
46
|
+
# Only steer for source files; never interfere with docs/specs/contracts/config.
|
|
47
|
+
case "$path_value" in
|
|
48
|
+
*.py|*.js|*.jsx|*.mjs|*.cjs|*.ts|*.tsx|*.vue|*.svelte|*.go|*.java|*.rb|*.php) : ;;
|
|
49
|
+
*) exit 0 ;;
|
|
50
|
+
esac
|
|
51
|
+
# Allow reading the map itself.
|
|
52
|
+
case "$path_value" in
|
|
53
|
+
*.cdd/code-map.yml) exit 0 ;;
|
|
54
|
+
esac
|
|
55
|
+
|
|
56
|
+
msg="cdd-kit: prefer \`cdd-kit index query \"<symbol-or-file>\" --with-source\` (or \`cdd-kit graph query ... --with-source\`) before Read — it returns the code inline and keeps token use low. Read directly only for ranges the query did not return."
|
|
57
|
+
|
|
58
|
+
if [ "${CDD_GRAPH_FIRST_STRICT:-0}" = "1" ]; then
|
|
59
|
+
# Block and feed the reason back to the model.
|
|
60
|
+
printf '%s\n' "$msg Set CDD_GRAPH_FIRST_STRICT=0 to make this advisory only." 1>&2
|
|
61
|
+
exit 2
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
printf '%s\n' "$msg" 1>&2
|
|
65
|
+
exit 0
|
|
@@ -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()
|