bmad-plus 0.20.0 → 0.21.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 +21 -0
- package/README.md +13 -13
- package/package.json +1 -1
- package/readme-international/README.de.md +13 -13
- package/readme-international/README.es.md +13 -13
- package/readme-international/README.fr.md +13 -13
- package/src/bmad-plus/agents/agent-quality/SKILL.md +1 -1
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +23 -4
- package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +36 -24
- package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +179 -59
- package/tools/cli/commands/review.js +133 -6
- package/tools/cli/lib/packs.js +1 -1
- package/tools/cli/lib/review-rules.js +34 -4
- package/tools/cli/lib/review.js +466 -10
- package/tools/cli/review-rules/index.yaml +9 -0
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
SEO Fetch — Secure HTTP page fetcher for SEO analysis.
|
|
4
4
|
|
|
5
5
|
Features:
|
|
6
|
-
- SSRF protection (blocks private/loopback/reserved IPs
|
|
6
|
+
- SSRF protection (blocks private/loopback/reserved IPs, re-checked on every
|
|
7
|
+
redirect hop, connection pinned to the validated address)
|
|
7
8
|
- Multi-UA support (standard, Googlebot, GPTBot, ClaudeBot)
|
|
8
9
|
- Redirect chain tracking
|
|
9
10
|
- Cookie handling
|
|
@@ -16,12 +17,16 @@ License: MIT
|
|
|
16
17
|
import argparse
|
|
17
18
|
import ipaddress
|
|
18
19
|
import json
|
|
20
|
+
import os
|
|
19
21
|
import socket
|
|
20
22
|
import sys
|
|
23
|
+
import time
|
|
21
24
|
from urllib.parse import urljoin, urlparse
|
|
22
25
|
|
|
23
26
|
try:
|
|
24
27
|
import requests
|
|
28
|
+
from requests.adapters import DEFAULT_POOLBLOCK, HTTPAdapter
|
|
29
|
+
from urllib3 import PoolManager
|
|
25
30
|
except ImportError:
|
|
26
31
|
print("Error: requests library required. Install: pip install requests", file=sys.stderr)
|
|
27
32
|
sys.exit(1)
|
|
@@ -61,10 +66,37 @@ DEFAULT_HEADERS = {
|
|
|
61
66
|
|
|
62
67
|
# ── Security: SSRF Prevention ──────────────────────────────────────
|
|
63
68
|
|
|
69
|
+
ALLOWED_SCHEMES = frozenset({"http", "https"})
|
|
70
|
+
|
|
71
|
+
# RFC 6052 well-known NAT64 prefix: the last 32 bits are the IPv4 host the
|
|
72
|
+
# translator will reach, so the address is only as safe as that IPv4 host.
|
|
73
|
+
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UnsafeURLError(requests.exceptions.InvalidURL):
|
|
77
|
+
"""A URL, or the address it resolves to, must never be fetched."""
|
|
78
|
+
|
|
79
|
+
|
|
64
80
|
def _ip_is_blocked(ip: "ipaddress._BaseAddress") -> bool:
|
|
65
|
-
"""Return True if an IP falls in any range that must never be reached.
|
|
81
|
+
"""Return True if an IP falls in any range that must never be reached.
|
|
82
|
+
|
|
83
|
+
Anything that is not globally routable is refused: private, loopback,
|
|
84
|
+
link-local (cloud metadata at 169.254.169.254), carrier-grade NAT
|
|
85
|
+
(100.64.0.0/10, e.g. Alibaba metadata at 100.100.100.200), documentation
|
|
86
|
+
and benchmarking ranges, multicast and reserved space. IPv6 forms that
|
|
87
|
+
embed an IPv4 destination are judged by that destination.
|
|
88
|
+
"""
|
|
89
|
+
if ip.version == 6:
|
|
90
|
+
embedded = ip.ipv4_mapped
|
|
91
|
+
if embedded is None and ip in _NAT64_PREFIX:
|
|
92
|
+
embedded = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
|
93
|
+
if embedded is not None:
|
|
94
|
+
return _ip_is_blocked(embedded)
|
|
95
|
+
if ip.is_site_local:
|
|
96
|
+
return True
|
|
66
97
|
return bool(
|
|
67
|
-
ip.
|
|
98
|
+
not ip.is_global
|
|
99
|
+
or ip.is_private
|
|
68
100
|
or ip.is_loopback
|
|
69
101
|
or ip.is_reserved
|
|
70
102
|
or ip.is_link_local
|
|
@@ -73,47 +105,152 @@ def _ip_is_blocked(ip: "ipaddress._BaseAddress") -> bool:
|
|
|
73
105
|
)
|
|
74
106
|
|
|
75
107
|
|
|
76
|
-
def
|
|
77
|
-
"""
|
|
108
|
+
def check_url(url: str) -> str:
|
|
109
|
+
"""Apply the URL-level rules and return the hostname, without resolving it.
|
|
78
110
|
|
|
79
|
-
Fails CLOSED
|
|
80
|
-
|
|
81
|
-
rejected. Every resolved address (IPv4 and IPv6) must be public.
|
|
111
|
+
Fails CLOSED with UnsafeURLError: a scheme outside http/https, embedded
|
|
112
|
+
credentials, a missing host or an invalid port rejects the URL.
|
|
82
113
|
"""
|
|
83
114
|
parsed = urlparse(url)
|
|
115
|
+
if parsed.scheme not in ALLOWED_SCHEMES:
|
|
116
|
+
raise UnsafeURLError(f"scheme not allowed: {parsed.scheme or '(none)'}")
|
|
117
|
+
# "user:pass@" would be sent as an Authorization header and makes
|
|
118
|
+
# "https://trusted.example@evil.example/" style URLs misleading.
|
|
119
|
+
if parsed.username is not None or parsed.password is not None:
|
|
120
|
+
raise UnsafeURLError("credentials in URL are not allowed")
|
|
84
121
|
hostname = parsed.hostname
|
|
85
|
-
|
|
86
122
|
if not hostname:
|
|
87
|
-
|
|
123
|
+
raise UnsafeURLError("URL has no host")
|
|
124
|
+
try:
|
|
125
|
+
parsed.port
|
|
126
|
+
except ValueError:
|
|
127
|
+
raise UnsafeURLError("URL has an invalid port") from None
|
|
128
|
+
return hostname
|
|
88
129
|
|
|
89
|
-
if parsed.scheme not in ("http", "https"):
|
|
90
|
-
return False
|
|
91
130
|
|
|
131
|
+
def resolve_public_address(hostname: str) -> str:
|
|
132
|
+
"""Resolve a hostname and return the public address to connect to.
|
|
133
|
+
|
|
134
|
+
Fails CLOSED with UnsafeURLError on a DNS error or when any resolved
|
|
135
|
+
address (IPv4 or IPv6) is not public: every address the name resolves to
|
|
136
|
+
must be public, so the returned one is safe whichever the resolver would
|
|
137
|
+
have preferred.
|
|
138
|
+
"""
|
|
92
139
|
try:
|
|
93
|
-
# Resolve ALL IP addresses (IPv4 and IPv6) via getaddrinfo
|
|
94
140
|
addrinfo = socket.getaddrinfo(hostname, None)
|
|
95
|
-
except socket.gaierror:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if not addrinfo:
|
|
99
|
-
return False # Fail closed: no addresses resolved
|
|
141
|
+
except (socket.gaierror, UnicodeError):
|
|
142
|
+
raise UnsafeURLError(f"cannot resolve host {hostname}") from None
|
|
100
143
|
|
|
144
|
+
addresses = []
|
|
101
145
|
for entry in addrinfo:
|
|
102
|
-
ip_str = entry[4][0] # sockaddr[0] contains the IP string
|
|
103
146
|
try:
|
|
104
|
-
ip = ipaddress.ip_address(
|
|
147
|
+
ip = ipaddress.ip_address(entry[4][0])
|
|
105
148
|
except ValueError:
|
|
106
|
-
|
|
107
|
-
# IPv4-mapped IPv6 (::ffff:a.b.c.d) must be checked as its IPv4 form
|
|
108
|
-
mapped = getattr(ip, "ipv4_mapped", None)
|
|
109
|
-
if mapped is not None and _ip_is_blocked(mapped):
|
|
110
|
-
return False
|
|
149
|
+
raise UnsafeURLError(f"{hostname} resolved to an unparseable address") from None
|
|
111
150
|
if _ip_is_blocked(ip):
|
|
112
|
-
|
|
151
|
+
raise UnsafeURLError(f"{hostname} resolves to a private/internal address ({ip})")
|
|
152
|
+
addresses.append(ip)
|
|
153
|
+
if not addresses:
|
|
154
|
+
raise UnsafeURLError(f"cannot resolve host {hostname}")
|
|
155
|
+
|
|
156
|
+
# A pinned connection cannot fall back to another address, and IPv6 routes
|
|
157
|
+
# are often missing in containers and CI: prefer IPv4 on dual-stack hosts.
|
|
158
|
+
preferred = next((ip for ip in addresses if ip.version == 4), addresses[0])
|
|
159
|
+
return str(preferred)
|
|
160
|
+
|
|
113
161
|
|
|
162
|
+
def resolve_target(url: str) -> "tuple[str, str]":
|
|
163
|
+
"""Validate a URL and return ``(hostname, address)`` to connect to.
|
|
164
|
+
|
|
165
|
+
Combines check_url() and resolve_public_address(); see their rules.
|
|
166
|
+
"""
|
|
167
|
+
hostname = check_url(url)
|
|
168
|
+
return hostname, resolve_public_address(hostname)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def is_safe_url(url: str) -> bool:
|
|
172
|
+
"""Return True when resolve_target() accepts the URL (see its rules)."""
|
|
173
|
+
try:
|
|
174
|
+
resolve_target(url)
|
|
175
|
+
except UnsafeURLError:
|
|
176
|
+
return False
|
|
114
177
|
return True
|
|
115
178
|
|
|
116
179
|
|
|
180
|
+
class _PinnedPoolManager(PoolManager):
|
|
181
|
+
"""urllib3 pool manager that only opens pools on validated IP literals.
|
|
182
|
+
|
|
183
|
+
Every requests version reaches the network through
|
|
184
|
+
PoolManager.connection_from_host (directly, or via connection_from_url),
|
|
185
|
+
whatever adapter hook it calls first. Validating here, instead of in a
|
|
186
|
+
requests hook that has been renamed across releases, keeps the guard
|
|
187
|
+
closed on old and future requests versions alike.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def connection_from_host(self, host, port=None, scheme="http", pool_kwargs=None):
|
|
191
|
+
hostname = (host or "").strip("[]")
|
|
192
|
+
if not hostname:
|
|
193
|
+
raise UnsafeURLError("URL has no host")
|
|
194
|
+
address = resolve_public_address(hostname)
|
|
195
|
+
if scheme == "https":
|
|
196
|
+
pool_kwargs = dict(pool_kwargs or {})
|
|
197
|
+
pool_kwargs["server_hostname"] = hostname
|
|
198
|
+
pool_kwargs["assert_hostname"] = hostname
|
|
199
|
+
return super().connection_from_host(
|
|
200
|
+
address, port=port, scheme=scheme, pool_kwargs=pool_kwargs
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class PinnedAddressAdapter(HTTPAdapter):
|
|
205
|
+
"""Transport adapter that connects to the exact address it validated.
|
|
206
|
+
|
|
207
|
+
A plain requests call resolves the host again when urllib3 opens the
|
|
208
|
+
socket, so a hostile DNS server can answer a public address to the SSRF
|
|
209
|
+
check and a private one to the connection (DNS rebinding). This adapter's
|
|
210
|
+
pool manager resolves and validates each host itself, then opens the
|
|
211
|
+
connection pool on that IP literal. The Host header, TLS SNI and the
|
|
212
|
+
certificate hostname check still use the original name, so HTTPS
|
|
213
|
+
verification is unchanged. Proxies are refused: a proxy would resolve the
|
|
214
|
+
name itself and bypass the pinned address.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool_kwargs):
|
|
218
|
+
super().init_poolmanager(connections, maxsize, block, **pool_kwargs)
|
|
219
|
+
# Rebuild with the exact settings requests chose for this version.
|
|
220
|
+
self.poolmanager = _PinnedPoolManager(
|
|
221
|
+
num_pools=connections, **self.poolmanager.connection_pool_kw
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def proxy_manager_for(self, proxy, **proxy_kwargs):
|
|
225
|
+
raise UnsafeURLError("proxies are not supported by the pinned transport")
|
|
226
|
+
|
|
227
|
+
def send(self, request, **kwargs):
|
|
228
|
+
check_url(request.url)
|
|
229
|
+
# urllib3 would derive Host from the pool's IP literal; keep the name.
|
|
230
|
+
if "Host" not in request.headers:
|
|
231
|
+
request.headers["Host"] = urlparse(request.url).netloc.rpartition("@")[2]
|
|
232
|
+
return super().send(request, **kwargs)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def create_session() -> requests.Session:
|
|
236
|
+
"""Build a requests session whose every connection is SSRF-checked and pinned.
|
|
237
|
+
|
|
238
|
+
Environment proxies and ~/.netrc credentials are ignored: a proxy defeats
|
|
239
|
+
address pinning and netrc would attach credentials to attacker-chosen hosts.
|
|
240
|
+
A custom CA bundle named by REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE (corporate
|
|
241
|
+
TLS inspection) is still honoured, since it does not weaken pinning.
|
|
242
|
+
"""
|
|
243
|
+
session = requests.Session()
|
|
244
|
+
session.trust_env = False
|
|
245
|
+
ca_bundle = os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("CURL_CA_BUNDLE")
|
|
246
|
+
if ca_bundle:
|
|
247
|
+
session.verify = ca_bundle
|
|
248
|
+
adapter = PinnedAddressAdapter()
|
|
249
|
+
session.mount("http://", adapter)
|
|
250
|
+
session.mount("https://", adapter)
|
|
251
|
+
return session
|
|
252
|
+
|
|
253
|
+
|
|
117
254
|
# ── Core Fetcher ───────────────────────────────────────────────────
|
|
118
255
|
|
|
119
256
|
def fetch_page(
|
|
@@ -147,37 +284,24 @@ def fetch_page(
|
|
|
147
284
|
url = f"https://{url}"
|
|
148
285
|
parsed = urlparse(url)
|
|
149
286
|
|
|
150
|
-
if parsed.scheme not in
|
|
287
|
+
if parsed.scheme not in ALLOWED_SCHEMES:
|
|
151
288
|
result["error"] = f"Invalid URL scheme: {parsed.scheme}"
|
|
152
289
|
return result
|
|
153
290
|
|
|
154
|
-
|
|
155
|
-
if not is_safe_url(url):
|
|
156
|
-
resolved = "unknown"
|
|
157
|
-
try:
|
|
158
|
-
# Use getaddrinfo for consistent multi-address resolution
|
|
159
|
-
addrinfo = socket.getaddrinfo(parsed.hostname, None)
|
|
160
|
-
resolved = ", ".join(set(entry[4][0] for entry in addrinfo))
|
|
161
|
-
except Exception:
|
|
162
|
-
pass
|
|
163
|
-
result["error"] = f"Blocked: URL resolves to private/internal IP ({resolved})"
|
|
164
|
-
return result
|
|
165
|
-
|
|
291
|
+
current_url = url
|
|
166
292
|
try:
|
|
167
|
-
session =
|
|
293
|
+
session = create_session()
|
|
168
294
|
|
|
169
295
|
headers = dict(DEFAULT_HEADERS)
|
|
170
296
|
ua_string = USER_AGENTS.get(user_agent, user_agent)
|
|
171
297
|
headers["User-Agent"] = ua_string
|
|
172
298
|
|
|
173
|
-
import time
|
|
174
299
|
start = time.monotonic()
|
|
175
300
|
|
|
176
|
-
# Follow redirects manually so
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
current_url = url
|
|
301
|
+
# Follow redirects manually so every hop goes back through the pinned
|
|
302
|
+
# adapter, which validates the target before connecting. Letting
|
|
303
|
+
# requests follow redirects internally would hide the hop count and
|
|
304
|
+
# the chain; the adapter still guards each connection either way.
|
|
181
305
|
redirect_chain = []
|
|
182
306
|
hops = 0
|
|
183
307
|
response = None
|
|
@@ -198,19 +322,9 @@ def fetch_page(
|
|
|
198
322
|
break
|
|
199
323
|
|
|
200
324
|
next_url = urljoin(current_url, location)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
result["error"] = (
|
|
205
|
-
f"Blocked redirect to non-HTTP(S) scheme: {next_parsed.scheme}"
|
|
206
|
-
)
|
|
207
|
-
return result
|
|
208
|
-
|
|
209
|
-
# Re-validate the redirect target (blocks redirect-based SSRF)
|
|
210
|
-
if not is_safe_url(next_url):
|
|
211
|
-
result["error"] = (
|
|
212
|
-
f"Blocked: redirect to private/internal URL ({next_url})"
|
|
213
|
-
)
|
|
325
|
+
next_scheme = urlparse(next_url).scheme
|
|
326
|
+
if next_scheme not in ALLOWED_SCHEMES:
|
|
327
|
+
result["error"] = f"Blocked redirect to non-HTTP(S) scheme: {next_scheme}"
|
|
214
328
|
return result
|
|
215
329
|
|
|
216
330
|
hops += 1
|
|
@@ -221,6 +335,7 @@ def fetch_page(
|
|
|
221
335
|
redirect_chain.append(
|
|
222
336
|
{"url": current_url, "status": response.status_code}
|
|
223
337
|
)
|
|
338
|
+
response.close()
|
|
224
339
|
current_url = next_url
|
|
225
340
|
|
|
226
341
|
elapsed_ms = round((time.monotonic() - start) * 1000)
|
|
@@ -233,6 +348,11 @@ def fetch_page(
|
|
|
233
348
|
result["response_time_ms"] = elapsed_ms
|
|
234
349
|
result["redirect_chain"] = redirect_chain
|
|
235
350
|
|
|
351
|
+
except UnsafeURLError as e:
|
|
352
|
+
if current_url == url:
|
|
353
|
+
result["error"] = f"Blocked: {e}"
|
|
354
|
+
else:
|
|
355
|
+
result["error"] = f"Blocked: redirect to private/internal URL ({current_url}): {e}"
|
|
236
356
|
except requests.exceptions.Timeout:
|
|
237
357
|
result["error"] = f"Request timed out after {timeout}s"
|
|
238
358
|
except requests.exceptions.TooManyRedirects:
|
|
@@ -7,7 +7,7 @@ const review = require('../lib/review');
|
|
|
7
7
|
const reviewRules = require('../lib/review-rules');
|
|
8
8
|
|
|
9
9
|
const ID = /^[a-z0-9][a-z0-9.-]{0,80}$/;
|
|
10
|
-
const ACTIONS = ['scope', 'anchor', 'gate', 'compare', 'rules'];
|
|
10
|
+
const ACTIONS = ['scope', 'anchor', 'gate', 'continue', 'compare', 'rules'];
|
|
11
11
|
|
|
12
12
|
function fail(message) {
|
|
13
13
|
throw new Error(message);
|
|
@@ -28,6 +28,48 @@ function writeJson(file, value) {
|
|
|
28
28
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** A reader of the file sees the previous version or the new one, never half of it. */
|
|
32
|
+
function writeAtomically(file, text) {
|
|
33
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
34
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
35
|
+
fs.writeFileSync(temporary, text, { flag: 'wx' });
|
|
36
|
+
try {
|
|
37
|
+
fs.renameSync(temporary, file);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
fs.rmSync(temporary, { force: true });
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const inside = (dir, file) => {
|
|
45
|
+
const relative = path.relative(dir, file);
|
|
46
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where `--emit-check` writes: a JSON file, resolved against the project, never the evidence
|
|
51
|
+
* itself. A working-tree scope selects untracked files, so its check stays in the review
|
|
52
|
+
* folder or out of the project: anywhere else it would enter the change it reports on.
|
|
53
|
+
*/
|
|
54
|
+
function checkTarget(projectDir, value, paths, scope) {
|
|
55
|
+
const file = path.resolve(projectDir, value);
|
|
56
|
+
if (!/\.json$/i.test(file)) fail('--emit-check writes a .json file');
|
|
57
|
+
const evidence = Object.entries(paths).filter(([key]) => key !== 'root');
|
|
58
|
+
if (evidence.some(([, taken]) => path.relative(taken, file) === ''))
|
|
59
|
+
fail('--emit-check must not overwrite the review evidence');
|
|
60
|
+
if (
|
|
61
|
+
scope.identity.workspace &&
|
|
62
|
+
inside(projectDir, file) &&
|
|
63
|
+
!inside(path.dirname(paths.root), file)
|
|
64
|
+
)
|
|
65
|
+
fail(
|
|
66
|
+
`--emit-check: a working-tree review writes its check in ${path.relative(projectDir, path.dirname(paths.root)) || '.'} or outside the project, where it cannot enter the reviewed change`
|
|
67
|
+
);
|
|
68
|
+
if (fs.existsSync(file) && !fs.statSync(file).isFile())
|
|
69
|
+
fail(`--emit-check: ${file} is not a file`);
|
|
70
|
+
return file;
|
|
71
|
+
}
|
|
72
|
+
|
|
31
73
|
function print(json, payload, lines) {
|
|
32
74
|
if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
|
|
33
75
|
else for (const line of lines) console.log(line);
|
|
@@ -46,6 +88,75 @@ function loadReview(projectDir, dir, id) {
|
|
|
46
88
|
return { paths, scope };
|
|
47
89
|
}
|
|
48
90
|
|
|
91
|
+
/** One line of observed usage: what the host reported, nothing estimated. */
|
|
92
|
+
function describeUsage(stop, usage) {
|
|
93
|
+
const parts = [
|
|
94
|
+
`stopped: ${stop || 'unstated'}`,
|
|
95
|
+
`${usage.passes ?? '?'}/${usage.plannedPasses ?? '?'} pass(es)`,
|
|
96
|
+
`${usage.unitsAttempted}/${usage.units} unit(s) attempted in ${usage.attempts} attempt(s), ${usage.failedAttempts} failed`,
|
|
97
|
+
];
|
|
98
|
+
if (usage.tokens !== undefined) parts.push(`${usage.tokens} tokens`);
|
|
99
|
+
if (usage.durationMs !== undefined) parts.push(`${Math.round(usage.durationMs / 1000)} s`);
|
|
100
|
+
return parts.join(', ');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `review continue <id>`: what an interrupted review still owes, against the same sealed
|
|
105
|
+
* scope. Refused when the code or the rules moved — a new scope and a compare answer that.
|
|
106
|
+
*/
|
|
107
|
+
function continueAction(projectDir, dir, id, paths, scope, json) {
|
|
108
|
+
const drift = review.scopeDrift(projectDir, scope, {
|
|
109
|
+
ruleset: reviewRules.loadRuleset(projectDir),
|
|
110
|
+
outputDir: dir,
|
|
111
|
+
});
|
|
112
|
+
if (drift.length) {
|
|
113
|
+
const next = [
|
|
114
|
+
`bmad-plus review scope <new-id> (same options as ${id})`,
|
|
115
|
+
`bmad-plus review compare <new-id> --since ${id}`,
|
|
116
|
+
];
|
|
117
|
+
print(json, { action: 'continue', id, status: 'moved', drift, next }, [
|
|
118
|
+
`${id} cannot continue: the sealed scope no longer describes the code.`,
|
|
119
|
+
...drift.map((reason) => ` - ${reason}`),
|
|
120
|
+
` Seal a new scope and compare: ${next.join(', then ')}.`,
|
|
121
|
+
]);
|
|
122
|
+
process.exitCode = 3;
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const coverage = readJson(paths.coverage, 'coverage.json');
|
|
126
|
+
const coverageErrors = coverage ? review.validateCoverage(coverage, scope) : [];
|
|
127
|
+
if (coverageErrors.length) {
|
|
128
|
+
print(json, { action: 'continue', id, status: 'error', errors: coverageErrors }, [
|
|
129
|
+
`${id}: coverage.json must be valid before the review continues`,
|
|
130
|
+
...coverageErrors.map((e) => ` error ${e}`),
|
|
131
|
+
]);
|
|
132
|
+
process.exitCode = 3;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const findings = readJson(paths.findings, 'findings.json');
|
|
136
|
+
const findingErrors = findings ? review.validateFindings(findings, scope) : [];
|
|
137
|
+
const anchored =
|
|
138
|
+
findings && !findingErrors.length ? review.anchorFindings(projectDir, scope, findings) : null;
|
|
139
|
+
const packet = review.remainingWork({ scope, coverage, anchored, findingErrors });
|
|
140
|
+
writeJson(paths.continue, packet);
|
|
141
|
+
const c = packet.counts;
|
|
142
|
+
const nothing = !c.files && !c.requote && !c.findingErrors && !c.passes;
|
|
143
|
+
print(json, { action: 'continue', id, status: 'ready', file: paths.continue, counts: c }, [
|
|
144
|
+
`${paths.continue} — same scope ${scope.sha256.slice(0, 12)}`,
|
|
145
|
+
`${c.files} file(s) in ${c.units} unit(s) to review, ${c.requote} finding(s) to requote, ${c.passes} pass(es) left`,
|
|
146
|
+
...packet.units.map((unit) => ` ${unit.id.padEnd(5)} ${unit.paths.join(', ')}`),
|
|
147
|
+
...packet.abandoned.map(
|
|
148
|
+
(file) => ` abandoned ${file.path} (${file.unit}): three failed attempts; it stays failed`
|
|
149
|
+
),
|
|
150
|
+
...packet.requote.map((f) => ` requote ${f.id} ${f.path} (${f.status})`),
|
|
151
|
+
...packet.findingErrors.map((e) => ` fix ${e}`),
|
|
152
|
+
nothing
|
|
153
|
+
? `Nothing remains: once coverage.json records the run as completed, run bmad-plus review gate ${id}.`
|
|
154
|
+
: 'Add to the same coverage.json and findings.json; set run.stop when this session ends.',
|
|
155
|
+
]);
|
|
156
|
+
process.exitCode = 0;
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
49
160
|
/** `review rules [path]`: the effective rule set, or the rules one path would get. */
|
|
50
161
|
function rulesAction(projectDir, target, json) {
|
|
51
162
|
const ruleset = reviewRules.loadRuleset(projectDir);
|
|
@@ -60,9 +171,10 @@ function rulesAction(projectDir, target, json) {
|
|
|
60
171
|
sha256: ruleset.sha256,
|
|
61
172
|
projectFile: ruleset.projectFile,
|
|
62
173
|
disabled: ruleset.disabled,
|
|
63
|
-
rules: listed.map(({ id, title, layer, globs, source }) => ({
|
|
174
|
+
rules: listed.map(({ id, title, group, layer, globs, source }) => ({
|
|
64
175
|
id,
|
|
65
176
|
title,
|
|
177
|
+
group,
|
|
66
178
|
layer,
|
|
67
179
|
globs,
|
|
68
180
|
source,
|
|
@@ -72,7 +184,8 @@ function rulesAction(projectDir, target, json) {
|
|
|
72
184
|
`rule set ${ruleset.sha256.slice(0, 12)}${ruleset.projectFile ? ` (built-in + ${ruleset.projectFile})` : ' (built-in)'}`,
|
|
73
185
|
...(file ? [`${file} gets ${listed.length} rule(s):`] : []),
|
|
74
186
|
...listed.map(
|
|
75
|
-
(rule) =>
|
|
187
|
+
(rule) =>
|
|
188
|
+
` ${rule.id.padEnd(24)} ${rule.group.padEnd(12)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}`
|
|
76
189
|
),
|
|
77
190
|
...ruleset.disabled.map((id) => ` ${id.padEnd(24)} disabled by the project`),
|
|
78
191
|
]
|
|
@@ -81,7 +194,7 @@ function rulesAction(projectDir, target, json) {
|
|
|
81
194
|
|
|
82
195
|
module.exports = {
|
|
83
196
|
command: 'review <action> [id]',
|
|
84
|
-
description: 'Code review evidence: scope, anchor, gate, compare, rules',
|
|
197
|
+
description: 'Code review evidence: scope, anchor, gate, continue, compare, rules',
|
|
85
198
|
options: [
|
|
86
199
|
['-d, --directory <path>', 'Project directory'],
|
|
87
200
|
['--dir <path>', 'Review folder inside the project', review.DEFAULT_DIR],
|
|
@@ -95,6 +208,7 @@ module.exports = {
|
|
|
95
208
|
`Review depth: ${Object.keys(review.EFFORTS).join(', ')} (default medium)`,
|
|
96
209
|
],
|
|
97
210
|
['--since <id>', 'compare: the earlier review to compare against'],
|
|
211
|
+
['--emit-check <file>', 'gate: also write the verdict as a JSON check result for CI'],
|
|
98
212
|
['--json', 'Machine-readable output'],
|
|
99
213
|
],
|
|
100
214
|
action: (action, id, options = {}) => {
|
|
@@ -116,6 +230,7 @@ module.exports = {
|
|
|
116
230
|
include: options.include,
|
|
117
231
|
exclude: options.exclude,
|
|
118
232
|
effort: options.effort,
|
|
233
|
+
outputDir: dir,
|
|
119
234
|
ruleset,
|
|
120
235
|
});
|
|
121
236
|
const paths = review.layout(projectDir, dir, id);
|
|
@@ -201,21 +316,33 @@ module.exports = {
|
|
|
201
316
|
if (findings && !review.validateFindings(findings, scope).length)
|
|
202
317
|
anchored = review.anchorFindings(projectDir, scope, findings);
|
|
203
318
|
const verdict = review.reviewGate({ scope, findings, coverage, anchored });
|
|
319
|
+
let check = null;
|
|
320
|
+
if (options.emitCheck) {
|
|
321
|
+
check = checkTarget(projectDir, options.emitCheck, paths, scope);
|
|
322
|
+
writeAtomically(
|
|
323
|
+
check,
|
|
324
|
+
`${JSON.stringify(review.checkResult({ id, scope, verdict, anchored }), null, 2)}\n`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
204
327
|
print(
|
|
205
328
|
json,
|
|
206
|
-
{ action, id, ...verdict },
|
|
329
|
+
{ action, id, ...verdict, check },
|
|
207
330
|
[
|
|
208
331
|
`${id}: ${verdict.status} — coverage ${verdict.coverage.completed}/${verdict.coverage.selected} completed (${verdict.coverage.waived} waived), ${verdict.findings.open} open finding(s), ${verdict.findings.refuted} refuted`,
|
|
209
332
|
...verdict.reasons.map((reason) => ` - ${reason}`),
|
|
333
|
+
verdict.usage ? ` run: ${describeUsage(verdict.stop, verdict.usage)}` : '',
|
|
334
|
+
check ? ` check: ${check}` : '',
|
|
210
335
|
verdict.status === 'clean'
|
|
211
336
|
? ' clean: no open finding within a fully covered scope. It does not prove the code correct.'
|
|
212
337
|
: '',
|
|
213
338
|
].filter(Boolean)
|
|
214
339
|
);
|
|
215
|
-
process.exitCode =
|
|
340
|
+
process.exitCode = review.GATE_EXIT[verdict.status];
|
|
216
341
|
return undefined;
|
|
217
342
|
}
|
|
218
343
|
|
|
344
|
+
if (action === 'continue') return continueAction(projectDir, dir, id, paths, scope, json);
|
|
345
|
+
|
|
219
346
|
// compare: this review against an earlier one.
|
|
220
347
|
const sinceId = requireId(options.since, '--since <earlier review id>');
|
|
221
348
|
if (sinceId === id) fail('--since must name a different review');
|
package/tools/cli/lib/packs.js
CHANGED
|
@@ -16,7 +16,7 @@ const SCHEMA = 'bmad-plus/review-rules/1';
|
|
|
16
16
|
const BUILTIN_DIR = path.join(__dirname, '..', 'review-rules');
|
|
17
17
|
const PROJECT_FILE = path.join('_bmad', 'review-rules.yaml');
|
|
18
18
|
const RULE_ID = /^[a-z0-9][a-z0-9-]{0,60}$/;
|
|
19
|
-
const RULE_KEYS = ['id', 'title', 'globs', 'doc'];
|
|
19
|
+
const RULE_KEYS = ['id', 'title', 'group', 'globs', 'doc'];
|
|
20
20
|
const MAX_DOC_BYTES = 64 * 1024;
|
|
21
21
|
const MAX_INDEX_BYTES = 256 * 1024;
|
|
22
22
|
|
|
@@ -60,11 +60,13 @@ function parseLayer(root, indexName, layer) {
|
|
|
60
60
|
if (!rule || typeof rule !== 'object') throw new Error(`${at}: must be a mapping`);
|
|
61
61
|
const unknown = Object.keys(rule).filter((key) => !RULE_KEYS.includes(key));
|
|
62
62
|
if (unknown.length) throw new Error(`${at}: unknown key(s) ${unknown.join(', ')}`);
|
|
63
|
-
if (!RULE_ID.test(
|
|
63
|
+
if (typeof rule.id !== 'string' || !RULE_ID.test(rule.id)) throw new Error(`${at}: invalid id`);
|
|
64
64
|
if (seen.has(rule.id)) throw new Error(`${at}: duplicate id`);
|
|
65
65
|
seen.add(rule.id);
|
|
66
66
|
if (typeof rule.title !== 'string' || !rule.title.trim())
|
|
67
67
|
throw new Error(`${at}: title is required`);
|
|
68
|
+
if (rule.group !== undefined && !(typeof rule.group === 'string' && RULE_ID.test(rule.group)))
|
|
69
|
+
throw new Error(`${at}: invalid group`);
|
|
68
70
|
if (
|
|
69
71
|
!Array.isArray(rule.globs) ||
|
|
70
72
|
!rule.globs.length ||
|
|
@@ -78,6 +80,8 @@ function parseLayer(root, indexName, layer) {
|
|
|
78
80
|
return {
|
|
79
81
|
id: rule.id,
|
|
80
82
|
title: rule.title.trim(),
|
|
83
|
+
// A rule family reviewers can split by; a rule without one is a family of its own.
|
|
84
|
+
group: rule.group === undefined ? rule.id : rule.group,
|
|
81
85
|
globs: [...rule.globs],
|
|
82
86
|
layer,
|
|
83
87
|
source: rule.doc,
|
|
@@ -107,7 +111,13 @@ function loadRuleset(projectDir, { builtinDir = BUILTIN_DIR } = {}) {
|
|
|
107
111
|
for (const id of disabled) ordered.delete(id);
|
|
108
112
|
}
|
|
109
113
|
const list = [...ordered.values()];
|
|
110
|
-
const fingerprint = list.map((rule) => [
|
|
114
|
+
const fingerprint = list.map((rule) => [
|
|
115
|
+
rule.id,
|
|
116
|
+
rule.group,
|
|
117
|
+
rule.layer,
|
|
118
|
+
rule.globs,
|
|
119
|
+
sha256(rule.text),
|
|
120
|
+
]);
|
|
111
121
|
return {
|
|
112
122
|
rules: list,
|
|
113
123
|
disabled,
|
|
@@ -121,6 +131,16 @@ function rulesFor(ruleset, file) {
|
|
|
121
131
|
return ruleset.rules.filter((rule) => matchesAny(file, rule.globs)).map((rule) => rule.id);
|
|
122
132
|
}
|
|
123
133
|
|
|
134
|
+
/** The rule families of a set of rule ids, each with its rules, in rule order. */
|
|
135
|
+
function groupsOf(ruleset, ids) {
|
|
136
|
+
const groups = new Map();
|
|
137
|
+
for (const rule of ruleset.rules) {
|
|
138
|
+
if (!ids.includes(rule.id)) continue;
|
|
139
|
+
groups.set(rule.group, [...(groups.get(rule.group) || []), rule.id]);
|
|
140
|
+
}
|
|
141
|
+
return [...groups].map(([id, rules]) => ({ id, rules }));
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
/**
|
|
125
145
|
* The reviewer's checklist for a scope: each applicable rule once, with the files it
|
|
126
146
|
* covers. Rules that match nothing in the scope are left out.
|
|
@@ -136,6 +156,8 @@ function checklist(ruleset, byPath, { title = 'Review checklist' } = {}) {
|
|
|
136
156
|
[
|
|
137
157
|
`## ${rule.title} \`${rule.id}\`${rule.layer === 'project' ? ' (project rule)' : ''}`,
|
|
138
158
|
'',
|
|
159
|
+
`Group: \`${rule.group}\``,
|
|
160
|
+
'',
|
|
139
161
|
`Applies to: ${files
|
|
140
162
|
.get(rule.id)
|
|
141
163
|
.map((file) => `\`${file}\``)
|
|
@@ -153,4 +175,12 @@ function checklist(ruleset, byPath, { title = 'Review checklist' } = {}) {
|
|
|
153
175
|
].join('\n');
|
|
154
176
|
}
|
|
155
177
|
|
|
156
|
-
module.exports = {
|
|
178
|
+
module.exports = {
|
|
179
|
+
SCHEMA,
|
|
180
|
+
BUILTIN_DIR,
|
|
181
|
+
PROJECT_FILE,
|
|
182
|
+
loadRuleset,
|
|
183
|
+
rulesFor,
|
|
184
|
+
groupsOf,
|
|
185
|
+
checklist,
|
|
186
|
+
};
|