explainer-video 0.0.0-stage → 0.4.1
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/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +154 -2
- package/cordis.patch.yml +7 -0
- package/lib/index.js +35 -0
- package/package.json +56 -4
- package/plugin.json +13 -0
- package/skills/coursework-author/.synced-from +5 -0
- package/skills/coursework-author/SKILL.md +209 -0
- package/skills/craft-codeflow-flows/SKILL.md +116 -0
- package/skills/craft-codeflow-mine/SKILL.md +207 -0
- package/skills/craft-codeflow-plan/SKILL.md +65 -0
- package/skills/craft-codeflow-scene/SKILL.md +168 -0
- package/skills/craft-video-align/SKILL.md +109 -0
- package/skills/craft-video-assemble/SKILL.md +70 -0
- package/skills/craft-video-audio/SKILL.md +59 -0
- package/skills/craft-video-enrich/SKILL.md +46 -0
- package/skills/craft-video-plan/SKILL.md +57 -0
- package/skills/craft-video-publish/SKILL.md +79 -0
- package/skills/craft-video-render/SKILL.md +68 -0
- package/skills/craft-video-scene/SKILL.md +112 -0
- package/skills/craft-video-script/SKILL.md +80 -0
- package/skills/craft-video-thumbnail/SKILL.md +168 -0
- package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
- package/skills/explainer-video-from-codebase/SKILL.md +698 -0
- package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
- package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
- package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
- package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
- package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
- package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
- package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
- package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
- package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
- package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
- package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
- package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
- package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
- package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
- package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
- package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
- package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
- package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
- package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
- package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
- package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
- package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
- package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
- package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
- package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
- package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
- package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
- package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
- package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
- package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
- package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
- package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
- package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
- package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
- package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
- package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
- package/skills/explainer-video-from-coursework/ARCHITECTURE.md +61 -0
- package/skills/explainer-video-from-coursework/PIPELINE.md +369 -0
- package/skills/explainer-video-from-coursework/ROADMAP.md +727 -0
- package/skills/explainer-video-from-coursework/SERIES.md +243 -0
- package/skills/explainer-video-from-coursework/SETUP.md +105 -0
- package/skills/explainer-video-from-coursework/SKILL.md +998 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
- package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
- package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
- package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
- package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
- package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
- package/skills/explainer-video-from-coursework/src/thumbnail.html +119 -0
- package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
- package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
- package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
- package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
- package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
- package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
- package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
- package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
- package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
- package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
- package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
- package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
- package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
- package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
- package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
- package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
- package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
- package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
- package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
- package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
- package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
- package/skills/explainer-video-from-coursework/tools/thumbnail.mjs +224 -0
- package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
- package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
- package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
- package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
- package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
- package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
- package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""trace.mjs's Python collector: record which in-repo functions called which while the repo's own test command runs.
|
|
2
|
+
|
|
3
|
+
Loaded through PYTHONPATH from a directory outside the repo, inside trace.mjs's sandbox. On every Python call it records
|
|
4
|
+
(nearest in-repo caller, in-repo callee, current test): library frames between the two are skipped, so a call through a
|
|
5
|
+
decorator, a framework or the event loop is attributed to the in-repo code that started it. The current test is set by
|
|
6
|
+
fmtrace_plugin (pytest) or by the TestCase.run wrapper below (unittest), so a call a framework makes (a fixture, a DI
|
|
7
|
+
provider, a test client's handler thread) is still attributed to the test it ran under. It also records each test's
|
|
8
|
+
network attempts (audit hooks), so a test that failed on a connect the sandbox denied can be listed as denied, as can
|
|
9
|
+
one that failed on reading a path the sandbox hides (the user's home folder, the original repo). Imports an editable
|
|
10
|
+
install points into the original repo are sent to the copy.
|
|
11
|
+
Writes one JSON file per process at exit (or on SIGTERM, when trace.mjs's time limit stops the run). Never imports repo
|
|
12
|
+
code itself.
|
|
13
|
+
|
|
14
|
+
Environment: FM_TRACE_ROOT (the scratch copy of the repo), FM_TRACE_OUT (the output directory), FM_TRACE_RUNNER
|
|
15
|
+
("pytest" or "unittest"), FM_TRACE_ORIGIN (where the original repo was) and FM_TRACE_HIDDEN (the paths the sandbox
|
|
16
|
+
hides), both os.pathsep-separated."""
|
|
17
|
+
import atexit, errno, json, os, signal, sys, threading
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _dirs(var):
|
|
21
|
+
return tuple(os.path.realpath(r) + os.sep for r in os.environ.get(var, "").split(os.pathsep) if r)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ROOTS = _dirs("FM_TRACE_ROOT")
|
|
25
|
+
ORIGINS = tuple(sorted({o for r in os.environ.get("FM_TRACE_ORIGIN", "").split(os.pathsep) if r
|
|
26
|
+
for o in (os.path.abspath(r) + os.sep, os.path.realpath(r) + os.sep)}, key=len, reverse=True))
|
|
27
|
+
HIDDEN = _dirs("FM_TRACE_HIDDEN")
|
|
28
|
+
OUT = os.environ["FM_TRACE_OUT"]
|
|
29
|
+
RUNNER = os.environ.get("FM_TRACE_RUNNER")
|
|
30
|
+
# an interpreter or virtualenv that lives under a repo root is library code, not repo code
|
|
31
|
+
_LIB = tuple({os.path.realpath(p) + os.sep for p in (sys.prefix, sys.base_prefix, sys.exec_prefix)})
|
|
32
|
+
_FOLD = (".<lambda>", ".<genexpr>", ".<listcomp>", ".<dictcomp>", ".<setcomp>")
|
|
33
|
+
_keys = {}
|
|
34
|
+
_pairs = {} # (caller, callee, test) -> calls
|
|
35
|
+
_net = {} # test -> [network attempts]
|
|
36
|
+
OUTCOMES = {} # test -> {phase: outcome, ...}, filled by fmtrace_plugin or the unittest wrapper
|
|
37
|
+
COLLECTED = [None]
|
|
38
|
+
COLLECT_ERRORS = []
|
|
39
|
+
FIXTURE_ERRORS = [] # unittest's failing class and module fixtures: not tests, so not in OUTCOMES
|
|
40
|
+
FIXTURE = [None] # (name, phase) of the unittest class or module fixture running outside any test
|
|
41
|
+
CURRENT = [None] # the running test's key, set around its setup, call and teardown
|
|
42
|
+
_done = [False]
|
|
43
|
+
_forked = [False]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rel(f):
|
|
47
|
+
if not f.endswith(".py") or f.startswith(_LIB) or os.sep + "site-packages" + os.sep in f or os.sep + "dist-packages" + os.sep in f:
|
|
48
|
+
return None
|
|
49
|
+
for r in ROOTS:
|
|
50
|
+
if f.startswith(r):
|
|
51
|
+
return f[len(r):]
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _key(code):
|
|
56
|
+
# keyed by identity: code objects compare by value (not by file), so twin bodies in two files would collide;
|
|
57
|
+
# the stored reference keeps the id from being reused
|
|
58
|
+
hit = _keys.get(id(code))
|
|
59
|
+
if hit is not None:
|
|
60
|
+
return hit[1]
|
|
61
|
+
rel = _rel(code.co_filename)
|
|
62
|
+
k = False
|
|
63
|
+
if rel is not None:
|
|
64
|
+
q = getattr(code, "co_qualname", code.co_name)
|
|
65
|
+
# comprehensions, lambdas and generator bodies belong to the function that holds them
|
|
66
|
+
while q.endswith(_FOLD) or q in ("<lambda>", "<genexpr>"):
|
|
67
|
+
q = q.rsplit(".<locals>.", 1)[0] if ".<locals>." in q else "<module>"
|
|
68
|
+
k = (rel, q)
|
|
69
|
+
_keys[id(code)] = (code, k)
|
|
70
|
+
return k
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _prof(frame, event, arg):
|
|
74
|
+
if event != "call":
|
|
75
|
+
return
|
|
76
|
+
callee = _key(frame.f_code)
|
|
77
|
+
if not callee:
|
|
78
|
+
return
|
|
79
|
+
caller = None
|
|
80
|
+
b = frame.f_back
|
|
81
|
+
while b is not None:
|
|
82
|
+
caller = _key(b.f_code)
|
|
83
|
+
if caller:
|
|
84
|
+
break
|
|
85
|
+
b = b.f_back
|
|
86
|
+
p = (caller or None, callee, CURRENT[0])
|
|
87
|
+
_pairs[p] = _pairs.get(p, 0) + 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _audit(event, args):
|
|
91
|
+
if event in ("socket.connect", "socket.getaddrinfo", "socket.gethostbyname", "socket.gethostbyaddr", "socket.sendto"):
|
|
92
|
+
seen = _net.setdefault(FIXTURE[0][0] if FIXTURE[0] else CURRENT[0], [])
|
|
93
|
+
if len(seen) < 5:
|
|
94
|
+
target = args[1] if event in ("socket.connect", "socket.sendto") else args[0] if args else None
|
|
95
|
+
if event == "socket.getaddrinfo" and len(args) > 1:
|
|
96
|
+
target = (args[0], args[1])
|
|
97
|
+
what = event.split(".", 1)[1] + " " + str(target)[:120]
|
|
98
|
+
if what not in seen:
|
|
99
|
+
seen.append(what)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _dump():
|
|
103
|
+
if _done[0]:
|
|
104
|
+
return
|
|
105
|
+
_done[0] = True
|
|
106
|
+
sys.setprofile(None)
|
|
107
|
+
threading.setprofile(None)
|
|
108
|
+
rows = [{"from": list(c) if c else None, "to": list(t), "test": k, "n": n} for (c, t, k), n in _pairs.items()]
|
|
109
|
+
data = {"argv": sys.argv, "python": sys.version.split()[0], "pairs": rows,
|
|
110
|
+
"network": {k: v for k, v in _net.items() if k is not None}, "outcomes": OUTCOMES,
|
|
111
|
+
"collected": COLLECTED[0], "collectErrors": COLLECT_ERRORS, "fixtureErrors": FIXTURE_ERRORS}
|
|
112
|
+
name = "trace-%d-%s.json" % (os.getpid(), os.urandom(4).hex())
|
|
113
|
+
with open(os.path.join(OUT, name), "w") as fh:
|
|
114
|
+
json.dump(data, fh)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _to_copy(p):
|
|
118
|
+
# the interpreter's own paths stay: its virtualenv may live in the original repo, and the sandbox exposes it there
|
|
119
|
+
if isinstance(p, str) and not (os.path.realpath(p) + os.sep).startswith(_LIB):
|
|
120
|
+
for o in ORIGINS:
|
|
121
|
+
if p == o[:-1] or p.startswith(o):
|
|
122
|
+
return ROOTS[0] + p[len(o):]
|
|
123
|
+
return p
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pth_paths():
|
|
127
|
+
# the path lines of the site dirs' .pth files: site skipped every one that points into the original repo, since
|
|
128
|
+
# the sandbox hides it
|
|
129
|
+
import site
|
|
130
|
+
for d in site.getsitepackages():
|
|
131
|
+
try:
|
|
132
|
+
names = sorted(n for n in os.listdir(d) if n.endswith(".pth"))
|
|
133
|
+
except OSError:
|
|
134
|
+
continue
|
|
135
|
+
for n in names:
|
|
136
|
+
try:
|
|
137
|
+
with open(os.path.join(d, n), encoding="utf-8") as fh:
|
|
138
|
+
lines = fh.read().splitlines()
|
|
139
|
+
except (OSError, UnicodeDecodeError):
|
|
140
|
+
continue
|
|
141
|
+
for line in lines:
|
|
142
|
+
line = line.strip()
|
|
143
|
+
if line and not line.startswith(("#", "import ", "import\t")):
|
|
144
|
+
yield os.path.join(d, line)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _redirect_origin():
|
|
148
|
+
# an editable install points into the original repo, which the sandbox hides: a path entry (a .pth line), a
|
|
149
|
+
# setuptools finder's MAPPING and NAMESPACES, or an `editables` redirect; each is sent to the copy instead
|
|
150
|
+
sys.path[:] = [_to_copy(p) for p in sys.path]
|
|
151
|
+
for p in _pth_paths():
|
|
152
|
+
moved = _to_copy(p)
|
|
153
|
+
if moved != p and moved not in sys.path and os.path.isdir(moved):
|
|
154
|
+
sys.path.append(moved)
|
|
155
|
+
for name, mod in list(sys.modules.items()):
|
|
156
|
+
if name.startswith("__editable__"):
|
|
157
|
+
for k, v in (getattr(mod, "MAPPING", None) or {}).items():
|
|
158
|
+
mod.MAPPING[k] = _to_copy(v)
|
|
159
|
+
for k, v in (getattr(mod, "NAMESPACES", None) or {}).items():
|
|
160
|
+
mod.NAMESPACES[k] = [_to_copy(x) for x in v]
|
|
161
|
+
for finder in sys.meta_path:
|
|
162
|
+
redirects = getattr(finder, "_redirections", None)
|
|
163
|
+
if isinstance(redirects, dict):
|
|
164
|
+
for k, v in redirects.items():
|
|
165
|
+
redirects[k] = _to_copy(v)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _hidden(path):
|
|
169
|
+
return isinstance(path, str) and os.path.realpath(path).startswith(HIDDEN)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def denied(exc):
|
|
173
|
+
"""Did this exception (or one it came from) come from the sandbox: a permission error, or a hidden path not found
|
|
174
|
+
(bwrap hides a path by mounting an empty dir over it, so a read of it fails as missing)."""
|
|
175
|
+
seen = set()
|
|
176
|
+
while exc is not None and id(exc) not in seen:
|
|
177
|
+
seen.add(id(exc))
|
|
178
|
+
if isinstance(exc, OSError) and (exc.errno in (errno.EPERM, errno.EACCES) or
|
|
179
|
+
(exc.errno == errno.ENOENT and (_hidden(exc.filename) or _hidden(exc.filename2)))):
|
|
180
|
+
return True
|
|
181
|
+
exc = exc.__cause__ or exc.__context__
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _denied_line(line):
|
|
186
|
+
# unittest keeps only the formatted traceback: its last line
|
|
187
|
+
if line.startswith("PermissionError") or "[Errno 1] Operation not permitted" in line or "[Errno 13] Permission denied" in line:
|
|
188
|
+
return True
|
|
189
|
+
return line.startswith("FileNotFoundError") and any(h in line or h[:-1] + "'" in line for h in HIDDEN)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _reset_in_child():
|
|
193
|
+
# a forked child starts with a copy of the parent's records; it must write only its own
|
|
194
|
+
_pairs.clear()
|
|
195
|
+
_net.clear()
|
|
196
|
+
OUTCOMES.clear()
|
|
197
|
+
COLLECT_ERRORS.clear()
|
|
198
|
+
FIXTURE_ERRORS.clear()
|
|
199
|
+
COLLECTED[0] = None
|
|
200
|
+
_forked[0] = True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _on_term(signum, frame):
|
|
204
|
+
# the test the time limit cut off, in the process that ran it (a forked worker's SIGTERM is not a cut-off): the
|
|
205
|
+
# phase it was in fails, as pytest counts it; under unittest the test is an error
|
|
206
|
+
if FIXTURE[0] and not _forked[0]:
|
|
207
|
+
FIXTURE_ERRORS.append({"nodeid": FIXTURE[0][0], "phase": FIXTURE[0][1], "message": "cut off by the time limit"})
|
|
208
|
+
elif CURRENT[0] is not None and not _forked[0]:
|
|
209
|
+
rec = OUTCOMES.setdefault(CURRENT[0], {})
|
|
210
|
+
rec.setdefault("message", "cut off by the time limit")
|
|
211
|
+
if RUNNER == "pytest":
|
|
212
|
+
phases = ("setup", "call", "teardown") if rec.get("setup", "passed") == "passed" else ("setup", "teardown")
|
|
213
|
+
phase = next((p for p in phases if p not in rec), None)
|
|
214
|
+
if phase:
|
|
215
|
+
rec[phase] = "error" if phase == "call" else "failed"
|
|
216
|
+
else:
|
|
217
|
+
rec.setdefault("call", "error")
|
|
218
|
+
_dump()
|
|
219
|
+
os._exit(128 + signum)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _wrap_unittest():
|
|
223
|
+
import traceback
|
|
224
|
+
import unittest.case
|
|
225
|
+
import unittest.result
|
|
226
|
+
import unittest.suite
|
|
227
|
+
|
|
228
|
+
run = unittest.case.TestCase.run
|
|
229
|
+
kinds = ("errors", "failures", "skipped", "expectedFailures", "unexpectedSuccesses")
|
|
230
|
+
|
|
231
|
+
def counts(result):
|
|
232
|
+
return {k: len(getattr(result, k, ()) or ()) for k in kinds}
|
|
233
|
+
|
|
234
|
+
# only the outermost test records: a suite a test runs inside itself is that test's own business
|
|
235
|
+
inside = [False]
|
|
236
|
+
|
|
237
|
+
def traced_run(self, result=None):
|
|
238
|
+
if inside[0]:
|
|
239
|
+
return run(self, result)
|
|
240
|
+
key = "unittest::" + self.id()
|
|
241
|
+
before = counts(result) if result is not None else None
|
|
242
|
+
CURRENT[0] = key
|
|
243
|
+
inside[0] = True
|
|
244
|
+
if before is not None:
|
|
245
|
+
OUTCOMES[key] = {}
|
|
246
|
+
try:
|
|
247
|
+
return run(self, result)
|
|
248
|
+
finally:
|
|
249
|
+
CURRENT[0] = None
|
|
250
|
+
inside[0] = False
|
|
251
|
+
if before is not None:
|
|
252
|
+
after = counts(result)
|
|
253
|
+
grew = [k for k in kinds if after[k] > before[k]]
|
|
254
|
+
outcome = {"errors": "error", "failures": "failed", "skipped": "skipped",
|
|
255
|
+
"expectedFailures": "xfailed", "unexpectedSuccesses": "xpassed"}.get(grew[0] if grew else "", "passed")
|
|
256
|
+
rec = {"call": outcome}
|
|
257
|
+
if outcome in ("error", "failed"):
|
|
258
|
+
tb = getattr(result, grew[0])[-1][1]
|
|
259
|
+
last = tb.strip().splitlines()[-1] if tb.strip() else ""
|
|
260
|
+
rec["message"] = last[:200]
|
|
261
|
+
rec["permission"] = _denied_line(last)
|
|
262
|
+
OUTCOMES[key] = rec
|
|
263
|
+
|
|
264
|
+
unittest.case.TestCase.run = traced_run
|
|
265
|
+
|
|
266
|
+
# a failing setUpModule, setUpClass, tearDownClass or tearDownModule is reported outside any TestCase.run, under
|
|
267
|
+
# unittest's own name for it (`setUpClass (pkg.mod.Class)`): record it as a fixture error (in its setup or teardown
|
|
268
|
+
# phase), and note the fixture while it runs, so its network attempts and a cut-off there are recorded under that
|
|
269
|
+
# name too
|
|
270
|
+
add_error = unittest.result.TestResult.addError
|
|
271
|
+
|
|
272
|
+
def traced_add_error(self, test, err):
|
|
273
|
+
if isinstance(test, unittest.suite._ErrorHolder) and not inside[0]:
|
|
274
|
+
last = traceback.format_exception_only(err[0], err[1])[-1].strip()
|
|
275
|
+
FIXTURE_ERRORS.append({"nodeid": "unittest::" + test.id(), "phase": "setup" if test.id().startswith("setUp") else "teardown",
|
|
276
|
+
"message": last[:200], "permission": denied(err[1])})
|
|
277
|
+
return add_error(self, test, err)
|
|
278
|
+
|
|
279
|
+
unittest.result.TestResult.addError = traced_add_error
|
|
280
|
+
|
|
281
|
+
def strclass(cls):
|
|
282
|
+
return "%s.%s" % (cls.__module__, cls.__qualname__)
|
|
283
|
+
|
|
284
|
+
def previous(result):
|
|
285
|
+
return getattr(result, "_previousTestClass", None)
|
|
286
|
+
|
|
287
|
+
def fixture(method, name, parent):
|
|
288
|
+
handle = getattr(unittest.suite.TestSuite, method)
|
|
289
|
+
|
|
290
|
+
def traced(self, *args):
|
|
291
|
+
p = None if inside[0] else parent(*args)
|
|
292
|
+
if not p:
|
|
293
|
+
return handle(self, *args)
|
|
294
|
+
prev, FIXTURE[0] = FIXTURE[0], ("unittest::%s (%s)" % (name, p), "setup" if name.startswith("setUp") else "teardown")
|
|
295
|
+
try:
|
|
296
|
+
return handle(self, *args)
|
|
297
|
+
finally:
|
|
298
|
+
FIXTURE[0] = prev
|
|
299
|
+
|
|
300
|
+
setattr(unittest.suite.TestSuite, method, traced)
|
|
301
|
+
|
|
302
|
+
fixture("_handleClassSetUp", "setUpClass", lambda test, result: strclass(test.__class__))
|
|
303
|
+
fixture("_tearDownPreviousClass", "tearDownClass", lambda test, result: previous(result) and strclass(previous(result)))
|
|
304
|
+
fixture("_handleModuleFixture", "setUpModule", lambda test, result: test.__class__.__module__)
|
|
305
|
+
fixture("_handleModuleTearDown", "tearDownModule", lambda result: previous(result) and previous(result).__module__)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _run_shadowed_sitecustomize():
|
|
309
|
+
# this module shadows the interpreter's own sitecustomize (Homebrew's adds its site-packages there): run that one too
|
|
310
|
+
here = os.path.dirname(os.path.realpath(__file__))
|
|
311
|
+
for d in sys.path:
|
|
312
|
+
p = os.path.join(d or ".", "sitecustomize.py")
|
|
313
|
+
if os.path.realpath(d or ".") != here and os.path.isfile(p):
|
|
314
|
+
import runpy
|
|
315
|
+
|
|
316
|
+
runpy.run_path(p, run_name="_shadowed_sitecustomize")
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
_run_shadowed_sitecustomize()
|
|
321
|
+
_redirect_origin()
|
|
322
|
+
if RUNNER == "unittest":
|
|
323
|
+
_wrap_unittest()
|
|
324
|
+
os.register_at_fork(after_in_child=_reset_in_child)
|
|
325
|
+
if signal.getsignal(signal.SIGTERM) == signal.SIG_DFL:
|
|
326
|
+
signal.signal(signal.SIGTERM, _on_term)
|
|
327
|
+
sys.addaudithook(_audit)
|
|
328
|
+
atexit.register(_dump)
|
|
329
|
+
threading.setprofile(_prof)
|
|
330
|
+
sys.setprofile(_prof)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# linux-bwrap.sh — trace.mjs's Linux sandbox (bubblewrap), for running a repo's test command.
|
|
3
|
+
#
|
|
4
|
+
# Usage: linux-bwrap.sh <scratch> <hidden paths, one per line> <read-only paths, one per line> <command> [args...]
|
|
5
|
+
#
|
|
6
|
+
# - no network at all: a new network namespace (no route out, and its own empty loopback, so nothing on
|
|
7
|
+
# the host's localhost is reachable); a seccomp filter makes socket(AF_UNIX, ...) fail with EPERM, so no
|
|
8
|
+
# unix socket (docker.sock, a database or agent socket) can be connected to, wherever it lives — a
|
|
9
|
+
# read-only mount does not stop a connect. socketpair() still works, so asyncio does;
|
|
10
|
+
# - no writes outside scratch: / is mounted read-only, the dirs that hold host state (/run, /tmp, /var/tmp,
|
|
11
|
+
# home dirs) and the hidden paths (the user's home folder and the original repo) are replaced by empty
|
|
12
|
+
# tmpfs mounts, so nothing in them can be read, and <scratch> is the only writable host path;
|
|
13
|
+
# - the read-only paths (the interpreter and its virtualenv, or node and the repo's node_modules) are mounted back over the hidden dirs;
|
|
14
|
+
# lib/sandbox.mjs never passes one that contains <scratch> or a hidden path;
|
|
15
|
+
# - new user, pid, ipc, uts and cgroup namespaces. No --die-with-parent: trace.mjs's time limit signals the whole
|
|
16
|
+
# process group, and the collector must outlive bwrap long enough to write the partial trace.
|
|
17
|
+
# The caller passes an empty environment; bwrap hands it to the command unchanged.
|
|
18
|
+
set -eu
|
|
19
|
+
scratch=$1
|
|
20
|
+
hidden=$2
|
|
21
|
+
ro=$3
|
|
22
|
+
shift 3
|
|
23
|
+
|
|
24
|
+
# the seccomp filter, as the classic-BPF program bwrap --seccomp loads (struct sock_filter, little-endian):
|
|
25
|
+
# arch is not this one -> kill · x32 syscall -> kill · socket(AF_UNIX, ...) -> EPERM · anything else -> allow
|
|
26
|
+
case $(uname -m) in
|
|
27
|
+
x86_64) arch=$((0xC000003E)); socket_nr=41 ;;
|
|
28
|
+
aarch64) arch=$((0xC00000B7)); socket_nr=198 ;;
|
|
29
|
+
*) echo "linux-bwrap.sh: no seccomp filter for $(uname -m)" >&2; exit 70 ;;
|
|
30
|
+
esac
|
|
31
|
+
byte() { printf "\\$(printf '%03o' "$1")"; }
|
|
32
|
+
ins() { byte $(($1 & 255)); byte $((($1 >> 8) & 255)); byte "$2"; byte "$3"
|
|
33
|
+
byte $(($4 & 255)); byte $((($4 >> 8) & 255)); byte $((($4 >> 16) & 255)); byte $((($4 >> 24) & 255)); }
|
|
34
|
+
LD=32 JEQ=21 JGE=53 RET=6 KILL=$((0x80000000)) ALLOW=$((0x7FFF0000)) EPERM=$((0x00050001))
|
|
35
|
+
bpf="$scratch/.fmtrace-seccomp.bpf"
|
|
36
|
+
{
|
|
37
|
+
ins $LD 0 0 4 # 0 A = arch
|
|
38
|
+
ins $JEQ 1 0 "$arch" # 1 this arch -> 3
|
|
39
|
+
ins $RET 0 0 "$KILL" # 2
|
|
40
|
+
ins $LD 0 0 0 # 3 A = syscall number
|
|
41
|
+
ins $JGE 4 0 $((0x40000000)) # 4 x32 -> 9
|
|
42
|
+
ins $JEQ 0 2 "$socket_nr" # 5 not socket() -> 8
|
|
43
|
+
ins $LD 0 0 16 # 6 A = low 32 bits of the domain argument
|
|
44
|
+
ins $JEQ 2 0 1 # 7 AF_UNIX -> 10
|
|
45
|
+
ins $RET 0 0 "$ALLOW" # 8
|
|
46
|
+
ins $RET 0 0 "$KILL" # 9
|
|
47
|
+
ins $RET 0 0 "$EPERM" # 10
|
|
48
|
+
} > "$bpf"
|
|
49
|
+
|
|
50
|
+
# the mount options are built back to front (each `set --` prepends), so bwrap applies them in this order:
|
|
51
|
+
# / read-only, /dev, /proc, the host-state dirs, the hidden paths, scratch, the read-only paths, then runs the command
|
|
52
|
+
set -- -- "$@"
|
|
53
|
+
set -f
|
|
54
|
+
IFS='
|
|
55
|
+
'
|
|
56
|
+
for p in $ro; do
|
|
57
|
+
set -- --ro-bind "$p" "$p" "$@"
|
|
58
|
+
done
|
|
59
|
+
unset IFS
|
|
60
|
+
set +f
|
|
61
|
+
set -- --bind "$scratch" "$scratch" "$@"
|
|
62
|
+
set -f
|
|
63
|
+
IFS='
|
|
64
|
+
'
|
|
65
|
+
for p in $hidden; do
|
|
66
|
+
set -- --tmpfs "$p" "$@"
|
|
67
|
+
done
|
|
68
|
+
unset IFS
|
|
69
|
+
set +f
|
|
70
|
+
for d in /srv /media /mnt /root /home /var/tmp /tmp /run; do
|
|
71
|
+
if [ -d "$d" ] && [ ! -L "$d" ]; then set -- --tmpfs "$d" "$@"; fi
|
|
72
|
+
done
|
|
73
|
+
exec bwrap --unshare-all --seccomp 3 --ro-bind / / --dev /dev --proc /proc "$@" 3<"$bpf"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
; macos-nonet.sb — trace.mjs's macOS sandbox (sandbox-exec), for running a repo's test command.
|
|
2
|
+
; SCRATCH (passed with -D SCRATCH=<realpath>) is the only place a write may land. lib/sandbox.mjs appends the read
|
|
3
|
+
; rules for each run: no reads under the user's home folder or the original repo, except scratch, the interpreter
|
|
4
|
+
; and its virtualenv. A later rule overrides an earlier one.
|
|
5
|
+
(version 1)
|
|
6
|
+
(allow default)
|
|
7
|
+
|
|
8
|
+
; no network at all: no IP traffic (localhost included), no connects to unix sockets (the DNS resolver,
|
|
9
|
+
; docker.sock, database sockets); socketpair() still works, so asyncio does
|
|
10
|
+
(deny network-outbound (remote ip))
|
|
11
|
+
(deny network-inbound (local ip))
|
|
12
|
+
(deny network-bind (local ip))
|
|
13
|
+
(deny network-outbound (remote unix-socket))
|
|
14
|
+
|
|
15
|
+
; no handing work to a process outside the sandbox, which would run with none of these rules: no opening a URL, file
|
|
16
|
+
; or app through LaunchServices (open, NSWorkspace), no Apple Events (osascript, `tell application`), no launchd jobs
|
|
17
|
+
; (launchctl submit, SMJobSubmit), neither through the services behind them nor through the tools that use them
|
|
18
|
+
(deny lsopen)
|
|
19
|
+
(deny appleevent-send)
|
|
20
|
+
(deny job-creation)
|
|
21
|
+
(deny mach-lookup
|
|
22
|
+
(global-name "com.apple.coreservices.launchservicesd")
|
|
23
|
+
(global-name "com.apple.lsd.open")
|
|
24
|
+
(global-name "com.apple.lsd.openurl")
|
|
25
|
+
(global-name "com.apple.lsd.modifydb")
|
|
26
|
+
(global-name "com.apple.coreservices.appleevents")
|
|
27
|
+
(global-name "com.apple.xpc.smd"))
|
|
28
|
+
(deny process-exec
|
|
29
|
+
(literal "/usr/bin/open")
|
|
30
|
+
(literal "/usr/bin/osascript")
|
|
31
|
+
(literal "/bin/launchctl"))
|
|
32
|
+
|
|
33
|
+
; writes only inside the scratch dir (and the null and terminal devices)
|
|
34
|
+
(deny file-write*)
|
|
35
|
+
(allow file-write*
|
|
36
|
+
(subpath (param "SCRATCH"))
|
|
37
|
+
(literal "/dev/null")
|
|
38
|
+
(literal "/dev/zero")
|
|
39
|
+
(literal "/dev/tty")
|
|
40
|
+
(literal "/dev/dtracehelper")
|
|
41
|
+
(regex #"^/dev/fd/"))
|