@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. 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())