@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.
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +28 -2
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-command.d.ts +29 -0
- package/dist/cli/validate-command.d.ts.map +1 -0
- package/dist/cli/validate-command.js +89 -0
- package/dist/cli/validate-command.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +62 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/load-runtime-config.d.ts +38 -0
- package/dist/runtime/load-runtime-config.d.ts.map +1 -1
- package/dist/runtime/load-runtime-config.js +33 -25
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime/runtime-schema.d.ts +100 -100
- package/dist/scanners/external/scaffold/__main__.py +8 -0
- package/dist/scanners/external/scaffold/ctx.py +15 -1
- package/dist/scanners/external/scaffold/http_post.py +30 -0
- package/dist/scanners/external/scaffold/import_check.py +310 -0
- package/dist/scanners/external/scaffold/preflight.py +545 -0
- package/dist/scanners/external/scaffold/scaffold.py +19 -6
- package/dist/scanners/implementations/market-regime.d.ts +1 -1
- package/dist/telemetry/metrics.d.ts +6 -0
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +45 -0
- package/dist/telemetry/metrics.js.map +1 -1
- package/dist/utils/event-catalog.d.ts +40 -0
- package/dist/utils/event-catalog.d.ts.map +1 -1
- package/dist/utils/event-catalog.js +12 -0
- package/dist/utils/event-catalog.js.map +1 -1
- package/dist/validate/codes.d.ts +165 -0
- package/dist/validate/codes.d.ts.map +1 -0
- package/dist/validate/codes.js +381 -0
- package/dist/validate/codes.js.map +1 -0
- package/dist/validate/coverage.d.ts +34 -0
- package/dist/validate/coverage.d.ts.map +1 -0
- package/dist/validate/coverage.js +34 -0
- package/dist/validate/coverage.js.map +1 -0
- package/dist/validate/import-stage.d.ts +56 -0
- package/dist/validate/import-stage.d.ts.map +1 -0
- package/dist/validate/import-stage.js +244 -0
- package/dist/validate/import-stage.js.map +1 -0
- package/dist/validate/index.d.ts +28 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +27 -0
- package/dist/validate/index.js.map +1 -0
- package/dist/validate/install-gate.d.ts +44 -0
- package/dist/validate/install-gate.d.ts.map +1 -0
- package/dist/validate/install-gate.js +66 -0
- package/dist/validate/install-gate.js.map +1 -0
- package/dist/validate/live-stage.d.ts +134 -0
- package/dist/validate/live-stage.d.ts.map +1 -0
- package/dist/validate/live-stage.js +472 -0
- package/dist/validate/live-stage.js.map +1 -0
- package/dist/validate/proof.d.ts +104 -0
- package/dist/validate/proof.d.ts.map +1 -0
- package/dist/validate/proof.js +269 -0
- package/dist/validate/proof.js.map +1 -0
- package/dist/validate/recipe-checks.d.ts +79 -0
- package/dist/validate/recipe-checks.d.ts.map +1 -0
- package/dist/validate/recipe-checks.js +205 -0
- package/dist/validate/recipe-checks.js.map +1 -0
- package/dist/validate/recipe.d.ts +41 -0
- package/dist/validate/recipe.d.ts.map +1 -0
- package/dist/validate/recipe.js +215 -0
- package/dist/validate/recipe.js.map +1 -0
- package/dist/validate/report.d.ts +35 -0
- package/dist/validate/report.d.ts.map +1 -0
- package/dist/validate/report.js +80 -0
- package/dist/validate/report.js.map +1 -0
- package/dist/validate/run.d.ts +101 -0
- package/dist/validate/run.d.ts.map +1 -0
- package/dist/validate/run.js +471 -0
- package/dist/validate/run.js.map +1 -0
- package/dist/validate/signal-stage.d.ts +40 -0
- package/dist/validate/signal-stage.d.ts.map +1 -0
- package/dist/validate/signal-stage.js +116 -0
- package/dist/validate/signal-stage.js.map +1 -0
- package/dist/validate/target.d.ts +70 -0
- package/dist/validate/target.d.ts.map +1 -0
- package/dist/validate/target.js +231 -0
- package/dist/validate/target.js.map +1 -0
- package/dist/validate/telemetry.d.ts +24 -0
- package/dist/validate/telemetry.d.ts.map +1 -0
- package/dist/validate/telemetry.js +60 -0
- package/dist/validate/telemetry.js.map +1 -0
- package/dist/validate/types.d.ts +115 -0
- package/dist/validate/types.d.ts.map +1 -0
- package/dist/validate/types.js +31 -0
- package/dist/validate/types.js.map +1 -0
- package/dist/validate/wallet.d.ts +63 -0
- package/dist/validate/wallet.d.ts.map +1 -0
- package/dist/validate/wallet.js +66 -0
- package/dist/validate/wallet.js.map +1 -0
- package/openclaw.plugin.json +1 -1
- 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__ = (
|
|
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())
|