@giamme/forge 0.0.0-stage → 0.8.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 +181 -0
- package/LICENSE +21 -0
- package/README.md +1854 -2
- package/SKILL.md +122 -0
- package/VERSION +1 -0
- package/forge +74 -0
- package/package.json +26 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +353 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +665 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runtime.py +533 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +198 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +83 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +287 -0
- package/ui/THIRD_PARTY_NOTICES.md +69 -0
- package/ui/docs/api.md +888 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +204 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +583 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1336 -0
- package/ui/forge_ui/server.py +303 -0
- package/ui/forge_ui/static/app.js +924 -0
- package/ui/forge_ui/static/cloud3d.js +646 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +394 -0
- package/ui/forge_ui/static/diffview.js +216 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/index.html +55 -0
- package/ui/forge_ui/static/iso.js +765 -0
- package/ui/forge_ui/static/leaderboard.js +315 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +526 -0
- package/ui/forge_ui/static/runmodel.js +651 -0
- package/ui/forge_ui/static/scene3d.js +996 -0
- package/ui/forge_ui/static/search.js +119 -0
- package/ui/forge_ui/static/style.css +618 -0
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Warn when the project's own test suites are not exercised by the verify command.
|
|
3
|
+
|
|
4
|
+
forge verifies combined results with whatever command the user hands it. A real
|
|
5
|
+
run skipped a project's node:test suites and a regression reached the integration
|
|
6
|
+
branch with no warning. This module reads the project's own test configuration
|
|
7
|
+
(package.json scripts, Makefile targets, pytest/tox config, Cargo.toml, go.mod,
|
|
8
|
+
Gemfile/spec, mix.exs, test shell scripts) and reports every discovered suite that
|
|
9
|
+
the verify command does not appear to run. It is advisory only: it never blocks,
|
|
10
|
+
it exits 0 for check/discover, 2 for usage errors and 3 for an unreadable repo.
|
|
11
|
+
|
|
12
|
+
forge-verify-coverage.py discover --repo DIR [--json]
|
|
13
|
+
forge-verify-coverage.py check --repo DIR (--command CMD | --command-file FILE | --none)
|
|
14
|
+
[--json] [--out FILE]
|
|
15
|
+
|
|
16
|
+
Stdlib only, python3.9+. Callers prefix each human line with "forge: ".
|
|
17
|
+
|
|
18
|
+
Known limits (v1)
|
|
19
|
+
- Root of the repo only. Monorepo workspace packages are not discovered; a root
|
|
20
|
+
script that fans out (`pnpm -r test`, `turbo run test`) counts as running the
|
|
21
|
+
root suite of that name, nothing more.
|
|
22
|
+
- Coverage is a text heuristic over the verify command, not shell evaluation:
|
|
23
|
+
variables, functions, `$(...)`, heredocs and conditionals are not interpreted.
|
|
24
|
+
Everything that is merely mentioned counts as run (`echo npm test` is
|
|
25
|
+
"covered"), because under-warning is preferred to noise.
|
|
26
|
+
- Only package.json script bodies, Makefile recipes and shell scripts that live in
|
|
27
|
+
the repo are expanded; other interpreters' scripts (node/python) are opaque.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
37
|
+
|
|
38
|
+
READ_LIMIT = 1 << 20 # bytes read from any single file
|
|
39
|
+
EXPAND_BUDGET = 400 # hard cap on expansions per check (cycle/blowup guard)
|
|
40
|
+
MAX_SCRIPT_DEPTH = 4 # nested `npm run X` body expansions
|
|
41
|
+
MAX_MAKE_DEPTH = 2 # nested `make X` recipe expansions
|
|
42
|
+
MAX_FILE_DEPTH = 2 # nested repo shell scripts (bash scripts/ci.sh -> inner.sh)
|
|
43
|
+
MAX_TEXT_DEPTH = 3 # nested `bash -c "..."` / eval strings
|
|
44
|
+
DEFAULT_MAX_LINES = 10 # per-suite human lines before "and N more"
|
|
45
|
+
|
|
46
|
+
# State threaded through expansion: (script depth, make depth, file depth, text depth).
|
|
47
|
+
State = Tuple[int, int, int, int]
|
|
48
|
+
Tokens = List[str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --------------------------------------------------------------------------- shell text
|
|
52
|
+
|
|
53
|
+
def read_text(path: str, limit: int = READ_LIMIT) -> str:
|
|
54
|
+
try:
|
|
55
|
+
with open(path, 'rb') as handle:
|
|
56
|
+
data = handle.read(limit)
|
|
57
|
+
except OSError:
|
|
58
|
+
return ''
|
|
59
|
+
return data.decode('utf-8', 'replace')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize(text: str) -> str:
|
|
63
|
+
"""Join backslash-continued lines and drop comments; newlines stay as separators.
|
|
64
|
+
|
|
65
|
+
A `#` starts a comment only at the start of a word and outside quotes, so a
|
|
66
|
+
commented-out `# npm run test:contract` never counts as coverage.
|
|
67
|
+
"""
|
|
68
|
+
text = text.replace('\r\n', '\n').replace('\r', '\n').replace('\\\n', ' ')
|
|
69
|
+
out: List[str] = []
|
|
70
|
+
quote = ''
|
|
71
|
+
prev = '\n'
|
|
72
|
+
i, n = 0, len(text)
|
|
73
|
+
while i < n:
|
|
74
|
+
c = text[i]
|
|
75
|
+
if quote:
|
|
76
|
+
out.append(c)
|
|
77
|
+
if c == '\\' and quote == '"' and i + 1 < n:
|
|
78
|
+
out.append(text[i + 1]); prev = text[i + 1]; i += 2
|
|
79
|
+
continue
|
|
80
|
+
if c == quote:
|
|
81
|
+
quote = ''
|
|
82
|
+
prev = c; i += 1
|
|
83
|
+
continue
|
|
84
|
+
if c == '\\' and i + 1 < n:
|
|
85
|
+
out.append(c); out.append(text[i + 1]); prev = text[i + 1]; i += 2
|
|
86
|
+
continue
|
|
87
|
+
if c in '\'"':
|
|
88
|
+
quote = c
|
|
89
|
+
elif c == '#' and (prev.isspace() or prev in ';&|()'):
|
|
90
|
+
while i < n and text[i] != '\n':
|
|
91
|
+
i += 1
|
|
92
|
+
continue
|
|
93
|
+
out.append(c); prev = c; i += 1
|
|
94
|
+
return ''.join(out)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def split_commands(text: str) -> List[Tokens]:
|
|
98
|
+
"""Split shell text into simple commands (token lists) at ; && || | & ( ) and newlines.
|
|
99
|
+
|
|
100
|
+
Quotes are removed, quoted words stay one token. Unbalanced quotes never raise.
|
|
101
|
+
"""
|
|
102
|
+
cmds: List[Tokens] = []
|
|
103
|
+
cur: Tokens = []
|
|
104
|
+
tok: List[str] = []
|
|
105
|
+
state = {'has': False}
|
|
106
|
+
|
|
107
|
+
def end_token() -> None:
|
|
108
|
+
if state['has']:
|
|
109
|
+
cur.append(''.join(tok))
|
|
110
|
+
del tok[:]
|
|
111
|
+
state['has'] = False
|
|
112
|
+
|
|
113
|
+
def end_command() -> None:
|
|
114
|
+
end_token()
|
|
115
|
+
if cur:
|
|
116
|
+
cmds.append(cur[:])
|
|
117
|
+
del cur[:]
|
|
118
|
+
|
|
119
|
+
quote = ''
|
|
120
|
+
i, n = 0, len(text)
|
|
121
|
+
while i < n:
|
|
122
|
+
c = text[i]
|
|
123
|
+
if quote == "'":
|
|
124
|
+
if c == "'":
|
|
125
|
+
quote = ''
|
|
126
|
+
else:
|
|
127
|
+
tok.append(c)
|
|
128
|
+
i += 1
|
|
129
|
+
continue
|
|
130
|
+
if quote == '"':
|
|
131
|
+
if c == '"':
|
|
132
|
+
quote = ''
|
|
133
|
+
elif c == '\\' and i + 1 < n and text[i + 1] in '"\\$`':
|
|
134
|
+
tok.append(text[i + 1]); i += 1
|
|
135
|
+
else:
|
|
136
|
+
tok.append(c)
|
|
137
|
+
i += 1
|
|
138
|
+
continue
|
|
139
|
+
if c == '\\' and i + 1 < n:
|
|
140
|
+
tok.append(text[i + 1]); state['has'] = True; i += 2
|
|
141
|
+
continue
|
|
142
|
+
if c in '\'"':
|
|
143
|
+
quote = c; state['has'] = True; i += 1
|
|
144
|
+
continue
|
|
145
|
+
if c in ' \t':
|
|
146
|
+
end_token(); i += 1
|
|
147
|
+
continue
|
|
148
|
+
if c in '\n;`()':
|
|
149
|
+
end_command(); i += 1
|
|
150
|
+
continue
|
|
151
|
+
if c == '|':
|
|
152
|
+
end_command(); i += 2 if text[i:i + 2] == '||' else 1
|
|
153
|
+
continue
|
|
154
|
+
if c == '&':
|
|
155
|
+
if text[i:i + 2] == '&&':
|
|
156
|
+
end_command(); i += 2
|
|
157
|
+
elif (i > 0 and text[i - 1] in '<>') or text[i + 1:i + 2] == '>':
|
|
158
|
+
tok.append(c); state['has'] = True; i += 1 # 2>&1, &>file
|
|
159
|
+
else:
|
|
160
|
+
end_command(); i += 1
|
|
161
|
+
continue
|
|
162
|
+
tok.append(c); state['has'] = True; i += 1
|
|
163
|
+
end_command()
|
|
164
|
+
return cmds
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse(text: str) -> List[Tokens]:
|
|
168
|
+
return split_commands(normalize(text))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
ENV_ASSIGN = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
|
|
172
|
+
WRAPPERS = {'cross-env', 'env', 'time', 'npx', 'exec', 'sudo', 'command', 'nohup', 'xvfb-run', 'dotenv'}
|
|
173
|
+
TRIVIAL = {'echo', 'printf', 'true', 'false', ':', 'exit', 'cd', 'export', 'set', 'unset', 'rm', 'rimraf',
|
|
174
|
+
'mkdir', 'mkdirp', 'cp', 'mv', 'cat', 'clear', 'sleep', 'pwd', 'touch', 'ls', 'rmdir', 'shx',
|
|
175
|
+
# shell plumbing inside scripts
|
|
176
|
+
'fi', 'done', 'esac', 'case', 'for', 'function', 'local', 'declare', 'readonly', 'shift', 'return',
|
|
177
|
+
'trap', '[', '[[', 'test', 'source', '.', 'wait', 'break', 'continue', '}'}
|
|
178
|
+
KEYWORDS = {'if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', '{'}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def canonical(tokens: Tokens) -> Tokens:
|
|
182
|
+
"""Drop leading env assignments, wrappers and control keywords (`CI=1 cross-env X=1 npx -y jest` -> `jest`)."""
|
|
183
|
+
t = list(tokens)
|
|
184
|
+
while t:
|
|
185
|
+
if ENV_ASSIGN.match(t[0]) or t[0] in KEYWORDS:
|
|
186
|
+
t.pop(0)
|
|
187
|
+
elif t[0] in WRAPPERS:
|
|
188
|
+
t.pop(0)
|
|
189
|
+
while t and (t[0].startswith('-') or ENV_ASSIGN.match(t[0])):
|
|
190
|
+
t.pop(0)
|
|
191
|
+
elif t[0] == 'timeout':
|
|
192
|
+
t.pop(0)
|
|
193
|
+
while t and t[0].startswith('-'):
|
|
194
|
+
t.pop(0)
|
|
195
|
+
if t and re.match(r'^\d', t[0]):
|
|
196
|
+
t.pop(0)
|
|
197
|
+
else:
|
|
198
|
+
break
|
|
199
|
+
return t
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
TEST_RUNNERS = {'vitest', 'jest', 'mocha', 'ava', 'playwright', 'cypress', 'pytest', 'py.test', 'tap', 'rspec', 'ginkgo'}
|
|
203
|
+
TEST_SUBCOMMAND_RUNNERS = {'go', 'cargo', 'mix', 'deno', 'bun'}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def is_test_runner(tokens: Tokens) -> bool:
|
|
207
|
+
"""tokens (canonical) start with a known test runner invocation."""
|
|
208
|
+
if not tokens:
|
|
209
|
+
return False
|
|
210
|
+
first = os.path.basename(tokens[0])
|
|
211
|
+
if first in TEST_RUNNERS:
|
|
212
|
+
return True
|
|
213
|
+
if first == 'node':
|
|
214
|
+
return '--test' in tokens
|
|
215
|
+
return first in TEST_SUBCOMMAND_RUNNERS and len(tokens) > 1 and tokens[1] == 'test'
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def is_trivial(tokens: Tokens) -> bool:
|
|
219
|
+
c = canonical(tokens)
|
|
220
|
+
return not c or os.path.basename(c[0]) in TRIVIAL
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------------- Makefile
|
|
224
|
+
|
|
225
|
+
class MakeInfo:
|
|
226
|
+
def __init__(self) -> None:
|
|
227
|
+
self.targets: Dict[str, Dict[str, List[str]]] = {}
|
|
228
|
+
self.default: Optional[str] = None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
MAKE_ASSIGN = re.compile(r'^[^:#\s][^:]*?:{1,3}=')
|
|
232
|
+
MAKE_RULE = re.compile(r'^([^\s:=#$%][^:=#$%]*?)\s*::?(?!=)\s*(.*)$')
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def parse_makefile(text: str) -> MakeInfo:
|
|
236
|
+
"""Targets with their prerequisites and recipe lines; pattern rules, .PHONY and variables skipped."""
|
|
237
|
+
info = MakeInfo()
|
|
238
|
+
lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
|
239
|
+
joined: List[str] = []
|
|
240
|
+
for line in lines:
|
|
241
|
+
if joined and joined[-1].endswith('\\'):
|
|
242
|
+
joined[-1] = joined[-1][:-1] + ' ' + line.lstrip('\t ')
|
|
243
|
+
else:
|
|
244
|
+
joined.append(line)
|
|
245
|
+
current: List[str] = []
|
|
246
|
+
for line in joined:
|
|
247
|
+
if line.startswith('\t'):
|
|
248
|
+
command = line[1:].lstrip('@-+ \t').replace('$(MAKE)', 'make').replace('${MAKE}', 'make').strip()
|
|
249
|
+
if command and not command.startswith('#'):
|
|
250
|
+
for name in current:
|
|
251
|
+
info.targets[name]['recipe'].append(command)
|
|
252
|
+
continue
|
|
253
|
+
stripped = line.strip()
|
|
254
|
+
if not stripped or stripped.startswith('#'):
|
|
255
|
+
continue
|
|
256
|
+
if MAKE_ASSIGN.match(line):
|
|
257
|
+
current = []
|
|
258
|
+
continue
|
|
259
|
+
match = MAKE_RULE.match(line)
|
|
260
|
+
if not match:
|
|
261
|
+
current = []
|
|
262
|
+
continue
|
|
263
|
+
names = [n for n in match.group(1).split() if not n.startswith('.')]
|
|
264
|
+
rest, _, inline = match.group(2).partition(';')
|
|
265
|
+
prereqs = [p for p in rest.split() if p != '|' and '$' not in p and '%' not in p]
|
|
266
|
+
for name in names:
|
|
267
|
+
entry = info.targets.setdefault(name, {'prereqs': [], 'recipe': []})
|
|
268
|
+
entry['prereqs'].extend(prereqs)
|
|
269
|
+
if info.default is None:
|
|
270
|
+
info.default = name
|
|
271
|
+
current = names
|
|
272
|
+
command = inline.strip().replace('$(MAKE)', 'make').replace('${MAKE}', 'make')
|
|
273
|
+
if command:
|
|
274
|
+
for name in names:
|
|
275
|
+
info.targets[name]['recipe'].append(command)
|
|
276
|
+
return info
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --------------------------------------------------------------------------- invocation scanner
|
|
280
|
+
|
|
281
|
+
PM_NAMES = {'npm', 'pnpm', 'yarn', 'bun'}
|
|
282
|
+
RUN_WORDS = {'run', 'run-script', 'rum', 'urn'}
|
|
283
|
+
TEST_ALIASES = {'test', 't', 'tst'}
|
|
284
|
+
NPM_BUILTIN_SCRIPTS = {'start', 'stop', 'restart'}
|
|
285
|
+
# `pnpm X` / `yarn X` run script X unless X is one of the package manager's own commands.
|
|
286
|
+
PM_RESERVED = {
|
|
287
|
+
'install', 'i', 'add', 'remove', 'rm', 'uninstall', 'update', 'up', 'upgrade', 'exec', 'dlx', 'create',
|
|
288
|
+
'init', 'publish', 'pack', 'link', 'unlink', 'audit', 'outdated', 'list', 'ls', 'why', 'rebuild', 'prune',
|
|
289
|
+
'fetch', 'deploy', 'env', 'setup', 'store', 'config', 'cache', 'info', 'global', 'bin', 'version', 'set',
|
|
290
|
+
'workspace', 'workspaces', 'node', 'plugin', 'import', 'login', 'logout', 'whoami', 'tag', 'team', 'owner',
|
|
291
|
+
'help', 'licenses', 'recursive', 'patch', 'patch-commit', 'self-update', 'doctor', 'root', 'x',
|
|
292
|
+
}
|
|
293
|
+
VALUE_FLAGS = {'--prefix', '-C', '--cwd', '--dir', '-w', '--workspace', '--filter', '-F', '--filter-prod'}
|
|
294
|
+
RUNALL_NAMES = {'npm-run-all', 'npm-run-all2', 'run-s', 'run-p'}
|
|
295
|
+
RUNALL_VALUE_FLAGS = {'--max-parallel', '--npm-path', '--arglist'}
|
|
296
|
+
MAKE_NAMES = {'make', 'gmake', '$MAKE', '${MAKE}'}
|
|
297
|
+
MAKE_VALUE_FLAGS = {'-C', '-f', '-I', '-o', '-W', '-E', '--directory', '--file', '--include-dir',
|
|
298
|
+
'--old-file', '--what-if', '--assume-old', '--assume-new'}
|
|
299
|
+
SHELLS = {'sh', 'bash', 'zsh', 'dash', 'ksh'}
|
|
300
|
+
SCRIPT_EXTS = {'.sh', '.bash', '.zsh', '.ksh', '.js', '.mjs', '.cjs', '.ts', '.mts', '.py', '.rb', '.pl', '.php'}
|
|
301
|
+
SHELL_EXTS = {'.sh', '.bash', '.zsh', '.ksh'}
|
|
302
|
+
SHEBANG_SHELL = re.compile(r'^#!.*\b(?:ba|z|da|k)?sh\b')
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def skip_flags(tokens: Tokens, j: int) -> int:
|
|
306
|
+
n = len(tokens)
|
|
307
|
+
while j < n and tokens[j].startswith('-') and tokens[j] != '--':
|
|
308
|
+
flag = tokens[j]
|
|
309
|
+
j += 1
|
|
310
|
+
if flag in VALUE_FLAGS and j < n:
|
|
311
|
+
j += 1
|
|
312
|
+
return j
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def pm_scripts(tokens: Tokens, i: int, pm: str) -> List[str]:
|
|
316
|
+
"""Script names run by the package-manager invocation starting at tokens[i].
|
|
317
|
+
|
|
318
|
+
npm: `npm run X`, `npm run-script X`, `npm rum|urn X`, built-ins `npm test|t|tst` and
|
|
319
|
+
`npm start|stop|restart` (NOT `npm X`: `npm ci` is an install). pnpm/yarn: `<pm> run X`
|
|
320
|
+
and `<pm> X` unless X is a built-in command. bun: `bun run X` only (`bun test` is bun's
|
|
321
|
+
own runner; a script whose body is `bun test` is matched through its body instead).
|
|
322
|
+
Options before/after the subcommand (`--silent`, `--prefix d`, `-r`, `--filter p`) are skipped.
|
|
323
|
+
A workspace/filter-scoped call still counts (under-warn).
|
|
324
|
+
"""
|
|
325
|
+
n = len(tokens)
|
|
326
|
+
j = skip_flags(tokens, i + 1)
|
|
327
|
+
if j >= n:
|
|
328
|
+
return []
|
|
329
|
+
sub = tokens[j]
|
|
330
|
+
if sub in RUN_WORDS:
|
|
331
|
+
k = skip_flags(tokens, j + 1)
|
|
332
|
+
return [tokens[k]] if k < n and tokens[k] != '--' else []
|
|
333
|
+
if pm == 'bun':
|
|
334
|
+
return []
|
|
335
|
+
if sub in TEST_ALIASES:
|
|
336
|
+
return ['test']
|
|
337
|
+
if pm == 'npm':
|
|
338
|
+
return [sub] if sub in NPM_BUILTIN_SCRIPTS else []
|
|
339
|
+
if sub in PM_RESERVED or sub.startswith('-') or not re.match(r'^[A-Za-z0-9_@][\w:@./-]*$', sub):
|
|
340
|
+
return []
|
|
341
|
+
return [sub]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def glob_names(pattern: str, names: List[str]) -> List[str]:
|
|
345
|
+
"""npm-run-all patterns: `*` matches within a `:` segment, `**` across segments."""
|
|
346
|
+
if not any(ch in pattern for ch in '*?'):
|
|
347
|
+
return [pattern]
|
|
348
|
+
rx = ''
|
|
349
|
+
i = 0
|
|
350
|
+
while i < len(pattern):
|
|
351
|
+
if pattern.startswith('**', i):
|
|
352
|
+
rx += '.*'; i += 2
|
|
353
|
+
elif pattern[i] == '*':
|
|
354
|
+
rx += '[^:]*'; i += 1
|
|
355
|
+
elif pattern[i] == '?':
|
|
356
|
+
rx += '[^:]'; i += 1
|
|
357
|
+
else:
|
|
358
|
+
rx += re.escape(pattern[i]); i += 1
|
|
359
|
+
return [name for name in names if re.fullmatch(rx, name)]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def runall_scripts(tokens: Tokens, i: int, names: List[str]) -> List[str]:
|
|
363
|
+
out: List[str] = []
|
|
364
|
+
n = len(tokens)
|
|
365
|
+
j = i + 1
|
|
366
|
+
while j < n:
|
|
367
|
+
t = tokens[j]
|
|
368
|
+
if t == '--':
|
|
369
|
+
break
|
|
370
|
+
if t.startswith('-'):
|
|
371
|
+
j += 2 if t in RUNALL_VALUE_FLAGS else 1
|
|
372
|
+
continue
|
|
373
|
+
pattern = (t.split() or [''])[0]
|
|
374
|
+
j += 1
|
|
375
|
+
if pattern and not pattern.startswith('!'):
|
|
376
|
+
out.extend(glob_names(pattern, names))
|
|
377
|
+
return out
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def make_targets(tokens: Tokens, i: int) -> List[str]:
|
|
381
|
+
targets: List[str] = []
|
|
382
|
+
n = len(tokens)
|
|
383
|
+
j = i + 1
|
|
384
|
+
while j < n:
|
|
385
|
+
t = tokens[j]
|
|
386
|
+
if t.startswith('-'):
|
|
387
|
+
if t in MAKE_VALUE_FLAGS:
|
|
388
|
+
j += 1
|
|
389
|
+
elif t == '-j' and j + 1 < n and tokens[j + 1].isdigit():
|
|
390
|
+
j += 1
|
|
391
|
+
elif '=' not in t and not re.match(r'^\d*[<>]', t):
|
|
392
|
+
targets.append(t)
|
|
393
|
+
j += 1
|
|
394
|
+
return targets
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class Scanner:
|
|
398
|
+
"""Extracts what a simple command runs: package scripts, make targets, repo files, nested shell text."""
|
|
399
|
+
|
|
400
|
+
def __init__(self, repo: str, scripts: Dict[str, str], make: Optional[MakeInfo]) -> None:
|
|
401
|
+
self.repo = os.path.realpath(repo)
|
|
402
|
+
self.scripts = scripts
|
|
403
|
+
self.names = list(scripts)
|
|
404
|
+
self.make = make
|
|
405
|
+
|
|
406
|
+
def resolve_file(self, token: str, base: Optional[str] = None) -> Optional[str]:
|
|
407
|
+
"""Repo-relative posix path when `token` names a script-like file inside the repo."""
|
|
408
|
+
if not token or token.startswith('-') or len(token) > 400 or re.search(r'[*?\[\]{}<>|;&=]', token):
|
|
409
|
+
return None
|
|
410
|
+
t = re.sub(r'^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/', '', token)
|
|
411
|
+
if '$' in t or not t:
|
|
412
|
+
return None
|
|
413
|
+
candidates = [t] if os.path.isabs(t) else [os.path.join(self.repo, t)]
|
|
414
|
+
if base and not os.path.isabs(t):
|
|
415
|
+
candidates.append(os.path.join(base, t))
|
|
416
|
+
for candidate in candidates:
|
|
417
|
+
try:
|
|
418
|
+
real = os.path.realpath(candidate)
|
|
419
|
+
except (OSError, ValueError):
|
|
420
|
+
continue
|
|
421
|
+
if real.startswith(self.repo + os.sep) and os.path.isfile(real):
|
|
422
|
+
ext = os.path.splitext(real)[1]
|
|
423
|
+
if ext in SCRIPT_EXTS or os.access(real, os.X_OK):
|
|
424
|
+
return os.path.relpath(real, self.repo).replace(os.sep, '/')
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
def is_shell(self, rel: str) -> bool:
|
|
428
|
+
if os.path.splitext(rel)[1] in SHELL_EXTS:
|
|
429
|
+
return True
|
|
430
|
+
head = read_text(os.path.join(self.repo, rel), 256).split('\n', 1)[0]
|
|
431
|
+
return bool(SHEBANG_SHELL.match(head))
|
|
432
|
+
|
|
433
|
+
def events(self, tokens: Tokens, base: Optional[str] = None) -> List[Tuple[str, object]]:
|
|
434
|
+
out: List[Tuple[str, object]] = []
|
|
435
|
+
n = len(tokens)
|
|
436
|
+
for i, tok in enumerate(tokens):
|
|
437
|
+
b = os.path.basename(tok)
|
|
438
|
+
if b in PM_NAMES:
|
|
439
|
+
for name in pm_scripts(tokens, i, b):
|
|
440
|
+
out.append(('script', name))
|
|
441
|
+
elif b in RUNALL_NAMES:
|
|
442
|
+
for name in runall_scripts(tokens, i, self.names):
|
|
443
|
+
out.append(('script', name))
|
|
444
|
+
elif b in MAKE_NAMES or tok in MAKE_NAMES:
|
|
445
|
+
out.append(('make', make_targets(tokens, i)))
|
|
446
|
+
elif b in SHELLS:
|
|
447
|
+
for j in range(i + 1, min(n, i + 4)):
|
|
448
|
+
if re.fullmatch(r'-[a-z]*c[a-z]*', tokens[j]) and j + 1 < n:
|
|
449
|
+
out.append(('text', tokens[j + 1]))
|
|
450
|
+
break
|
|
451
|
+
if not tokens[j].startswith('-'):
|
|
452
|
+
break
|
|
453
|
+
elif b == 'eval':
|
|
454
|
+
out.append(('text', ' '.join(tokens[i + 1:])))
|
|
455
|
+
elif b == 'concurrently':
|
|
456
|
+
out.extend(('text', t) for t in tokens[i + 1:] if ' ' in t)
|
|
457
|
+
rel = self.resolve_file(tok, base)
|
|
458
|
+
if rel:
|
|
459
|
+
out.append(('file', rel))
|
|
460
|
+
return out
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class Reach:
|
|
464
|
+
"""Everything the verify command (transitively) runs.
|
|
465
|
+
|
|
466
|
+
cmds every simple command seen: the verify command plus expanded bodies
|
|
467
|
+
scripts package.json script names run (incl. npm pre/post hooks)
|
|
468
|
+
make make targets run (incl. prerequisites)
|
|
469
|
+
files repo-relative script files run
|
|
470
|
+
Expansion is cycle-safe (memoised on node + state) and bounded by the depth
|
|
471
|
+
limits above and a global budget.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
def __init__(self, scanner: Scanner) -> None:
|
|
475
|
+
self.sc = scanner
|
|
476
|
+
self.cmds: List[Tokens] = []
|
|
477
|
+
self.scripts: Set[str] = set()
|
|
478
|
+
self.make: Set[str] = set()
|
|
479
|
+
self.files: Set[str] = set()
|
|
480
|
+
self.seen: Set[tuple] = set()
|
|
481
|
+
self.budget = EXPAND_BUDGET
|
|
482
|
+
|
|
483
|
+
def add_text(self, text: str, st: State, base: Optional[str] = None) -> None:
|
|
484
|
+
for cmd in parse(text):
|
|
485
|
+
self.add_cmd(cmd, st, base)
|
|
486
|
+
|
|
487
|
+
def add_cmd(self, tokens: Tokens, st: State, base: Optional[str]) -> None:
|
|
488
|
+
self.cmds.append(tokens)
|
|
489
|
+
for kind, value in self.sc.events(tokens, base):
|
|
490
|
+
if kind == 'script':
|
|
491
|
+
self.run_script(str(value), st)
|
|
492
|
+
elif kind == 'make':
|
|
493
|
+
self.run_make(list(value), st) # type: ignore[arg-type]
|
|
494
|
+
elif kind == 'file':
|
|
495
|
+
self.run_file(str(value), st)
|
|
496
|
+
elif kind == 'text' and st[3] < MAX_TEXT_DEPTH:
|
|
497
|
+
self.add_text(str(value), (st[0], st[1], st[2], st[3] + 1), base)
|
|
498
|
+
|
|
499
|
+
def expand(self, key: tuple) -> bool:
|
|
500
|
+
if key in self.seen or self.budget <= 0:
|
|
501
|
+
return False
|
|
502
|
+
self.seen.add(key)
|
|
503
|
+
self.budget -= 1
|
|
504
|
+
return True
|
|
505
|
+
|
|
506
|
+
def run_script(self, name: str, st: State) -> None:
|
|
507
|
+
self.scripts.add(name)
|
|
508
|
+
for hook_name in (name, 'pre' + name, 'post' + name): # npm runs preX/postX around X
|
|
509
|
+
body = self.sc.scripts.get(hook_name)
|
|
510
|
+
if hook_name != name and body is not None:
|
|
511
|
+
self.scripts.add(hook_name)
|
|
512
|
+
if body is not None and st[0] < MAX_SCRIPT_DEPTH and self.expand(('s', hook_name, st)):
|
|
513
|
+
self.add_text(body, (st[0] + 1, st[1], st[2], st[3]))
|
|
514
|
+
|
|
515
|
+
def run_make(self, targets: List[str], st: State) -> None:
|
|
516
|
+
mk = self.sc.make
|
|
517
|
+
if not targets and mk is not None and mk.default:
|
|
518
|
+
targets = [mk.default] # bare `make` runs the default goal
|
|
519
|
+
visiting: Set[str] = set()
|
|
520
|
+
for target in targets:
|
|
521
|
+
self._make_target(target, st, visiting)
|
|
522
|
+
|
|
523
|
+
def _make_target(self, target: str, st: State, visiting: Set[str]) -> None:
|
|
524
|
+
if target in visiting:
|
|
525
|
+
return
|
|
526
|
+
visiting.add(target)
|
|
527
|
+
self.make.add(target)
|
|
528
|
+
entry = self.sc.make.targets.get(target) if self.sc.make is not None else None
|
|
529
|
+
if entry is None:
|
|
530
|
+
return
|
|
531
|
+
for prereq in entry['prereqs']: # prerequisites always run with the target
|
|
532
|
+
self._make_target(prereq, st, visiting)
|
|
533
|
+
if entry['recipe'] and st[1] < MAX_MAKE_DEPTH and self.expand(('m', target, st)):
|
|
534
|
+
self.add_text('\n'.join(entry['recipe']), (st[0], st[1] + 1, st[2], st[3]))
|
|
535
|
+
|
|
536
|
+
def run_file(self, rel: str, st: State) -> None:
|
|
537
|
+
self.files.add(rel)
|
|
538
|
+
if st[2] < MAX_FILE_DEPTH and self.sc.is_shell(rel) and self.expand(('f', rel, st)):
|
|
539
|
+
text = read_text(os.path.join(self.sc.repo, rel))
|
|
540
|
+
self.add_text(text, (st[0], st[1], st[2] + 1, st[3]), os.path.dirname(os.path.join(self.sc.repo, rel)))
|
|
541
|
+
|
|
542
|
+
def contains(self, part: Tokens) -> bool:
|
|
543
|
+
"""`part` is run by some command that runs.
|
|
544
|
+
|
|
545
|
+
- verbatim, as whole tokens, followed only by flags (`jest --coverage`, `tsc -p x`); a trailing
|
|
546
|
+
positional argument means a narrower run (`node --test test/unit` does not run `node --test`);
|
|
547
|
+
- or the reverse for known test runners: `part` is a narrower run of a runner command that runs
|
|
548
|
+
(`vitest run src/a` is inside `vitest run`, `node --test test/unit` inside `node --test`).
|
|
549
|
+
Only positional narrowing counts; any extra flag (`--config x`, `--project y`) may select
|
|
550
|
+
different tests, and custom scripts' arguments (`node run.mjs perf`) are modes, not filters.
|
|
551
|
+
"""
|
|
552
|
+
m = len(part)
|
|
553
|
+
if not m:
|
|
554
|
+
return False
|
|
555
|
+
for cmd in self.cmds:
|
|
556
|
+
for i in range(len(cmd) - m + 1):
|
|
557
|
+
if cmd[i:i + m] == part and (i + m == len(cmd) or cmd[i + m].startswith('-')):
|
|
558
|
+
return True
|
|
559
|
+
run = canonical(cmd)
|
|
560
|
+
k = len(run)
|
|
561
|
+
if 0 < k < m and part[:k] == run and is_test_runner(run) and not any(t.startswith('-') for t in part[k:]):
|
|
562
|
+
return True
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
def has_word(self, words: Set[str]) -> bool:
|
|
566
|
+
return any(os.path.basename(t) in words for cmd in self.cmds for t in cmd)
|
|
567
|
+
|
|
568
|
+
def has_seq(self, first: str, seconds: Set[str]) -> bool:
|
|
569
|
+
for cmd in self.cmds:
|
|
570
|
+
for i, t in enumerate(cmd):
|
|
571
|
+
if os.path.basename(t) != first:
|
|
572
|
+
continue
|
|
573
|
+
j = i + 1
|
|
574
|
+
while j < len(cmd) and cmd[j][:1] in ('-', '+'): # `cargo +nightly test`
|
|
575
|
+
j += 1
|
|
576
|
+
if j < len(cmd) and cmd[j] in seconds:
|
|
577
|
+
return True
|
|
578
|
+
return False
|
|
579
|
+
|
|
580
|
+
def part_run(self, part: Tokens) -> Optional[bool]:
|
|
581
|
+
"""Is one simple command of a suite body run? None for no-op commands (echo, rm, cd ...)."""
|
|
582
|
+
part = canonical(part)
|
|
583
|
+
if not part or os.path.basename(part[0]) in TRIVIAL:
|
|
584
|
+
return None
|
|
585
|
+
events = self.sc.events(part)
|
|
586
|
+
satisfied = []
|
|
587
|
+
for kind, value in events:
|
|
588
|
+
if kind == 'script':
|
|
589
|
+
satisfied.append(value in self.scripts)
|
|
590
|
+
elif kind == 'make':
|
|
591
|
+
targets = list(value) or ([self.sc.make.default] if self.sc.make and self.sc.make.default else []) # type: ignore[arg-type]
|
|
592
|
+
satisfied.append(all(t in self.make for t in targets))
|
|
593
|
+
elif kind == 'file':
|
|
594
|
+
satisfied.append(value in self.files)
|
|
595
|
+
if satisfied and all(satisfied):
|
|
596
|
+
return True
|
|
597
|
+
return self.contains(part)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# --------------------------------------------------------------------------- discovery
|
|
601
|
+
|
|
602
|
+
SKIP_WORDS = {'start', 'dev', 'serve', 'clean', 'prepare', 'postinstall', 'preinstall', 'install', 'format',
|
|
603
|
+
'watch', 'release', 'publish', 'deploy', 'fix', 'debug', 'ui', 'report', 'open', 'update',
|
|
604
|
+
'record', 'inspect', 'headed', 'list', 'coverage', 'codegen', 'show', 'trace'}
|
|
605
|
+
NODE_PLACEHOLDER = 'no test specified'
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def kind_of_script(name: str) -> Optional[str]:
|
|
609
|
+
if name == 'test' or name.startswith('test:'):
|
|
610
|
+
return 'test'
|
|
611
|
+
if name == 'e2e' or name.startswith('e2e:'):
|
|
612
|
+
return 'test'
|
|
613
|
+
if name == 'lint' or name.startswith('lint:'):
|
|
614
|
+
return 'lint'
|
|
615
|
+
if name in ('typecheck', 'type-check', 'tsc') or name.startswith(('typecheck:', 'type-check:')):
|
|
616
|
+
return 'typecheck'
|
|
617
|
+
if name == 'build':
|
|
618
|
+
return 'build'
|
|
619
|
+
if name in ('ci', 'verify', 'check') or name.startswith('check:'):
|
|
620
|
+
return 'check'
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def skipped_name(name: str) -> bool:
|
|
625
|
+
return bool(set(re.split(r'[:_\-./]', name)) & SKIP_WORDS)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def make_kind(name: str) -> Optional[str]:
|
|
629
|
+
if skipped_name(name):
|
|
630
|
+
return None
|
|
631
|
+
if name in ('test', 'tests') or name.startswith('test-'):
|
|
632
|
+
return 'test'
|
|
633
|
+
if name == 'lint' or name.startswith('lint-'):
|
|
634
|
+
return 'lint'
|
|
635
|
+
if name in ('check', 'verify', 'ci'):
|
|
636
|
+
return 'check'
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def detect_runner(repo: str, package: Optional[dict]) -> str:
|
|
641
|
+
for lock, runner in (('pnpm-lock.yaml', 'pnpm'), ('yarn.lock', 'yarn'), ('bun.lockb', 'bun'), ('bun.lock', 'bun')):
|
|
642
|
+
if os.path.isfile(os.path.join(repo, lock)):
|
|
643
|
+
return runner
|
|
644
|
+
declared = package.get('packageManager') if package else None
|
|
645
|
+
if isinstance(declared, str):
|
|
646
|
+
name = declared.split('@', 1)[0]
|
|
647
|
+
if name in ('pnpm', 'yarn', 'bun'):
|
|
648
|
+
return name
|
|
649
|
+
return 'npm'
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def display_script(runner: str, name: str) -> str:
|
|
653
|
+
return runner + ' test' if name == 'test' else runner + ' run ' + name
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def load_package(repo: str) -> Optional[dict]:
|
|
657
|
+
path = os.path.join(repo, 'package.json')
|
|
658
|
+
if not os.path.isfile(path):
|
|
659
|
+
return None
|
|
660
|
+
try:
|
|
661
|
+
data = json.loads(read_text(path))
|
|
662
|
+
except Exception: # garbled json (incl. RecursionError): skipped silently
|
|
663
|
+
return None
|
|
664
|
+
return data if isinstance(data, dict) else None
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def suite(name: str, source: str, command: str, kind: str, eco: str, **extra: object) -> dict:
|
|
668
|
+
result: dict = {'name': name, 'source': source, 'command': command, 'kind': kind, '_eco': eco}
|
|
669
|
+
result.update(extra)
|
|
670
|
+
return result
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
class Project:
|
|
674
|
+
"""What the repo root defines: suites plus the context needed to expand a verify command."""
|
|
675
|
+
|
|
676
|
+
def __init__(self, repo: str) -> None:
|
|
677
|
+
self.repo = repo
|
|
678
|
+
self.package = load_package(repo)
|
|
679
|
+
raw = self.package.get('scripts') if self.package else None
|
|
680
|
+
self.scripts: Dict[str, str] = (
|
|
681
|
+
{k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)} if isinstance(raw, dict) else {})
|
|
682
|
+
self.runner = detect_runner(repo, self.package)
|
|
683
|
+
self.make_file = next((f for f in ('Makefile', 'makefile', 'GNUmakefile') if os.path.isfile(os.path.join(repo, f))), '')
|
|
684
|
+
self.make: Optional[MakeInfo] = parse_makefile(read_text(os.path.join(repo, self.make_file))) if self.make_file else None
|
|
685
|
+
self.scanner = Scanner(repo, self.scripts, self.make)
|
|
686
|
+
self.suites: List[dict] = []
|
|
687
|
+
self._discover()
|
|
688
|
+
|
|
689
|
+
def exists(self, *parts: str) -> bool:
|
|
690
|
+
return os.path.exists(os.path.join(self.repo, *parts))
|
|
691
|
+
|
|
692
|
+
def _discover(self) -> None:
|
|
693
|
+
self._package_suites()
|
|
694
|
+
self._make_suites()
|
|
695
|
+
self._python_suites()
|
|
696
|
+
if self.exists('Cargo.toml'):
|
|
697
|
+
self.suites.append(suite('cargo test', 'Cargo.toml', 'cargo test', 'test', 'cargo'))
|
|
698
|
+
if self.exists('go.mod'):
|
|
699
|
+
self.suites.append(suite('go test', 'go.mod', 'go test ./...', 'test', 'go'))
|
|
700
|
+
if self.exists('Gemfile') and os.path.isdir(os.path.join(self.repo, 'spec')):
|
|
701
|
+
self.suites.append(suite('rspec', 'Gemfile', 'bundle exec rspec', 'test', 'rspec'))
|
|
702
|
+
if self.exists('mix.exs'):
|
|
703
|
+
self.suites.append(suite('mix test', 'mix.exs', 'mix test', 'test', 'mix'))
|
|
704
|
+
self._file_suites()
|
|
705
|
+
|
|
706
|
+
def _package_suites(self) -> None:
|
|
707
|
+
for name, body in self.scripts.items():
|
|
708
|
+
kind = kind_of_script(name)
|
|
709
|
+
if kind is None or skipped_name(name) or NODE_PLACEHOLDER in body:
|
|
710
|
+
continue
|
|
711
|
+
parts = parse(body)
|
|
712
|
+
if not [p for p in parts if not is_trivial(p)]:
|
|
713
|
+
continue # `echo skipped`, `exit 0`
|
|
714
|
+
self.suites.append(suite(name, 'package.json', display_script(self.runner, name), kind, 'npm',
|
|
715
|
+
_script=name, _parts=parts))
|
|
716
|
+
|
|
717
|
+
def _make_suites(self) -> None:
|
|
718
|
+
if self.make is None:
|
|
719
|
+
return
|
|
720
|
+
for name, entry in self.make.targets.items():
|
|
721
|
+
kind = make_kind(name)
|
|
722
|
+
if kind is None:
|
|
723
|
+
continue
|
|
724
|
+
parts: List[Tokens] = [['make', p] for p in entry['prereqs']]
|
|
725
|
+
for line in entry['recipe']:
|
|
726
|
+
parts.extend(parse(line))
|
|
727
|
+
self.suites.append(suite(name, self.make_file, 'make ' + name, kind, 'make', _target=name, _parts=parts))
|
|
728
|
+
|
|
729
|
+
def _python_suites(self) -> None:
|
|
730
|
+
source = ''
|
|
731
|
+
if self.exists('pytest.ini'):
|
|
732
|
+
source = 'pytest.ini'
|
|
733
|
+
elif self.exists('tox.ini') and re.search(r'\[pytest\]|\bpytest\b', read_text(os.path.join(self.repo, 'tox.ini'))):
|
|
734
|
+
source = 'tox.ini'
|
|
735
|
+
elif self.exists('setup.cfg') and '[tool:pytest]' in read_text(os.path.join(self.repo, 'setup.cfg')):
|
|
736
|
+
source = 'setup.cfg'
|
|
737
|
+
elif self.exists('pyproject.toml') and '[tool.pytest' in read_text(os.path.join(self.repo, 'pyproject.toml')):
|
|
738
|
+
source = 'pyproject.toml'
|
|
739
|
+
if source:
|
|
740
|
+
self.suites.append(suite('pytest', source, 'pytest', 'test', 'pytest'))
|
|
741
|
+
return
|
|
742
|
+
try:
|
|
743
|
+
found = any(f.startswith('test_') and f.endswith('.py') for f in os.listdir(os.path.join(self.repo, 'tests')))
|
|
744
|
+
except OSError:
|
|
745
|
+
found = False
|
|
746
|
+
if found:
|
|
747
|
+
self.suites.append(suite('unittest', 'tests/', 'python -m unittest discover', 'test', 'unittest'))
|
|
748
|
+
|
|
749
|
+
def _file_suites(self) -> None:
|
|
750
|
+
referenced: Set[str] = set()
|
|
751
|
+
for s in self.suites:
|
|
752
|
+
for part in s.get('_parts', []):
|
|
753
|
+
for token in part:
|
|
754
|
+
rel = self.scanner.resolve_file(token)
|
|
755
|
+
if rel:
|
|
756
|
+
referenced.add(rel)
|
|
757
|
+
for directory in ('scripts', 'tests'):
|
|
758
|
+
try:
|
|
759
|
+
names = sorted(os.listdir(os.path.join(self.repo, directory)))
|
|
760
|
+
except OSError:
|
|
761
|
+
continue
|
|
762
|
+
for entry in names:
|
|
763
|
+
stem = entry.lower()
|
|
764
|
+
if not entry.endswith('.sh') or not stem.startswith(('test', 'check', 'verify')):
|
|
765
|
+
continue
|
|
766
|
+
rel = directory + '/' + entry
|
|
767
|
+
if not os.path.isfile(os.path.join(self.repo, rel)) or rel in referenced:
|
|
768
|
+
continue # implementation detail of another suite
|
|
769
|
+
kind = 'test' if stem.startswith('test') else 'check'
|
|
770
|
+
parts = parse(read_text(os.path.join(self.repo, rel), 256 * 1024))
|
|
771
|
+
self.suites.append(suite(rel, rel, 'bash ' + rel, kind, 'file', _path=rel, _parts=parts))
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
# --------------------------------------------------------------------------- coverage
|
|
775
|
+
|
|
776
|
+
R_NAME = 'run by the verify command'
|
|
777
|
+
R_PARTS = 'every command of its body is run by the verify command'
|
|
778
|
+
R_ECO = 'its test runner is invoked by the verify command'
|
|
779
|
+
R_NO = 'not found in the verify command'
|
|
780
|
+
R_NONE = 'no verify command configured'
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def eco_covered(eco: str, reach: Reach) -> bool:
|
|
784
|
+
"""A literal ecosystem runner in the verify command covers the whole ecosystem suite."""
|
|
785
|
+
if eco == 'pytest':
|
|
786
|
+
return reach.has_word({'pytest', 'py.test', 'tox', 'nox'})
|
|
787
|
+
if eco == 'unittest':
|
|
788
|
+
return (reach.has_word({'unittest', 'pytest', 'py.test', 'tox', 'nox', 'nose2', 'nosetests'})
|
|
789
|
+
or any(re.match(r'^test.*\.py$', os.path.basename(t)) for cmd in reach.cmds for t in cmd))
|
|
790
|
+
if eco == 'cargo':
|
|
791
|
+
return reach.has_seq('cargo', {'test', 't', 'nextest', 'llvm-cov', 'tarpaulin', 'miri'})
|
|
792
|
+
if eco == 'go':
|
|
793
|
+
return reach.has_seq('go', {'test'}) or reach.has_word({'gotestsum', 'ginkgo'})
|
|
794
|
+
if eco == 'rspec':
|
|
795
|
+
return reach.has_word({'rspec', 'rake'})
|
|
796
|
+
if eco == 'mix':
|
|
797
|
+
return reach.has_seq('mix', {'test'})
|
|
798
|
+
return False
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def covered_by(s: dict, reach: Reach) -> Tuple[bool, str]:
|
|
802
|
+
eco = s['_eco']
|
|
803
|
+
if eco == 'npm' and s['_script'] in reach.scripts:
|
|
804
|
+
return True, R_NAME
|
|
805
|
+
if eco == 'make' and s['_target'] in reach.make:
|
|
806
|
+
return True, R_NAME
|
|
807
|
+
if eco == 'file' and s['_path'] in reach.files:
|
|
808
|
+
return True, R_NAME
|
|
809
|
+
if eco in ('npm', 'make', 'file'):
|
|
810
|
+
# Its own body is run verbatim (or via the scripts/targets it calls): a composite suite is
|
|
811
|
+
# covered only when every non-trivial part is run; one missing part leaves it uncovered.
|
|
812
|
+
results = [r for r in (reach.part_run(p) for p in s['_parts']) if r is not None]
|
|
813
|
+
if results and all(results):
|
|
814
|
+
return True, R_PARTS
|
|
815
|
+
elif eco_covered(eco, reach):
|
|
816
|
+
return True, R_ECO
|
|
817
|
+
return False, R_NO
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def analyse(repo: str, command: Optional[str], max_lines: int = DEFAULT_MAX_LINES) -> Tuple[dict, List[str]]:
|
|
821
|
+
"""Coverage of `command` (None/blank = no verify command) -> (json-shaped result, human lines).
|
|
822
|
+
|
|
823
|
+
More than `max_lines` per-suite lines (0 = unlimited) are cut to `max_lines` plus one summary line;
|
|
824
|
+
the json result always lists every suite.
|
|
825
|
+
"""
|
|
826
|
+
project = Project(repo)
|
|
827
|
+
verify = (command or '').strip()
|
|
828
|
+
empty = not parse(verify)
|
|
829
|
+
reach: Optional[Reach] = None
|
|
830
|
+
if not empty:
|
|
831
|
+
reach = Reach(project.scanner)
|
|
832
|
+
reach.add_text(verify, (0, 0, 0, 0))
|
|
833
|
+
entries = []
|
|
834
|
+
for s in project.suites:
|
|
835
|
+
if reach is None:
|
|
836
|
+
covered, reason = False, R_NONE
|
|
837
|
+
else:
|
|
838
|
+
covered, reason = covered_by(s, reach)
|
|
839
|
+
warn = not covered and (empty or s['kind'] != 'build') # builds are not expected in verify
|
|
840
|
+
entries.append({'name': s['name'], 'source': s['source'], 'command': s['command'], 'kind': s['kind'],
|
|
841
|
+
'covered': covered, 'reason': reason, 'warn': warn})
|
|
842
|
+
uncovered: List[str] = []
|
|
843
|
+
for e in entries:
|
|
844
|
+
if e['warn'] and e['name'] not in uncovered:
|
|
845
|
+
uncovered.append(e['name'])
|
|
846
|
+
result = {'suites': entries, 'uncovered': uncovered, 'verify_command': verify}
|
|
847
|
+
lines: List[str] = []
|
|
848
|
+
if entries and empty:
|
|
849
|
+
commands = [clean(e['command']) for e in entries]
|
|
850
|
+
shown = ', '.join(commands[:8]) + (', and %d more' % (len(commands) - 8) if len(commands) > 8 else '')
|
|
851
|
+
lines.append('verify coverage: no verify command configured; the project defines: ' + shown)
|
|
852
|
+
elif not empty:
|
|
853
|
+
for e in entries:
|
|
854
|
+
if e['warn']:
|
|
855
|
+
lines.append("verify coverage: suite '%s' (%s) is not run by the verify command"
|
|
856
|
+
% (clean(e['name']), clean(e['command'])))
|
|
857
|
+
if max_lines > 0 and len(lines) > max_lines:
|
|
858
|
+
extra = len(lines) - max_lines
|
|
859
|
+
lines = lines[:max_lines] + ['verify coverage: and %d more suites are not run by the verify command' % extra]
|
|
860
|
+
return result, lines
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def clean(text: str) -> str:
|
|
864
|
+
return re.sub(r'[\x00-\x1f\x7f]+', ' ', text).strip()
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# --------------------------------------------------------------------------- cli
|
|
868
|
+
|
|
869
|
+
def write_out(path: str, lines: List[str]) -> None:
|
|
870
|
+
try:
|
|
871
|
+
if lines:
|
|
872
|
+
with open(path, 'w') as handle:
|
|
873
|
+
handle.write('\n'.join(lines) + '\n')
|
|
874
|
+
elif os.path.lexists(path):
|
|
875
|
+
os.unlink(path)
|
|
876
|
+
except OSError as error:
|
|
877
|
+
sys.stderr.write('forge-verify-coverage: cannot update %s: %s\n' % (path, error))
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
881
|
+
parser = argparse.ArgumentParser(
|
|
882
|
+
prog='forge-verify-coverage',
|
|
883
|
+
description='Warn (never block) when project test suites are not run by the verify command.')
|
|
884
|
+
sub = parser.add_subparsers(dest='action')
|
|
885
|
+
sub.required = True
|
|
886
|
+
discover = sub.add_parser('discover', help='list the suites the project defines')
|
|
887
|
+
discover.add_argument('--repo', required=True)
|
|
888
|
+
discover.add_argument('--json', action='store_true')
|
|
889
|
+
check = sub.add_parser('check', help='report suites the verify command does not run')
|
|
890
|
+
check.add_argument('--repo', required=True)
|
|
891
|
+
group = check.add_mutually_exclusive_group(required=True)
|
|
892
|
+
group.add_argument('--command', help='verify command text')
|
|
893
|
+
group.add_argument('--command-file', help='file holding the verify command (multi-line ok)')
|
|
894
|
+
group.add_argument('--none', action='store_true', help='no verify command is configured')
|
|
895
|
+
check.add_argument('--json', action='store_true')
|
|
896
|
+
check.add_argument('--max-lines', type=int, default=DEFAULT_MAX_LINES,
|
|
897
|
+
help='cut human output to N suite lines plus a summary line (0 = no limit; default %(default)s)')
|
|
898
|
+
check.add_argument('--out', help='also write the human lines here (removed when there are none)')
|
|
899
|
+
return parser
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
903
|
+
try:
|
|
904
|
+
sys.stdout.reconfigure(errors='backslashreplace') # type: ignore[attr-defined]
|
|
905
|
+
except (AttributeError, ValueError):
|
|
906
|
+
pass
|
|
907
|
+
args = build_parser().parse_args(argv)
|
|
908
|
+
repo = args.repo
|
|
909
|
+
if not os.path.isdir(repo) or not os.access(repo, os.R_OK | os.X_OK):
|
|
910
|
+
sys.stderr.write('forge-verify-coverage: cannot read repo: %s\n' % repo)
|
|
911
|
+
return 3
|
|
912
|
+
if args.action == 'discover':
|
|
913
|
+
try:
|
|
914
|
+
project = Project(repo)
|
|
915
|
+
except Exception as error:
|
|
916
|
+
sys.stderr.write('forge-verify-coverage: internal error: %r\n' % (error,))
|
|
917
|
+
return 0
|
|
918
|
+
if args.json:
|
|
919
|
+
keys = ('name', 'source', 'command', 'kind')
|
|
920
|
+
print(json.dumps({'suites': [{k: s[k] for k in keys} for s in project.suites]}))
|
|
921
|
+
else:
|
|
922
|
+
for s in project.suites:
|
|
923
|
+
print('\t'.join(clean(s[k]) for k in ('source', 'kind', 'name', 'command')))
|
|
924
|
+
return 0
|
|
925
|
+
if args.none:
|
|
926
|
+
command: Optional[str] = None
|
|
927
|
+
elif args.command_file is not None:
|
|
928
|
+
try:
|
|
929
|
+
with open(args.command_file, 'rb') as handle:
|
|
930
|
+
command = handle.read(READ_LIMIT).decode('utf-8', 'replace')
|
|
931
|
+
except OSError as error:
|
|
932
|
+
sys.stderr.write('forge-verify-coverage: cannot read command file %s: %s\n' % (args.command_file, error))
|
|
933
|
+
return 2
|
|
934
|
+
else:
|
|
935
|
+
command = args.command
|
|
936
|
+
try:
|
|
937
|
+
result, lines = analyse(repo, command, args.max_lines)
|
|
938
|
+
except Exception as error: # advisory tool: never fail the caller's run over a heuristic
|
|
939
|
+
sys.stderr.write('forge-verify-coverage: internal error: %r\n' % (error,))
|
|
940
|
+
if args.out:
|
|
941
|
+
write_out(args.out, [])
|
|
942
|
+
if args.json:
|
|
943
|
+
print(json.dumps({'suites': [], 'uncovered': [], 'verify_command': (command or '').strip()}))
|
|
944
|
+
return 0
|
|
945
|
+
if args.out:
|
|
946
|
+
write_out(args.out, lines)
|
|
947
|
+
if args.json:
|
|
948
|
+
print(json.dumps(result))
|
|
949
|
+
else:
|
|
950
|
+
for line in lines:
|
|
951
|
+
print(line)
|
|
952
|
+
return 0
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
if __name__ == '__main__':
|
|
956
|
+
sys.exit(main())
|