@senpi/runtime 3.0.56 → 3.0.58

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/guide-commands.d.ts.map +1 -1
  2. package/dist/cli/guide-commands.js +28 -2
  3. package/dist/cli/guide-commands.js.map +1 -1
  4. package/dist/cli/senpi-commands.d.ts.map +1 -1
  5. package/dist/cli/senpi-commands.js +2 -0
  6. package/dist/cli/senpi-commands.js.map +1 -1
  7. package/dist/cli/validate-command.d.ts +29 -0
  8. package/dist/cli/validate-command.d.ts.map +1 -0
  9. package/dist/cli/validate-command.js +89 -0
  10. package/dist/cli/validate-command.js.map +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +62 -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 +30 -0
  22. package/dist/scanners/external/scaffold/import_check.py +310 -0
  23. package/dist/scanners/external/scaffold/preflight.py +545 -0
  24. package/dist/scanners/external/scaffold/scaffold.py +19 -6
  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 +40 -0
  31. package/dist/utils/event-catalog.d.ts.map +1 -1
  32. package/dist/utils/event-catalog.js +12 -0
  33. package/dist/utils/event-catalog.js.map +1 -1
  34. package/dist/validate/codes.d.ts +165 -0
  35. package/dist/validate/codes.d.ts.map +1 -0
  36. package/dist/validate/codes.js +381 -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 +244 -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 +134 -0
  55. package/dist/validate/live-stage.d.ts.map +1 -0
  56. package/dist/validate/live-stage.js +472 -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 +269 -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 +205 -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 +80 -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 +471 -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,42 @@ 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.
46
+ #
47
+ # What this is NOT: a security boundary. Author code runs in this process and can set the flag back,
48
+ # reach past the client wrapper, or build its own client from the environment — all reproducible in
49
+ # a single scan(). It is deliberately not hardened against that, because hardening here would buy a
50
+ # false sense of one: the invariants that actually hold live at the MCP and engine layers, where
51
+ # they hold no matter what a scanner does.
52
+ #
53
+ # What it IS: the ordinary path, closed by default, failing loudly instead of silently delivering
54
+ # during a check. That is worth having on its own terms.
55
+ PREFLIGHT = False
56
+
57
+
35
58
  def post_json(url: str, body: dict, timeout: float) -> tuple[int, Any]:
36
59
  """POST body as JSON to url.
37
60
 
38
61
  Returns (status_code, parsed_json_or_None).
39
62
  Raises TransportError on connection/timeout failures.
63
+ Raises EgressBlocked when a validation run is in progress.
40
64
  """
65
+ if PREFLIGHT:
66
+ raise EgressBlocked(
67
+ "refusing to POST to {}: validation collects and reports signals, it never "
68
+ "delivers them".format(url)
69
+ )
70
+
41
71
  data = json.dumps(body).encode("utf-8")
42
72
  req = urllib.request.Request(
43
73
  url,
@@ -0,0 +1,310 @@
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
+ # Routes that fetch data where the runtime cannot see it. `subprocess` belongs here for the same
36
+ # reason as `socket`: shelling out to curl is a data source, and one that also escapes the
37
+ # in-process guards entirely.
38
+ _UNSANCTIONED_SOURCES = {
39
+ "urllib", "urllib.request", "requests", "http", "http.client", "httpx", "socket", "subprocess",
40
+ }
41
+
42
+
43
+ def _stdlib_names() -> set:
44
+ """Names that would shadow a standard-library module. Falls back for older interpreters."""
45
+ names = getattr(sys, "stdlib_module_names", None)
46
+ if names:
47
+ return set(names)
48
+ return {
49
+ "abc", "argparse", "ast", "asyncio", "base64", "collections", "contextlib", "copy", "csv",
50
+ "datetime", "decimal", "enum", "functools", "hashlib", "http", "io", "itertools", "json",
51
+ "logging", "math", "os", "pathlib", "queue", "random", "re", "select", "shutil", "signal",
52
+ "socket", "statistics", "string", "struct", "subprocess", "sys", "tempfile", "threading",
53
+ "time", "types", "typing", "urllib", "uuid", "warnings",
54
+ }
55
+
56
+
57
+ def _python_files(root: str) -> List[str]:
58
+ """Every .py beneath `root`, skipping caches. Sorted so output is stable across runs."""
59
+ out = []
60
+ for dirpath, dirnames, filenames in os.walk(root):
61
+ dirnames[:] = [d for d in dirnames if d != "__pycache__" and not d.startswith(".")]
62
+ for name in sorted(filenames):
63
+ if name.endswith(".py"):
64
+ out.append(os.path.join(dirpath, name))
65
+ return sorted(out)
66
+
67
+
68
+ def _own_prefix(root: str) -> str:
69
+ """The root as a path prefix, separator included.
70
+
71
+ Without the separator, root `/x/scanner` claims `/x/scanner-utils/evil.py` as the author's own
72
+ code — plausible with sibling per-instance scanner directories, and it makes `relpath` produce
73
+ `../scanner-utils/...` in a finding that is supposed to name a file inside the package.
74
+ """
75
+ return os.path.abspath(root).rstrip(os.sep) + os.sep
76
+
77
+
78
+ def _rel(root: str, path: str) -> str:
79
+ try:
80
+ return os.path.relpath(path, root)
81
+ except ValueError:
82
+ return path
83
+
84
+
85
+ def _finding(code: str, **fields: Any) -> Dict[str, Any]:
86
+ return {"code": code, **fields}
87
+
88
+
89
+ def _chain(exc: BaseException) -> List[str]:
90
+ """The full formatted traceback including every chained cause.
91
+
92
+ The three-argument form is used because it is correct on every interpreter this runs on.
93
+ """
94
+ return traceback.format_exception(type(exc), exc, exc.__traceback__)
95
+
96
+
97
+ def _deepest_frame_in(root: str, exc: BaseException) -> Optional[Dict[str, Any]]:
98
+ """The last frame inside the scanner directory, walking the whole cause chain.
99
+
100
+ This is the attribution that matters. An import failure three modules deep reports as a failure
101
+ to import the entrypoint, which names the wrong file — the reader then goes looking in a file
102
+ that is fine. Walking back to the deepest frame that is still the author's code puts the finding
103
+ where the defect is.
104
+ """
105
+ best = None
106
+ seen = set()
107
+ current: Optional[BaseException] = exc
108
+ while current is not None and id(current) not in seen:
109
+ seen.add(id(current))
110
+ tb = current.__traceback__
111
+ while tb is not None:
112
+ filename = tb.tb_frame.f_code.co_filename
113
+ if os.path.abspath(filename).startswith(_own_prefix(root)):
114
+ best = {"file": _rel(root, filename), "line": tb.tb_lineno}
115
+ tb = tb.tb_next
116
+ current = current.__cause__ or current.__context__
117
+ return best
118
+
119
+
120
+ def _import_chain(root: str, exc: BaseException) -> List[str]:
121
+ """The author's modules involved in the failure, entrypoint first."""
122
+ chain: List[str] = []
123
+ seen = set()
124
+ current: Optional[BaseException] = exc
125
+ while current is not None and id(current) not in seen:
126
+ seen.add(id(current))
127
+ tb = current.__traceback__
128
+ while tb is not None:
129
+ filename = os.path.abspath(tb.tb_frame.f_code.co_filename)
130
+ if filename.startswith(_own_prefix(root)):
131
+ name = _rel(root, filename)
132
+ if name not in chain:
133
+ chain.append(name)
134
+ tb = tb.tb_next
135
+ current = current.__cause__ or current.__context__
136
+ return chain
137
+
138
+
139
+ def check_compiles(root: str) -> List[Dict[str, Any]]:
140
+ """Compile every file, including ones the entrypoint never imports.
141
+
142
+ An import only executes the modules it reaches. A helper that is broken but only referenced
143
+ inside a branch stays invisible until that branch runs — in production, at some later hour.
144
+
145
+ Uses the builtin compiler rather than `py_compile` so nothing is written to disk: this runs
146
+ against directories it does not own, and a check that leaves artefacts behind is a check people
147
+ stop running.
148
+ """
149
+ findings = []
150
+ for path in _python_files(root):
151
+ try:
152
+ # Bytes, so the file's own PEP 263 declaration governs. Decoding as UTF-8 here would
153
+ # raise on a file CPython reports as `SyntaxError: Non-UTF-8 code` — a defect in the
154
+ # package, which was escaping as an unhandled crash and being blamed on the environment.
155
+ with open(path, "rb") as handle:
156
+ source = handle.read()
157
+ except OSError as exc:
158
+ findings.append(_finding("SYNTAX", file=_rel(root, path), line=None, message=str(exc)))
159
+ continue
160
+ try:
161
+ compile(source, path, "exec")
162
+ except SyntaxError as exc:
163
+ findings.append(
164
+ _finding("SYNTAX", file=_rel(root, path), line=exc.lineno, message=exc.msg)
165
+ )
166
+ except ValueError as exc:
167
+ # Null bytes, an undecodable encoding declaration, and similar cases the compiler
168
+ # rejects outright. `UnicodeDecodeError` is a ValueError and lands here too.
169
+ findings.append(_finding("SYNTAX", file=_rel(root, path), line=None, message=str(exc)))
170
+ return findings
171
+
172
+
173
+ def check_folder_shape(root: str) -> List[Dict[str, Any]]:
174
+ """Shapes that are legal Python but wrong for how the scaffold imports this directory."""
175
+ findings = []
176
+
177
+ if os.path.isfile(os.path.join(root, "__init__.py")):
178
+ findings.append(_finding("SCANNERS_IS_PACKAGE", file="__init__.py"))
179
+
180
+ stdlib = _stdlib_names()
181
+ for name in sorted(os.listdir(root)):
182
+ if not name.endswith(".py") or name == "__init__.py":
183
+ continue
184
+ stem = name[:-3]
185
+ if stem in stdlib:
186
+ findings.append(_finding("SHADOWS_STDLIB", file=name, module=stem))
187
+
188
+ return findings
189
+
190
+
191
+ def check_data_sources(root: str) -> List[Dict[str, Any]]:
192
+ """Imports that fetch data by a route the runtime cannot observe.
193
+
194
+ Read statically rather than at run time: the import may sit on a branch this check never
195
+ executes, and the point is to surface it either way.
196
+ """
197
+ findings = []
198
+ for path in _python_files(root):
199
+ try:
200
+ # Bytes, not text: PEP 263 lets a file declare its own encoding, and `compile`/`ast.parse`
201
+ # honour it. Reading as UTF-8 here would raise UnicodeDecodeError on a file CPython
202
+ # itself calls a SyntaxError — reported, before this fix, as a broken environment.
203
+ with open(path, "rb") as handle:
204
+ tree = ast.parse(handle.read(), filename=path)
205
+ except (OSError, SyntaxError, ValueError):
206
+ continue # a file that will not parse is already reported by the compile sweep
207
+ for node in ast.walk(tree):
208
+ names = []
209
+ if isinstance(node, ast.Import):
210
+ names = [alias.name for alias in node.names]
211
+ elif isinstance(node, ast.ImportFrom) and node.module:
212
+ names = [node.module]
213
+ for name in names:
214
+ root_name = name.split(".")[0]
215
+ if name in _UNSANCTIONED_SOURCES or root_name in _UNSANCTIONED_SOURCES:
216
+ findings.append(
217
+ _finding("NON_MCP_SOURCE", file=_rel(root, path), line=node.lineno, module=name)
218
+ )
219
+ return findings
220
+
221
+
222
+ def check_entrypoint(root: str, entrypoint: str) -> List[Dict[str, Any]]:
223
+ """Import the entrypoint for real and verify the contract it must satisfy.
224
+
225
+ Restores the module cache and the search path afterwards. The loader clears the *entrypoint*
226
+ before importing, but not the siblings it pulls in — so without this, a `scoring` left behind by
227
+ one check satisfies the next scanner's `import scoring` even when that file does not exist, and
228
+ a broken package reports clean. Production spawns a process per scanner and never sees it; a
229
+ function that is only correct the first time it is called is a trap regardless.
230
+ """
231
+ before_modules = set(sys.modules)
232
+ before_path = list(sys.path)
233
+ try:
234
+ return _check_entrypoint_inner(root, entrypoint)
235
+ finally:
236
+ for name in set(sys.modules) - before_modules:
237
+ del sys.modules[name]
238
+ sys.path[:] = before_path
239
+
240
+
241
+ def _check_entrypoint_inner(root: str, entrypoint: str) -> List[Dict[str, Any]]:
242
+ try:
243
+ module = import_entrypoint(root, entrypoint)
244
+ except ScanEntrypointError as exc:
245
+ cause = exc.__cause__ or exc
246
+ return [
247
+ _finding(
248
+ "IMPORT_FAILED",
249
+ message=str(cause),
250
+ error_type=type(cause).__name__,
251
+ where=_deepest_frame_in(root, exc),
252
+ import_chain=_import_chain(root, exc),
253
+ traceback="".join(_chain(exc)),
254
+ )
255
+ ]
256
+ except BaseException as exc: # noqa: BLE001 — a module can raise anything at import time
257
+ return [
258
+ _finding(
259
+ "IMPORT_FAILED",
260
+ message=str(exc),
261
+ error_type=type(exc).__name__,
262
+ where=_deepest_frame_in(root, exc),
263
+ import_chain=_import_chain(root, exc),
264
+ traceback="".join(_chain(exc)),
265
+ )
266
+ ]
267
+
268
+ try:
269
+ scan = locate_scan(module)
270
+ except ScanEntrypointError as exc:
271
+ code = "SCAN_NOT_CALLABLE" if "not callable" in str(exc) else "SCAN_MISSING"
272
+ return [_finding(code, message=str(exc), file=entrypoint)]
273
+
274
+ try:
275
+ verify_scan_signature(scan)
276
+ except ScanSignatureError as exc:
277
+ return [_finding("SCAN_BAD_ARITY", message=str(exc), file=entrypoint)]
278
+
279
+ return []
280
+
281
+
282
+ def run(root: str, entrypoint: Optional[str]) -> Dict[str, Any]:
283
+ if not os.path.isdir(root):
284
+ return {"ok": False, "error": "scanner root is not a directory: {}".format(root)}
285
+
286
+ findings = check_compiles(root)
287
+ findings.extend(check_folder_shape(root))
288
+ findings.extend(check_data_sources(root))
289
+
290
+ # A file that will not compile cannot be imported, and the import error would only restate the
291
+ # syntax error less clearly.
292
+ entry_blocked = any(f["code"] == "SYNTAX" and f["file"] == entrypoint for f in findings)
293
+ if entrypoint and not entry_blocked:
294
+ findings.extend(check_entrypoint(root, entrypoint))
295
+
296
+ return {"ok": True, "root": root, "entrypoint": entrypoint, "findings": findings}
297
+
298
+
299
+ def main(argv: Optional[List[str]] = None) -> int:
300
+ argv = list(sys.argv if argv is None else argv)
301
+ if len(argv) < 2:
302
+ sys.stderr.write("usage: python3 -m scaffold.import_check <scanner-root> [entrypoint]\n")
303
+ return 2
304
+ result = run(argv[1], argv[2] if len(argv) > 2 else None)
305
+ sys.stdout.write(json.dumps(result))
306
+ return 0 if result.get("ok") else 1
307
+
308
+
309
+ if __name__ == "__main__":
310
+ sys.exit(main())