@senpi/runtime 3.0.55 → 3.0.57

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 (99) hide show
  1. package/dist/cli/senpi-commands.d.ts.map +1 -1
  2. package/dist/cli/senpi-commands.js +2 -0
  3. package/dist/cli/senpi-commands.js.map +1 -1
  4. package/dist/cli/validate-command.d.ts +29 -0
  5. package/dist/cli/validate-command.d.ts.map +1 -0
  6. package/dist/cli/validate-command.js +89 -0
  7. package/dist/cli/validate-command.js.map +1 -0
  8. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  9. package/dist/dsl/monitor/process-one-position.js +5 -1
  10. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +48 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/runtime/load-runtime-config.d.ts +38 -0
  15. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  16. package/dist/runtime/load-runtime-config.js +33 -25
  17. package/dist/runtime/load-runtime-config.js.map +1 -1
  18. package/dist/runtime/runtime-schema.d.ts +100 -100
  19. package/dist/scanners/external/scaffold/__main__.py +8 -0
  20. package/dist/scanners/external/scaffold/ctx.py +15 -1
  21. package/dist/scanners/external/scaffold/http_post.py +22 -0
  22. package/dist/scanners/external/scaffold/import_check.py +305 -0
  23. package/dist/scanners/external/scaffold/preflight.py +541 -0
  24. package/dist/scanners/external/scaffold/scaffold.py +11 -2
  25. package/dist/scanners/implementations/market-regime.d.ts +1 -1
  26. package/dist/telemetry/metrics.d.ts +6 -0
  27. package/dist/telemetry/metrics.d.ts.map +1 -1
  28. package/dist/telemetry/metrics.js +45 -0
  29. package/dist/telemetry/metrics.js.map +1 -1
  30. package/dist/utils/event-catalog.d.ts +22 -0
  31. package/dist/utils/event-catalog.d.ts.map +1 -1
  32. package/dist/utils/event-catalog.js +6 -0
  33. package/dist/utils/event-catalog.js.map +1 -1
  34. package/dist/validate/codes.d.ts +163 -0
  35. package/dist/validate/codes.d.ts.map +1 -0
  36. package/dist/validate/codes.js +375 -0
  37. package/dist/validate/codes.js.map +1 -0
  38. package/dist/validate/coverage.d.ts +34 -0
  39. package/dist/validate/coverage.d.ts.map +1 -0
  40. package/dist/validate/coverage.js +34 -0
  41. package/dist/validate/coverage.js.map +1 -0
  42. package/dist/validate/import-stage.d.ts +56 -0
  43. package/dist/validate/import-stage.d.ts.map +1 -0
  44. package/dist/validate/import-stage.js +238 -0
  45. package/dist/validate/import-stage.js.map +1 -0
  46. package/dist/validate/index.d.ts +28 -0
  47. package/dist/validate/index.d.ts.map +1 -0
  48. package/dist/validate/index.js +27 -0
  49. package/dist/validate/index.js.map +1 -0
  50. package/dist/validate/install-gate.d.ts +44 -0
  51. package/dist/validate/install-gate.d.ts.map +1 -0
  52. package/dist/validate/install-gate.js +66 -0
  53. package/dist/validate/install-gate.js.map +1 -0
  54. package/dist/validate/live-stage.d.ts +108 -0
  55. package/dist/validate/live-stage.d.ts.map +1 -0
  56. package/dist/validate/live-stage.js +416 -0
  57. package/dist/validate/live-stage.js.map +1 -0
  58. package/dist/validate/proof.d.ts +104 -0
  59. package/dist/validate/proof.d.ts.map +1 -0
  60. package/dist/validate/proof.js +256 -0
  61. package/dist/validate/proof.js.map +1 -0
  62. package/dist/validate/recipe-checks.d.ts +79 -0
  63. package/dist/validate/recipe-checks.d.ts.map +1 -0
  64. package/dist/validate/recipe-checks.js +202 -0
  65. package/dist/validate/recipe-checks.js.map +1 -0
  66. package/dist/validate/recipe.d.ts +41 -0
  67. package/dist/validate/recipe.d.ts.map +1 -0
  68. package/dist/validate/recipe.js +215 -0
  69. package/dist/validate/recipe.js.map +1 -0
  70. package/dist/validate/report.d.ts +35 -0
  71. package/dist/validate/report.d.ts.map +1 -0
  72. package/dist/validate/report.js +78 -0
  73. package/dist/validate/report.js.map +1 -0
  74. package/dist/validate/run.d.ts +101 -0
  75. package/dist/validate/run.d.ts.map +1 -0
  76. package/dist/validate/run.js +444 -0
  77. package/dist/validate/run.js.map +1 -0
  78. package/dist/validate/signal-stage.d.ts +40 -0
  79. package/dist/validate/signal-stage.d.ts.map +1 -0
  80. package/dist/validate/signal-stage.js +116 -0
  81. package/dist/validate/signal-stage.js.map +1 -0
  82. package/dist/validate/target.d.ts +70 -0
  83. package/dist/validate/target.d.ts.map +1 -0
  84. package/dist/validate/target.js +231 -0
  85. package/dist/validate/target.js.map +1 -0
  86. package/dist/validate/telemetry.d.ts +24 -0
  87. package/dist/validate/telemetry.d.ts.map +1 -0
  88. package/dist/validate/telemetry.js +60 -0
  89. package/dist/validate/telemetry.js.map +1 -0
  90. package/dist/validate/types.d.ts +115 -0
  91. package/dist/validate/types.d.ts.map +1 -0
  92. package/dist/validate/types.js +31 -0
  93. package/dist/validate/types.js.map +1 -0
  94. package/dist/validate/wallet.d.ts +63 -0
  95. package/dist/validate/wallet.d.ts.map +1 -0
  96. package/dist/validate/wallet.js +66 -0
  97. package/dist/validate/wallet.js.map +1 -0
  98. package/openclaw.plugin.json +1 -1
  99. package/package.json +2 -1
@@ -20,6 +20,8 @@ Optional keys:
20
20
  state_path — JSON state file path (None => no persistence).
21
21
  inputs — tunables dict for scan() (was `params`); default {}.
22
22
  state_history_max_count — state series bound; default 0 = history disabled.
23
+ max_ticks — stop after N ticks; absent = run until stopped. A validation
24
+ run sets 1; production leaves it absent.
23
25
 
24
26
  Delivery sink is wired when EXTERNAL_SCANNER_ID and SIGNAL_INGESTION_URL are set in
25
27
  the environment. If either is absent the scaffold runs with the default no-op sink
@@ -29,6 +31,7 @@ and logs a warning — scanners can still operate without a live runtime.
29
31
  import json
30
32
  import os
31
33
  import sys
34
+ import traceback
32
35
 
33
36
 
34
37
  def _main(argv=None) -> int:
@@ -145,7 +148,12 @@ def _main(argv=None) -> int:
145
148
  max_ticks=max_ticks,
146
149
  )
147
150
  except (ScanEntrypointError, ScanSignatureError, ValueError) as exc:
151
+ # The full chain, not just the message. `import_entrypoint` raises `from exc`, so the real
152
+ # cause — the missing sibling, the undefined name, the file and line it happened in — hangs
153
+ # off __cause__. Printing only str(exc) discarded all of it and left an operator with
154
+ # "failed to import scan.py" for a fault three modules deeper, in a file that was fine.
148
155
  print(f"[scaffold] launch failed: {exc}", file=sys.stderr)
156
+ traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr)
149
157
  return 1
150
158
 
151
159
  return 0
@@ -16,6 +16,7 @@ two tiers (design §2.1):
16
16
  wallet — str, the strategy wallet (routing identity).
17
17
  scanner_name — str, this scanner's recipe name.
18
18
  interval_seconds — int, tick cadence in seconds.
19
+ dry_run — bool, True only during a validation run (see __init__).
19
20
 
20
21
  The metadata is FROZEN: mutating, deleting, or adding any attribute raises. The
21
22
  author can read `ctx.wallet` but cannot rebind it (or scribble a backdoor onto
@@ -45,7 +46,14 @@ class ScanContext:
45
46
 
46
47
  # Lock the attribute set: no __dict__, so any unknown attribute (mutate / add /
47
48
  # delete) raises AttributeError. The five documented slots are the only ones.
48
- __slots__ = ("state", "senpi_mcp", "wallet", "scanner_name", "interval_seconds")
49
+ __slots__ = (
50
+ "state",
51
+ "senpi_mcp",
52
+ "wallet",
53
+ "scanner_name",
54
+ "interval_seconds",
55
+ "dry_run",
56
+ )
49
57
 
50
58
  def __init__(
51
59
  self,
@@ -55,6 +63,7 @@ class ScanContext:
55
63
  scanner_name: str,
56
64
  interval_seconds: int,
57
65
  senpi_mcp: Optional[Any] = None,
66
+ dry_run: bool = False,
58
67
  ) -> None:
59
68
  # Bypass our own frozen __setattr__ to populate the slots once at
60
69
  # construction. object.__setattr__ writes the slot directly.
@@ -63,6 +72,11 @@ class ScanContext:
63
72
  object.__setattr__(self, "wallet", wallet)
64
73
  object.__setattr__(self, "scanner_name", scanner_name)
65
74
  object.__setattr__(self, "interval_seconds", interval_seconds)
75
+ # False in production, True when a validation run forces a single tick. A scanner that
76
+ # returns early outside its trading window can consult this to run anyway, so validation
77
+ # gets to see it do real work instead of watching it decline to start. Ignoring it is
78
+ # safe — a tick that reads nothing is reported as unproven rather than passing.
79
+ object.__setattr__(self, "dry_run", dry_run)
66
80
 
67
81
  # -- frozen: block every post-construction mutation --------------------
68
82
 
@@ -32,12 +32,34 @@ class TransportError(Exception):
32
32
  """Raised on connection failure, timeout, or non-JSON transport body."""
33
33
 
34
34
 
35
+ class EgressBlocked(RuntimeError):
36
+ """Raised when something tries to send data out during a validation run."""
37
+
38
+
39
+ # Set once, by the preflight entry, before any author code runs.
40
+ #
41
+ # Validation collects what a scanner produces and reports it; delivery is the runtime's job after
42
+ # installation. Nothing is configured to receive a post during a validation run, so in practice
43
+ # there is nowhere for one to go — but "nothing is configured" is a property of how the caller was
44
+ # set up, provable only by tracing several layers, and one refactor away from silently ceasing to
45
+ # be true. This is the same shape as the read-only tool boundary: owned in source, not reachable by
46
+ # authored code, and loud rather than silent.
47
+ PREFLIGHT = False
48
+
49
+
35
50
  def post_json(url: str, body: dict, timeout: float) -> tuple[int, Any]:
36
51
  """POST body as JSON to url.
37
52
 
38
53
  Returns (status_code, parsed_json_or_None).
39
54
  Raises TransportError on connection/timeout failures.
55
+ Raises EgressBlocked when a validation run is in progress.
40
56
  """
57
+ if PREFLIGHT:
58
+ raise EgressBlocked(
59
+ "refusing to POST to {}: validation collects and reports signals, it never "
60
+ "delivers them".format(url)
61
+ )
62
+
41
63
  data = json.dumps(body).encode("utf-8")
42
64
  req = urllib.request.Request(
43
65
  url,
@@ -0,0 +1,305 @@
1
+ """Will this scanner load?
2
+
3
+ Runs the checks that can be answered without executing a tick: every file compiles, the entrypoint
4
+ imports, and the module exposes an entry function of the right shape. No MCP client is built, no
5
+ network is touched, and `scan()` is never called — so this needs no credentials and is safe to run
6
+ across a whole fleet.
7
+
8
+ Deliberately reuses `entrypoint_loader` rather than reimplementing the import. That module is what
9
+ the scaffold itself uses before its first tick, so a package that passes here has satisfied the
10
+ same contract production applies, not an approximation of it.
11
+
12
+ python3 -m scaffold.import_check <scanner-root> [entrypoint]
13
+
14
+ Emits one JSON document on stdout. Findings are returned as data — this exits 0 whenever the check
15
+ itself ran, and reserves a non-zero exit for being unable to run at all.
16
+ """
17
+
18
+ import ast
19
+ import json
20
+ import os
21
+ import sys
22
+ import traceback
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ from .entrypoint_loader import (
26
+ ScanEntrypointError,
27
+ ScanSignatureError,
28
+ import_entrypoint,
29
+ locate_scan,
30
+ verify_scan_signature,
31
+ )
32
+
33
+ # Modules a scanner is expected to reach for. Anything here means data is arriving by a route the
34
+ # runtime cannot see, which the scan contract does not sanction.
35
+ _UNSANCTIONED_SOURCES = {"urllib", "urllib.request", "requests", "http", "http.client", "httpx", "socket"}
36
+
37
+
38
+ def _stdlib_names() -> set:
39
+ """Names that would shadow a standard-library module. Falls back for older interpreters."""
40
+ names = getattr(sys, "stdlib_module_names", None)
41
+ if names:
42
+ return set(names)
43
+ return {
44
+ "abc", "argparse", "ast", "asyncio", "base64", "collections", "contextlib", "copy", "csv",
45
+ "datetime", "decimal", "enum", "functools", "hashlib", "http", "io", "itertools", "json",
46
+ "logging", "math", "os", "pathlib", "queue", "random", "re", "select", "shutil", "signal",
47
+ "socket", "statistics", "string", "struct", "subprocess", "sys", "tempfile", "threading",
48
+ "time", "types", "typing", "urllib", "uuid", "warnings",
49
+ }
50
+
51
+
52
+ def _python_files(root: str) -> List[str]:
53
+ """Every .py beneath `root`, skipping caches. Sorted so output is stable across runs."""
54
+ out = []
55
+ for dirpath, dirnames, filenames in os.walk(root):
56
+ dirnames[:] = [d for d in dirnames if d != "__pycache__" and not d.startswith(".")]
57
+ for name in sorted(filenames):
58
+ if name.endswith(".py"):
59
+ out.append(os.path.join(dirpath, name))
60
+ return sorted(out)
61
+
62
+
63
+ def _own_prefix(root: str) -> str:
64
+ """The root as a path prefix, separator included.
65
+
66
+ Without the separator, root `/x/scanner` claims `/x/scanner-utils/evil.py` as the author's own
67
+ code — plausible with sibling per-instance scanner directories, and it makes `relpath` produce
68
+ `../scanner-utils/...` in a finding that is supposed to name a file inside the package.
69
+ """
70
+ return os.path.abspath(root).rstrip(os.sep) + os.sep
71
+
72
+
73
+ def _rel(root: str, path: str) -> str:
74
+ try:
75
+ return os.path.relpath(path, root)
76
+ except ValueError:
77
+ return path
78
+
79
+
80
+ def _finding(code: str, **fields: Any) -> Dict[str, Any]:
81
+ return {"code": code, **fields}
82
+
83
+
84
+ def _chain(exc: BaseException) -> List[str]:
85
+ """The full formatted traceback including every chained cause.
86
+
87
+ The three-argument form is used because it is correct on every interpreter this runs on.
88
+ """
89
+ return traceback.format_exception(type(exc), exc, exc.__traceback__)
90
+
91
+
92
+ def _deepest_frame_in(root: str, exc: BaseException) -> Optional[Dict[str, Any]]:
93
+ """The last frame inside the scanner directory, walking the whole cause chain.
94
+
95
+ This is the attribution that matters. An import failure three modules deep reports as a failure
96
+ to import the entrypoint, which names the wrong file — the reader then goes looking in a file
97
+ that is fine. Walking back to the deepest frame that is still the author's code puts the finding
98
+ where the defect is.
99
+ """
100
+ best = None
101
+ seen = set()
102
+ current: Optional[BaseException] = exc
103
+ while current is not None and id(current) not in seen:
104
+ seen.add(id(current))
105
+ tb = current.__traceback__
106
+ while tb is not None:
107
+ filename = tb.tb_frame.f_code.co_filename
108
+ if os.path.abspath(filename).startswith(_own_prefix(root)):
109
+ best = {"file": _rel(root, filename), "line": tb.tb_lineno}
110
+ tb = tb.tb_next
111
+ current = current.__cause__ or current.__context__
112
+ return best
113
+
114
+
115
+ def _import_chain(root: str, exc: BaseException) -> List[str]:
116
+ """The author's modules involved in the failure, entrypoint first."""
117
+ chain: List[str] = []
118
+ seen = set()
119
+ current: Optional[BaseException] = exc
120
+ while current is not None and id(current) not in seen:
121
+ seen.add(id(current))
122
+ tb = current.__traceback__
123
+ while tb is not None:
124
+ filename = os.path.abspath(tb.tb_frame.f_code.co_filename)
125
+ if filename.startswith(_own_prefix(root)):
126
+ name = _rel(root, filename)
127
+ if name not in chain:
128
+ chain.append(name)
129
+ tb = tb.tb_next
130
+ current = current.__cause__ or current.__context__
131
+ return chain
132
+
133
+
134
+ def check_compiles(root: str) -> List[Dict[str, Any]]:
135
+ """Compile every file, including ones the entrypoint never imports.
136
+
137
+ An import only executes the modules it reaches. A helper that is broken but only referenced
138
+ inside a branch stays invisible until that branch runs — in production, at some later hour.
139
+
140
+ Uses the builtin compiler rather than `py_compile` so nothing is written to disk: this runs
141
+ against directories it does not own, and a check that leaves artefacts behind is a check people
142
+ stop running.
143
+ """
144
+ findings = []
145
+ for path in _python_files(root):
146
+ try:
147
+ # Bytes, so the file's own PEP 263 declaration governs. Decoding as UTF-8 here would
148
+ # raise on a file CPython reports as `SyntaxError: Non-UTF-8 code` — a defect in the
149
+ # package, which was escaping as an unhandled crash and being blamed on the environment.
150
+ with open(path, "rb") as handle:
151
+ source = handle.read()
152
+ except OSError as exc:
153
+ findings.append(_finding("SYNTAX", file=_rel(root, path), line=None, message=str(exc)))
154
+ continue
155
+ try:
156
+ compile(source, path, "exec")
157
+ except SyntaxError as exc:
158
+ findings.append(
159
+ _finding("SYNTAX", file=_rel(root, path), line=exc.lineno, message=exc.msg)
160
+ )
161
+ except ValueError as exc:
162
+ # Null bytes, an undecodable encoding declaration, and similar cases the compiler
163
+ # rejects outright. `UnicodeDecodeError` is a ValueError and lands here too.
164
+ findings.append(_finding("SYNTAX", file=_rel(root, path), line=None, message=str(exc)))
165
+ return findings
166
+
167
+
168
+ def check_folder_shape(root: str) -> List[Dict[str, Any]]:
169
+ """Shapes that are legal Python but wrong for how the scaffold imports this directory."""
170
+ findings = []
171
+
172
+ if os.path.isfile(os.path.join(root, "__init__.py")):
173
+ findings.append(_finding("SCANNERS_IS_PACKAGE", file="__init__.py"))
174
+
175
+ stdlib = _stdlib_names()
176
+ for name in sorted(os.listdir(root)):
177
+ if not name.endswith(".py") or name == "__init__.py":
178
+ continue
179
+ stem = name[:-3]
180
+ if stem in stdlib:
181
+ findings.append(_finding("SHADOWS_STDLIB", file=name, module=stem))
182
+
183
+ return findings
184
+
185
+
186
+ def check_data_sources(root: str) -> List[Dict[str, Any]]:
187
+ """Imports that fetch data by a route the runtime cannot observe.
188
+
189
+ Read statically rather than at run time: the import may sit on a branch this check never
190
+ executes, and the point is to surface it either way.
191
+ """
192
+ findings = []
193
+ for path in _python_files(root):
194
+ try:
195
+ # Bytes, not text: PEP 263 lets a file declare its own encoding, and `compile`/`ast.parse`
196
+ # honour it. Reading as UTF-8 here would raise UnicodeDecodeError on a file CPython
197
+ # itself calls a SyntaxError — reported, before this fix, as a broken environment.
198
+ with open(path, "rb") as handle:
199
+ tree = ast.parse(handle.read(), filename=path)
200
+ except (OSError, SyntaxError, ValueError):
201
+ continue # a file that will not parse is already reported by the compile sweep
202
+ for node in ast.walk(tree):
203
+ names = []
204
+ if isinstance(node, ast.Import):
205
+ names = [alias.name for alias in node.names]
206
+ elif isinstance(node, ast.ImportFrom) and node.module:
207
+ names = [node.module]
208
+ for name in names:
209
+ root_name = name.split(".")[0]
210
+ if name in _UNSANCTIONED_SOURCES or root_name in _UNSANCTIONED_SOURCES:
211
+ findings.append(
212
+ _finding("NON_MCP_SOURCE", file=_rel(root, path), line=node.lineno, module=name)
213
+ )
214
+ return findings
215
+
216
+
217
+ def check_entrypoint(root: str, entrypoint: str) -> List[Dict[str, Any]]:
218
+ """Import the entrypoint for real and verify the contract it must satisfy.
219
+
220
+ Restores the module cache and the search path afterwards. The loader clears the *entrypoint*
221
+ before importing, but not the siblings it pulls in — so without this, a `scoring` left behind by
222
+ one check satisfies the next scanner's `import scoring` even when that file does not exist, and
223
+ a broken package reports clean. Production spawns a process per scanner and never sees it; a
224
+ function that is only correct the first time it is called is a trap regardless.
225
+ """
226
+ before_modules = set(sys.modules)
227
+ before_path = list(sys.path)
228
+ try:
229
+ return _check_entrypoint_inner(root, entrypoint)
230
+ finally:
231
+ for name in set(sys.modules) - before_modules:
232
+ del sys.modules[name]
233
+ sys.path[:] = before_path
234
+
235
+
236
+ def _check_entrypoint_inner(root: str, entrypoint: str) -> List[Dict[str, Any]]:
237
+ try:
238
+ module = import_entrypoint(root, entrypoint)
239
+ except ScanEntrypointError as exc:
240
+ cause = exc.__cause__ or exc
241
+ return [
242
+ _finding(
243
+ "IMPORT_FAILED",
244
+ message=str(cause),
245
+ error_type=type(cause).__name__,
246
+ where=_deepest_frame_in(root, exc),
247
+ import_chain=_import_chain(root, exc),
248
+ traceback="".join(_chain(exc)),
249
+ )
250
+ ]
251
+ except BaseException as exc: # noqa: BLE001 — a module can raise anything at import time
252
+ return [
253
+ _finding(
254
+ "IMPORT_FAILED",
255
+ message=str(exc),
256
+ error_type=type(exc).__name__,
257
+ where=_deepest_frame_in(root, exc),
258
+ import_chain=_import_chain(root, exc),
259
+ traceback="".join(_chain(exc)),
260
+ )
261
+ ]
262
+
263
+ try:
264
+ scan = locate_scan(module)
265
+ except ScanEntrypointError as exc:
266
+ code = "SCAN_NOT_CALLABLE" if "not callable" in str(exc) else "SCAN_MISSING"
267
+ return [_finding(code, message=str(exc), file=entrypoint)]
268
+
269
+ try:
270
+ verify_scan_signature(scan)
271
+ except ScanSignatureError as exc:
272
+ return [_finding("SCAN_BAD_ARITY", message=str(exc), file=entrypoint)]
273
+
274
+ return []
275
+
276
+
277
+ def run(root: str, entrypoint: Optional[str]) -> Dict[str, Any]:
278
+ if not os.path.isdir(root):
279
+ return {"ok": False, "error": "scanner root is not a directory: {}".format(root)}
280
+
281
+ findings = check_compiles(root)
282
+ findings.extend(check_folder_shape(root))
283
+ findings.extend(check_data_sources(root))
284
+
285
+ # A file that will not compile cannot be imported, and the import error would only restate the
286
+ # syntax error less clearly.
287
+ entry_blocked = any(f["code"] == "SYNTAX" and f["file"] == entrypoint for f in findings)
288
+ if entrypoint and not entry_blocked:
289
+ findings.extend(check_entrypoint(root, entrypoint))
290
+
291
+ return {"ok": True, "root": root, "entrypoint": entrypoint, "findings": findings}
292
+
293
+
294
+ def main(argv: Optional[List[str]] = None) -> int:
295
+ argv = list(sys.argv if argv is None else argv)
296
+ if len(argv) < 2:
297
+ sys.stderr.write("usage: python3 -m scaffold.import_check <scanner-root> [entrypoint]\n")
298
+ return 2
299
+ result = run(argv[1], argv[2] if len(argv) > 2 else None)
300
+ sys.stdout.write(json.dumps(result))
301
+ return 0 if result.get("ok") else 1
302
+
303
+
304
+ if __name__ == "__main__":
305
+ sys.exit(main())