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.
Files changed (153) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -2
  4. package/cordis.patch.yml +7 -0
  5. package/lib/index.js +35 -0
  6. package/package.json +56 -4
  7. package/plugin.json +13 -0
  8. package/skills/coursework-author/.synced-from +5 -0
  9. package/skills/coursework-author/SKILL.md +209 -0
  10. package/skills/craft-codeflow-flows/SKILL.md +116 -0
  11. package/skills/craft-codeflow-mine/SKILL.md +207 -0
  12. package/skills/craft-codeflow-plan/SKILL.md +65 -0
  13. package/skills/craft-codeflow-scene/SKILL.md +168 -0
  14. package/skills/craft-video-align/SKILL.md +109 -0
  15. package/skills/craft-video-assemble/SKILL.md +70 -0
  16. package/skills/craft-video-audio/SKILL.md +59 -0
  17. package/skills/craft-video-enrich/SKILL.md +46 -0
  18. package/skills/craft-video-plan/SKILL.md +57 -0
  19. package/skills/craft-video-publish/SKILL.md +79 -0
  20. package/skills/craft-video-render/SKILL.md +68 -0
  21. package/skills/craft-video-scene/SKILL.md +112 -0
  22. package/skills/craft-video-script/SKILL.md +80 -0
  23. package/skills/craft-video-thumbnail/SKILL.md +168 -0
  24. package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
  25. package/skills/explainer-video-from-codebase/SKILL.md +698 -0
  26. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  27. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  28. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  29. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  30. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  31. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  32. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  33. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  35. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  37. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  38. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  45. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  59. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  60. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  61. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  62. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  63. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  81. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  88. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  89. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  106. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  107. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +61 -0
  108. package/skills/explainer-video-from-coursework/PIPELINE.md +369 -0
  109. package/skills/explainer-video-from-coursework/ROADMAP.md +727 -0
  110. package/skills/explainer-video-from-coursework/SERIES.md +243 -0
  111. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  112. package/skills/explainer-video-from-coursework/SKILL.md +998 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  118. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  120. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  121. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  122. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  124. package/skills/explainer-video-from-coursework/src/thumbnail.html +119 -0
  125. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  126. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  127. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  128. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  129. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  130. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  131. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  132. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  133. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  134. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  135. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  136. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  137. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  138. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  139. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  140. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  141. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  142. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  143. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  144. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  145. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  146. package/skills/explainer-video-from-coursework/tools/thumbnail.mjs +224 -0
  147. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  148. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  149. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  150. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  151. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  152. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  153. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,577 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ trace.mjs — run the repo's own test suite once, in a sandboxed scratch copy,
4
+ under a profiler; mark the static edges that fired as observed and attribute
5
+ every call to the test it ran under (design §3.17, ADR-014). OPT-IN.
6
+
7
+ Usage:
8
+ node tools/trace.mjs --codemap out/<name>/codemap.json --test-command "<cmd>"
9
+ --i-ran-this-by-the-user [--timeout 900]
10
+
11
+ Only the stage-1t prompt passes --i-ran-this-by-the-user, after the user said
12
+ yes to this exact command. The boundary, all of it enforced here:
13
+ - only a recognised test runner: pytest, py.test, python -m pytest,
14
+ python -m unittest; node --test, npx jest, npx vitest (or jest / vitest
15
+ from the repo's node_modules/.bin, never installed; run from the package
16
+ that holds them, the repo root or one below it); go test [-C dir]
17
+ (lib/trace-go.mjs); make test / make check [-C dir] (lib/trace-c.mjs)
18
+ (no shell syntax, no wrapper such as `uv run`);
19
+ - only in a scratch copy of the repo (its git-tracked and unignored files;
20
+ a submodule or nested repo by its own git view, never whole),
21
+ inside this platform's OS sandbox: no network of any kind (IP, localhost,
22
+ unix-socket connects), no writes outside scratch, no program outside the
23
+ sandbox acting for the run, no reads of the user's
24
+ home folder or the original repo (only the copy, the interpreter and its
25
+ virtualenv; for Go, exactly its GOROOT and GOMODCACHE), an empty environment (HOME and TMPDIR in scratch), no
26
+ bytecode or cache writes, a time limit;
27
+ - no sandbox, no run: a platform without one, or a sandbox that cannot
28
+ start here, is a refusal, never a fallback.
29
+ Writes trace.json beside the code map: an overlay (observed pairs, the static
30
+ edges that fired, per-test attribution, outcome, the tests the sandbox
31
+ denied), and trace.log (the run's output). A JavaScript / TypeScript runner is
32
+ run once per test file under V8 precise coverage (NODE_V8_COVERAGE): what ran is
33
+ attributed per test file, and no caller -> callee pair is recorded (lib/observe-v8.mjs). `go test` runs once per test
34
+ under -coverprofile (what ran is attributed to that test); `make` builds and runs its target once with coverage, routed per
35
+ recipe line, and gcov reads what ran (attributed to each test binary); neither records a caller -> callee pair (lib/observe-coverage.mjs).
36
+ Credential-shaped strings are redacted from the log, its tail and the failure messages. The code map is
37
+ never edited and no static edge is ever removed. Failing tests are data:
38
+ never retried.
39
+
40
+ Exit: 0 ok · 1 refused: no opt-in flag; or bad flags or inputs
41
+ · 2 the run produced no trace records · 3 refused: not a recognised test
42
+ command · 4 refused: no sandbox on this platform · 5 timed out (the
43
+ partial trace is kept and marked)
44
+ ========================================================================== */
45
+ import { readFileSync, writeFileSync, existsSync, realpathSync, mkdtempSync, mkdirSync, rmSync, readdirSync, lstatSync,
46
+ copyFileSync, symlinkSync, readlinkSync, chmodSync } from 'node:fs';
47
+ import { join, resolve, dirname, sep, relative, basename, isAbsolute, posix } from 'node:path';
48
+ import { tmpdir, homedir } from 'node:os';
49
+ import { spawn, spawnSync } from 'node:child_process';
50
+ import { createHash } from 'node:crypto';
51
+ import { fileURLToPath } from 'node:url';
52
+ import { pickSandbox, sandboxArgv, exposable, readRunFile, readRunHead, readRunJson, SANDBOX_LABEL } from './lib/sandbox.mjs';
53
+ import { redactSecrets } from './lib/redact.mjs';
54
+ import { observe } from './lib/observe.mjs';
55
+ import { observeV8, parseTap, parseJestJson } from './lib/observe-v8.mjs';
56
+ import { recogniseJs, jsTestFiles, passThrough, runnerPackage, nodeModulesDirs, nodeModulesLinks, fileArgv } from './lib/trace-js.mjs';
57
+ import { recogniseGo, goModuleDir, goTestsToRun, goTestArgv, parseGoTestJson, parseCoverProfile, GO_NETWORK } from './lib/trace-go.mjs';
58
+ import { recogniseMake, makeArgv, makefileShell, lineHeads, isNativeExecutable, parseGcov, parseCTestOutput, C_NETWORK } from './lib/trace-c.mjs';
59
+ import { observeCoverage, symbolsRan, linesToBlocks } from './lib/observe-coverage.mjs';
60
+
61
+ const fail = (code, msg) => { console.error(`[trace] ${msg}`); process.exit(code); };
62
+ const PAYLOAD = join(dirname(fileURLToPath(import.meta.url)), 'trace', 'python');
63
+ const JS_PAYLOAD = join(dirname(fileURLToPath(import.meta.url)), 'trace', 'js');
64
+ const C_SHELL = join(dirname(fileURLToPath(import.meta.url)), 'trace', 'c', 'fmtrace-sh');
65
+
66
+ /* ---------------- args ---------------- */
67
+ const argv = process.argv.slice(2);
68
+ const VALUED = new Set(['--codemap', '--test-command', '--timeout']);
69
+ const flags = new Map();
70
+ for (let i = 0; i < argv.length; i++) {
71
+ if (VALUED.has(argv[i])) { if (argv[i + 1] == null) fail(1, `${argv[i]} needs a value`); flags.set(argv[i], argv[++i]); }
72
+ else if (argv[i] === '--i-ran-this-by-the-user') flags.set(argv[i], true);
73
+ else fail(1, `unknown argument ${argv[i]}`);
74
+ }
75
+ const USAGE = 'usage: trace.mjs --codemap out/<name>/codemap.json --test-command "<cmd>" --i-ran-this-by-the-user [--timeout 900]';
76
+ if (!flags.has('--codemap') || !flags.has('--test-command')) fail(1, USAGE);
77
+ if (!flags.has('--i-ran-this-by-the-user'))
78
+ fail(1, 'refused: tracing runs the repo\'s test code on this machine, so it runs only after the user said yes to this exact command (--i-ran-this-by-the-user, set by the stage-1t prompt)');
79
+ const timeoutSec = flags.has('--timeout') ? Number(flags.get('--timeout')) : 900;
80
+ if (!Number.isInteger(timeoutSec) || timeoutSec <= 0) fail(1, '--timeout must be a whole number of seconds');
81
+
82
+ /* ---------------- the test command ---------------- */
83
+ // split like a POSIX shell, but refuse anything a shell would expand, chain or redirect: the command runs without one
84
+ function splitCommand(s) {
85
+ const out = []; let cur = null, i = 0;
86
+ const push = c => { cur = (cur ?? '') + c; };
87
+ while (i < s.length) {
88
+ const c = s[i];
89
+ if (c === "'") { const j = s.indexOf("'", i + 1); if (j < 0) return null; push(s.slice(i + 1, j)); i = j + 1; continue; }
90
+ if (c === '"') { let j = i + 1, v = '';
91
+ for (; j < s.length && s[j] !== '"'; j++) { if ('$`'.includes(s[j])) return null; if (s[j] === '\\' && '"\\'.includes(s[j + 1])) j++; v += s[j]; }
92
+ if (j >= s.length) return null; push(v); i = j + 1; continue; }
93
+ if (c === '\\') { if (i + 1 >= s.length) return null; push(s[i + 1]); i += 2; continue; }
94
+ if (/\s/.test(c)) { if (cur !== null) out.push(cur); cur = null; i++; continue; }
95
+ if ('|&;<>()$`{}\n'.includes(c)) return null;
96
+ push(c); i++;
97
+ }
98
+ if (cur !== null) out.push(cur);
99
+ return out;
100
+ }
101
+ const COMMAND = flags.get('--test-command');
102
+ const words = splitCommand(COMMAND);
103
+ const NOT_TEST = `refused: "${COMMAND}" is not a recognised test command. Accepted: pytest [args], py.test [args], python -m pytest [args], python -m unittest [args] (python, python3 or python3.N, by name or path); node --test [--test-* flags] [paths], npx jest [args], npx vitest [run] [args] (or jest / vitest from the repo's node_modules/.bin); go test [-C dir] [-run/-skip/-short/-v/-count/-tags/-timeout/-failfast/-p/-parallel/-cpu] [./pkg/...]; make test, make check, make -C <dir> test|check; with no shell syntax and no wrapper, a jest or vitest flag's value given as -t=name or -t name (only for the flags that take one; every other flag is boolean, so a word after it is a test path or filter), and none of the flags the trace sets itself (watch, reporter, coverage, pool); nothing but the repo's own test suite is ever traced`;
104
+ if (!words?.length) fail(3, NOT_TEST);
105
+ let runner, testArgs, lang = 'python';
106
+ const head = basename(words[0]);
107
+ const js = recogniseJs(words), goCmd = recogniseGo(words), makeCmd = recogniseMake(words);
108
+ if (/^(pytest|py\.test)$/.test(head)) { runner = 'pytest'; testArgs = words.slice(1); }
109
+ else if (/^python(\d+(\.\d+)?)?$/.test(head) && words[1] === '-m' && ['pytest', 'unittest'].includes(words[2])) { runner = words[2]; testArgs = words.slice(3); }
110
+ else if (js) { lang = 'js'; runner = js.runner; testArgs = js.args; }
111
+ else if (goCmd) { lang = 'go'; runner = goCmd.runner; testArgs = words.slice(1); }
112
+ else if (makeCmd) { lang = 'c'; runner = makeCmd.runner; testArgs = words.slice(1); }
113
+ else fail(3, NOT_TEST);
114
+
115
+ /* ---------------- the sandbox ---------------- */
116
+ const box = pickSandbox();
117
+ if (!box.kind) fail(4, `refused: ${box.why}. Tracing runs only inside an OS sandbox (sandbox-exec on macOS, bwrap on Linux); stage 1t is not offered without one`);
118
+
119
+ /* ---------------- the code map and the repo ---------------- */
120
+ const CODEMAP = resolve(flags.get('--codemap'));
121
+ let m;
122
+ try { m = JSON.parse(readFileSync(CODEMAP, 'utf8')); } catch (e) { fail(1, `cannot read the code map ${CODEMAP}: ${e.message}`); }
123
+ if (m.schema !== 1 || !Array.isArray(m.symbols) || !Array.isArray(m.edges) || !m.root) fail(1, `${CODEMAP} is not a mine.mjs code map (schema 1)`);
124
+ if (!existsSync(m.root)) fail(1, `the mined repo ${m.root} no longer exists`);
125
+ const ROOT = realpathSync(m.root), OUT = realpathSync(dirname(CODEMAP));
126
+ if (OUT === ROOT || OUT.startsWith(ROOT + sep)) fail(1, `refusing to write inside the repo: ${OUT}`);
127
+
128
+ // the repo's own interpreter: a path is taken relative to the repo; a bare name is looked up in the repo's
129
+ // virtualenv, then on PATH. Virtualenvs are not copied, so the interpreter runs from where it is, read-only, even when
130
+ // it lives in the repo (its .venv): the sandbox exposes that virtualenv and nothing else of the original repo
131
+ function findExecutable(word) {
132
+ if (word.includes('/')) { const p = isAbsolute(word) ? word : join(ROOT, word); return existsSync(p) ? p : null; }
133
+ for (const d of [join(ROOT, '.venv', 'bin'), join(ROOT, 'venv', 'bin'), ...(process.env.PATH || '').split(':').filter(Boolean)])
134
+ if (existsSync(join(d, word))) return join(d, word);
135
+ return null;
136
+ }
137
+ // a JavaScript runner runs on the node the command names (by path), else the node on PATH, from the repo's own
138
+ // node_modules (never installed, never npx's cache)
139
+ const onPath = word => (process.env.PATH || '').split(':').filter(Boolean).map(d => join(d, word)).find(p => existsSync(p)) || null;
140
+ const exe = lang === 'js' ? (runner === 'node-test' && words[0].includes('/') ? findExecutable(words[0]) : onPath('node'))
141
+ : lang === 'go' || lang === 'c' ? (words[0].includes('/') ? findExecutable(words[0]) : onPath(words[0])) : findExecutable(words[0]);
142
+ if (!exe) fail(1, lang === 'js' ? 'cannot find node on PATH' : lang === 'go' || lang === 'c' ? `cannot find ${words[0]} on PATH` : `cannot find ${words[0]} (looked in the repo's .venv/bin and venv/bin, then on PATH)`);
143
+ // the Go toolchain's own root and module cache (the repo's dependencies), asked of the go named, outside the sandbox
144
+ const goBin = lang === 'go' ? realpathSync(exe) : null;
145
+ const goEnv = lang === 'go' ? (() => { const r = spawnSync(goBin, ['env', 'GOROOT', 'GOMODCACHE', 'GOVERSION'], { encoding: 'utf8', env: { ...process.env, GOTOOLCHAIN: 'local' } });
146
+ const [root, modcache, version] = (r.stdout || '').split('\n'); return r.status === 0 ? { root, modcache, version } : null; })() : null;
147
+ if (lang === 'go' && !goEnv?.root) fail(1, `cannot ask ${exe} for its GOROOT`);
148
+ // the run may read exactly those two dirs of the home folder (the go it runs is the one inside GOROOT), never one that
149
+ // is the home folder or holds it
150
+ if (lang === 'go') {
151
+ const home = realpathSync(homedir()), within = (p, d) => p === d || p.startsWith(d === '/' ? '/' : d + sep);
152
+ for (const [k, p] of [['GOROOT', goEnv.root], ['GOMODCACHE', goEnv.modcache]]) if (p && existsSync(p) && within(home, realpathSync(p)))
153
+ fail(1, `refused: ${k} is ${p}, which is or holds your home folder; the trace exposes only the Go toolchain and its module cache, read-only, never the rest of your home folder: set ${k} to a dir of its own`);
154
+ if (within(goBin, home) && !within(goBin, realpathSync(goEnv.root)))
155
+ fail(1, `refused: ${goBin} is in your home folder outside its GOROOT ${goEnv.root}; the trace exposes only the Go toolchain and its module cache: name the go inside GOROOT/bin`);
156
+ }
157
+ const gcov = lang === 'c' ? onPath('gcov') : null;
158
+ if (lang === 'c' && !gcov) fail(1, 'cannot find gcov on PATH: the C / C++ trace reads coverage with it');
159
+ // jest and vitest run from the package that holds them (the repo root, or one below it such as frontend/)
160
+ const pkg = lang === 'js' && runner !== 'node-test' ? runnerPackage(ROOT, m, runner) : { dir: '', bin: null };
161
+ if (pkg.why) fail(1, `refused: ${pkg.why}: tracing runs only the repo's own installed test runner and never installs packages`);
162
+ const bin = pkg.bin, PKG = pkg.dir;
163
+ const jsFiles = lang === 'js' ? jsTestFiles(m, runner, testArgs, PKG) : [];
164
+ if (lang === 'js' && !jsFiles.length) fail(2, `no test file in the code map matches ${runner}'s test-file pattern${passThrough(runner, testArgs).length < testArgs.length ? ' among the files the paths or filters given select' : ''}: nothing to trace`);
165
+ const runArgs = runner === 'pytest' && !testArgs.some((a, i) => a === 'no:cacheprovider' && testArgs[i - 1] === '-p') ? [...testArgs, '-p', 'no:cacheprovider'] : testArgs;
166
+ const testArgv = lang === 'js' ? fileArgv({ runner, node: exe, bin, flags: passThrough(runner, testArgs), file: '<test file>', report: '<report>' })
167
+ : lang === 'go' ? goTestArgv({ go: goBin, flags: goCmd.flags, name: '<TestName>', profile: '<profile>', pkgDir: '<package>' })
168
+ : lang === 'c' ? makeArgv({ make: exe, dir: makeCmd.dir, target: makeCmd.target, shell: '<fmtrace-sh>' })
169
+ : /^(pytest|py\.test)$/.test(head) ? [exe, ...runArgs] : [exe, '-m', runner, ...runArgs];
170
+
171
+ // what the sandbox must still let the run read: the interpreter's prefix, its virtualenv and the prefix that
172
+ // virtualenv was made from; for Go, exactly its GOROOT and GOMODCACHE. Never the original repo: the run sees only the
173
+ // copy
174
+ function readOnlyPaths() {
175
+ const paths = [];
176
+ if (lang === 'go') return [...new Set([goEnv.root, goEnv.modcache].filter(p => p && existsSync(p)).map(p => realpathSync(p)))];
177
+ if (lang === 'c') return [...new Set([dirname(exe), dirname(realpathSync(exe)), dirname(gcov), dirname(realpathSync(gcov))].map(p => realpathSync(p)))];
178
+ if (lang === 'js') { // node's prefix (as named and as installed) and the repo's node_modules, linked into the copy
179
+ for (const prefix of new Set([dirname(dirname(exe)), dirname(dirname(realpathSync(exe)))])) paths.push(realpathSync(prefix));
180
+ for (const d of nodeModulesDirs(ROOT)) paths.push(realpathSync(join(ROOT, d)));
181
+ return [...new Set(paths)];
182
+ }
183
+ for (const prefix of new Set([dirname(dirname(exe)), dirname(dirname(realpathSync(exe)))])) {
184
+ paths.push(realpathSync(prefix));
185
+ const cfg = join(prefix, 'pyvenv.cfg');
186
+ if (!existsSync(cfg)) continue;
187
+ const text = readFileSync(cfg, 'utf8'), get = k => text.match(new RegExp(`^\\s*${k}\\s*=\\s*(.+?)\\s*$`, 'm'))?.[1];
188
+ const home = get('home'), base = get('base-prefix');
189
+ if (home && existsSync(home)) paths.push(dirname(realpathSync(home)));
190
+ if (base && existsSync(base)) paths.push(realpathSync(base));
191
+ }
192
+ return [...new Set(paths)];
193
+ }
194
+ // what the run must not read: the user's home folder (keys, cloud and git credentials) and the original repo (an
195
+ // ignored .env, other untracked files)
196
+ const HIDDEN = [...new Set([homedir(), ROOT].filter(p => p && existsSync(p)).map(p => realpathSync(p)))];
197
+
198
+ // the files to copy under dir, relative to it: git's view when dir is in a work tree (tracked plus untracked-but-not-
199
+ // ignored files, so no .git, no ignored .env, no virtualenv), else every file but .git and virtualenvs. A submodule
200
+ // or a nested repo is listed by its own git view, never copied whole, so nothing its own .gitignore ignores is copied
201
+ function repoFiles(dir) {
202
+ const files = [];
203
+ const nested = rel => existsSync(join(dir, rel, '.git')) ? repoFiles(join(dir, rel)).map(f => `${rel}/${f}`) : [];
204
+ const g = spawnSync('git', ['-C', dir, 'ls-files', '-z', '-co', '--exclude-standard'], { encoding: 'utf8', maxBuffer: 1 << 30 });
205
+ if (g.status === 0) for (const f of g.stdout.split('\0').filter(Boolean)) {
206
+ const rel = f.replace(/\/$/, '');
207
+ let st; try { st = lstatSync(join(dir, rel)); } catch { continue; }
208
+ files.push(...(st.isDirectory() ? nested(rel) : [rel]));
209
+ }
210
+ else (function walk(rel) {
211
+ for (const name of readdirSync(join(dir, rel))) {
212
+ const r = rel ? `${rel}/${name}` : name, st = lstatSync(join(dir, r));
213
+ if (!st.isDirectory()) files.push(r);
214
+ else if (existsSync(join(dir, r, '.git'))) files.push(...nested(r));
215
+ else if (name !== '.git' && !existsSync(join(dir, r, 'pyvenv.cfg'))) walk(r);
216
+ }
217
+ })('');
218
+ return files;
219
+ }
220
+
221
+ // copy the repo's files (repoFiles) into dest
222
+ function copyRepo(dest) {
223
+ const files = repoFiles(ROOT);
224
+ for (const f of files) {
225
+ const src = join(ROOT, f), dst = join(dest, f);
226
+ let st; try { st = lstatSync(src); } catch { continue; }
227
+ mkdirSync(dirname(dst), { recursive: true });
228
+ if (st.isSymbolicLink()) symlinkSync(readlinkSync(src), dst);
229
+ else if (st.isFile()) copyFileSync(src, dst);
230
+ }
231
+ return files;
232
+ }
233
+
234
+ /* ---------------- run ---------------- */
235
+ for (const f of ['trace.json', 'trace.log']) rmSync(join(OUT, f), { force: true });
236
+ const SCRATCH = realpathSync(mkdtempSync(join(tmpdir(), 'fmtrace-')));
237
+ const COPY = join(SCRATCH, 'repo'), TRACE_OUT = join(SCRATCH, 'out');
238
+ for (const d of [COPY, TRACE_OUT, join(SCRATCH, 'trace'), join(SCRATCH, 'home'), join(SCRATCH, 'tmp')]) mkdirSync(d, { recursive: true });
239
+
240
+ // one sandboxed run, its process group killed at the time limit; resolves with its output
241
+ function runSandboxed(argv, env, { box: kind, readOnly, limitMs, cwd = COPY }) {
242
+ const [cmd, ...args] = sandboxArgv(kind, { scratch: SCRATCH, hidden: HIDDEN, readOnly, argv });
243
+ const out = [], all = [];
244
+ return new Promise(done => {
245
+ const child = spawn(cmd, args, { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
246
+ let timedOut = false, killer = null;
247
+ const timer = setTimeout(() => {
248
+ timedOut = true;
249
+ try { process.kill(-child.pid, 'SIGTERM'); } catch {}
250
+ killer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 10000);
251
+ }, Math.max(1, limitMs));
252
+ child.stdout.on('data', b => { out.push(b); all.push(b); });
253
+ child.stderr.on('data', b => all.push(b));
254
+ child.on('error', e => { clearTimeout(timer); done({ status: null, error: e.message, timedOut, stdout: Buffer.concat(out).toString('utf8'), log: Buffer.concat(all) }); });
255
+ child.on('close', (status, signal) => { clearTimeout(timer); if (killer) clearTimeout(killer);
256
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
257
+ done({ status, signal, timedOut, stdout: Buffer.concat(out).toString('utf8'), log: Buffer.concat(all) }); });
258
+ });
259
+ }
260
+
261
+ let code = 0;
262
+ try {
263
+ const copiedFiles = copyRepo(COPY), copied = copiedFiles.length;
264
+ const sha = p => { try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; } };
265
+ const changedFiles = (m.files || []).filter(f => f.sha256 && sha(join(COPY, f.path)) !== f.sha256).map(f => f.path);
266
+ const readOnly = readOnlyPaths(), shown = readOnly.filter(p => exposable(p, { scratch: SCRATCH, hidden: HIDDEN }));
267
+ for (const p of readOnly.filter(p => !shown.includes(p)))
268
+ console.log(`[trace] not exposing ${p} to the run: it holds your home folder, the repo or scratch`);
269
+ if (lang === 'js') code = await runJs({ copied, changedFiles, shown });
270
+ else if (lang === 'go') code = await runGo({ copied, changedFiles, shown });
271
+ else if (lang === 'c') code = await runC({ copied, copiedFiles, changedFiles, shown });
272
+ else {
273
+ for (const f of readdirSync(PAYLOAD).filter(f => f.endsWith('.py'))) copyFileSync(join(PAYLOAD, f), join(SCRATCH, 'trace', f));
274
+ const env = {
275
+ HOME: join(SCRATCH, 'home'), TMPDIR: join(SCRATCH, 'tmp'), PATH: `${dirname(exe)}:/usr/bin:/bin`,
276
+ PYTHONDONTWRITEBYTECODE: '1', PYTHONPATH: join(SCRATCH, 'trace'),
277
+ FM_TRACE_ROOT: COPY, FM_TRACE_OUT: TRACE_OUT, FM_TRACE_RUNNER: runner,
278
+ // where the original repo was, which the sandbox hides: imports that point there (an editable install) are sent to
279
+ // the copy, and a test that fails on reading a hidden path is listed as denied
280
+ FM_TRACE_ORIGIN: [...new Set([ROOT, resolve(m.root)])].join(':'), FM_TRACE_HIDDEN: HIDDEN.join(':'),
281
+ ...(runner === 'pytest' ? { PYTEST_PLUGINS: 'fmtrace_plugin' } : {}),
282
+ };
283
+ console.log(`[trace] ${SANDBOX_LABEL[box.kind]} · ${copied} files copied to scratch · running: ${testArgv.join(' ')} (limit ${timeoutSec}s)`);
284
+ const t0 = Date.now();
285
+ const run = await runSandboxed(testArgv, env, { box: box.kind, readOnly: shown, limitMs: timeoutSec * 1000 });
286
+ const durationMs = Date.now() - t0;
287
+ const logText = redactSecrets(run.log.toString('utf8'));
288
+ writeFileSync(join(OUT, 'trace.log'), logText);
289
+ const tail = logText.trimEnd().split('\n').slice(-12).join('\n');
290
+
291
+ const dumps = readdirSync(TRACE_OUT).filter(f => /^trace-.*\.json$/.test(f)).sort().flatMap(f => {
292
+ return readRunJson(join(TRACE_OUT, f)); });
293
+ if (!dumps.length || !dumps.some(d => d.collected || Object.keys(d.outcomes || {}).length || d.fixtureErrors?.length)) {
294
+ console.error(tail);
295
+ console.error(`[trace] the run produced no trace records or ran no test (${run.error || (run.timedOut ? 'timed out' : `exit ${run.status ?? run.signal}`)}); its output is in ${join(OUT, 'trace.log')}`);
296
+ code = 2;
297
+ } else code = report(observe(m, dumps), { run, durationMs, tail, changedFiles });
298
+ }
299
+ } finally {
300
+ rmSync(SCRATCH, { recursive: true, force: true });
301
+ }
302
+ process.exit(code);
303
+
304
+ /* ---------------- JavaScript / TypeScript: one run per test file, under V8 coverage ---------------- */
305
+ async function runJs({ copied, changedFiles, shown }) {
306
+ // the repo's node_modules are not copied (git ignores them): linked into the copy entry by entry, read-only through
307
+ // the sandbox; a workspace package's link points at its copy
308
+ const nm = nodeModulesDirs(ROOT);
309
+ for (const d of nm) if (!existsSync(join(COPY, d))) {
310
+ mkdirSync(join(COPY, d), { recursive: true });
311
+ for (const l of nodeModulesLinks(ROOT, d, nm)) { mkdirSync(dirname(join(COPY, l.rel)), { recursive: true }); symlinkSync(l.copy != null ? join(COPY, l.copy) : join(ROOT, l.rel), join(COPY, l.rel)); }
312
+ }
313
+ for (const f of readdirSync(JS_PAYLOAD)) copyFileSync(join(JS_PAYLOAD, f), join(SCRATCH, 'trace', f));
314
+ const nodeVersion = (spawnSync(exe, ['--version'], { encoding: 'utf8', env: {} }).stdout || '').trim() || null;
315
+ console.log(`[trace] ${SANDBOX_LABEL[box.kind]} · ${copied} files copied to scratch · ${runner}${PKG ? ` from ${PKG}/` : ''}: ${jsFiles.length} test files, one run each under V8 coverage (limit ${timeoutSec}s in all)`);
316
+ const deadline = Date.now() + timeoutSec * 1000, t0 = Date.now();
317
+ const runs = [], logs = [];
318
+ let timedOut = false, exitCode = 0;
319
+ for (const [i, file] of jsFiles.entries()) {
320
+ const left = deadline - Date.now();
321
+ if (left <= 0) { timedOut = true; break; }
322
+ const cov = join(TRACE_OUT, `v8-${i}`), rep = join(TRACE_OUT, `report-${i}.json`);
323
+ mkdirSync(cov, { recursive: true });
324
+ const env = {
325
+ HOME: join(SCRATCH, 'home'), TMPDIR: join(SCRATCH, 'tmp'), PATH: `${dirname(exe)}:/usr/bin:/bin`,
326
+ NODE_V8_COVERAGE: cov, NODE_OPTIONS: ['fmtrace-net.cjs', 'fmtrace-srcmap.cjs'].map(f => `--require ${JSON.stringify(join(SCRATCH, 'trace', f))}`).join(' '),
327
+ FM_TRACE_OUT: TRACE_OUT, FM_TRACE_FILE: file, CI: '1', NO_COLOR: '1', FORCE_COLOR: '0',
328
+ };
329
+ const argv = fileArgv({ runner, node: exe, bin, flags: passThrough(runner, testArgs), file: PKG ? relative(PKG, file) : file, report: rep });
330
+ const run = await runSandboxed(argv, env, { box: box.kind, readOnly: shown, limitMs: left, cwd: join(COPY, PKG) });
331
+ logs.push(`== ${file} (exit ${run.status ?? run.signal}${run.timedOut ? ', cut off by the time limit' : ''})\n${run.log.toString('utf8')}`);
332
+ if (run.status && !exitCode) exitCode = run.status;
333
+ let outcomes;
334
+ if (runner === 'node-test') outcomes = parseTap(run.stdout);
335
+ else outcomes = parseJestJson(readRunJson(rep)[0] ?? null, COPY);
336
+ const coverage = readdirSync(cov).filter(f => f.endsWith('.json')).sort().flatMap(f => readRunJson(join(cov, f)));
337
+ // the source maps of the scripts jest and vitest compiled through node:vm, which Node's dump does not record
338
+ const maps = readdirSync(TRACE_OUT).filter(f => /^srcmap-.*\.json$/.test(f)).flatMap(f => { try { return readRunJson(join(TRACE_OUT, f)); } finally { rmSync(join(TRACE_OUT, f), { force: true }); } })
339
+ .filter(s => s.file === file);
340
+ for (const d of coverage) for (const s of maps) (d['source-map-cache'] ||= {})[s.url] ??= { lineLengths: s.lineLengths, data: s.data };
341
+ const network = readdirSync(TRACE_OUT).filter(f => /^net-.*\.json$/.test(f)).sort().flatMap(f => readRunJson(join(TRACE_OUT, f)))
342
+ .filter(n => n.file === file).flatMap(n => n.attempts);
343
+ runs.push({ file, outcomes, coverage, network: [...new Set(network)], timedOut: run.timedOut, exitCode: run.status });
344
+ if (run.timedOut) { timedOut = true; break; }
345
+ }
346
+ const durationMs = Date.now() - t0;
347
+ const logText = redactSecrets(logs.join('\n'));
348
+ writeFileSync(join(OUT, 'trace.log'), logText);
349
+ const tail = logText.trimEnd().split('\n').slice(-12).join('\n');
350
+ // V8 writes coverage for a runner that failed at startup too: no test reported in any file is no trace
351
+ if (!runs.some(r => r.outcomes.tests.length)) {
352
+ console.error(tail);
353
+ console.error(`[trace] the run ran no test (${timedOut ? 'timed out' : `exit ${exitCode}`}); its output is in ${join(OUT, 'trace.log')}`);
354
+ return 2;
355
+ }
356
+ const o = observeV8(m, runs, { root: COPY, readFile: rel => readRunFile(join(COPY, rel)), node: nodeVersion });
357
+ return reportJs(o, { durationMs, tail, changedFiles, timedOut, exitCode, ranFiles: runs.map(r => r.file) });
358
+ }
359
+
360
+ function reportJs(o, { durationMs, tail, changedFiles, timedOut, exitCode, ranFiles }) {
361
+ const sha = p => createHash('sha256').update(readFileSync(p)).digest('hex');
362
+ const trace = {
363
+ schema: 1, tool: { name: 'trace.mjs', node: o.runtime.node },
364
+ optIn: { by: 'user', at: new Date().toISOString(), command: COMMAND },
365
+ sandbox: SANDBOX_LABEL[box.kind], runner, cwd: PKG || '.', argv: testArgv, files: jsFiles, ranFiles, timeoutSec, timedOut, exitCode, durationMs,
366
+ codemap: { file: relative(OUT, CODEMAP), sha256: sha(CODEMAP), commit: m.commit ?? null, treeHash: m.treeHash ?? null, changedFiles },
367
+ granularity: o.granularity, outcome: o.outcome, notCollected: o.notCollected, failures: o.failures.map(redactMessage), deniedBySandbox: o.deniedBySandbox.map(redactMessage), summary: o.summary,
368
+ pairs: o.pairs, edges: o.edges, noStaticEdge: o.noStaticEdge, callbacks: o.callbacks, ran: o.ran, byFile: o.byFile, byTest: o.byTest,
369
+ };
370
+ writeFileSync(join(OUT, 'trace.json'), JSON.stringify(trace, null, 1) + '\n');
371
+ const s = o.summary, oc = o.outcome, pct = (a, b) => b ? `${(100 * a / b).toFixed(1)}%` : '-';
372
+ console.log(tail.split('\n').slice(-3).map(l => ` | ${l}`).join('\n'));
373
+ console.log(`[trace] ${ranFiles.length}/${jsFiles.length} test files run · ${oc.collected} tests: ${oc.passed} passed, ${oc.failed} failed, ${oc.errors} errors, ${oc.skipped} skipped · ${(durationMs / 1000).toFixed(1)}s${timedOut ? ' · TIMED OUT, partial trace' : ''}`);
374
+ if (o.notCollected.length) console.log(`[trace] ${o.notCollected.length} test files ran but reported no test (the runner's config does not collect them, or they define none), their ${o.notCollected.reduce((n, f) => n + f.tests, 0)} tests left out of the share below: ${o.notCollected.slice(0, 5).map(f => f.file).join(', ')}`);
375
+ console.log(`[trace] functions that ran (V8 coverage, per test file): ${Object.entries(s.functionsRan).map(([g, v]) => `${g} ${v.ran}/${v.total}`).join(', ')} · ${s.unmappedFunctions} named functions matched no symbol (${s.anonymousCallbacks} anonymous callbacks ran inside the symbols above)`);
376
+ console.log(`[trace] per test file: direct links whose target ran in their test's file ${s.directLinksPerTest.observed}/${s.directLinksPerTest.total} (${pct(s.directLinksPerTest.observed, s.directLinksPerTest.total)}, ${s.directLinksPerTest.tests} tests) · hooks that ran ${s.fixtureLinks.observed}/${s.fixtureLinks.total}`);
377
+ console.log(`[trace] caller -> callee pairs: ${s.pairs}`);
378
+ console.log(`[trace] denied by the sandbox: ${o.deniedBySandbox.length ? o.deniedBySandbox.map(d => `${d.nodeid}${d.network.length ? ` [${d.network.join('; ')}]` : ''}${d.permission ? ' [permission]' : ''}`).join(' · ') : 'none'}`);
379
+ if (changedFiles.length) console.log(`[trace] warning: ${changedFiles.length} mined files changed since the code map was written (${changedFiles.slice(0, 5).join(', ')}): mine again, then trace`);
380
+ console.log(`[trace] wrote ${relative(process.cwd(), join(OUT, 'trace.json'))} and trace.log`);
381
+ return timedOut ? 5 : 0;
382
+ }
383
+
384
+ /* ---------------- Go: one run per test, under -coverprofile ---------------- */
385
+ async function runGo({ copied, changedFiles, shown }) {
386
+ const mod = goModuleDir(m, goCmd, existsSync(join(ROOT, 'go.mod')));
387
+ if (mod.why) { console.error(`[trace] refused: ${mod.why}`); return 1; }
388
+ const tests = goTestsToRun(m, goCmd, mod.dir);
389
+ if (!tests.length) { console.error(`[trace] no TestXxx in the code map is in the packages ${goCmd.packages.join(' ')}${goCmd.run ? ` matching -run ${goCmd.run}` : ''} of ${mod.dir || 'the root'} module: nothing to trace`); return 2; }
390
+ const modPath = /^\s*module\s+(\S+)/m.exec(readFileSync(join(COPY, mod.dir, 'go.mod'), 'utf8'))?.[1];
391
+ const importDir = p => p === modPath ? mod.dir : p.startsWith(modPath + '/') ? [mod.dir, p.slice(modPath.length + 1)].filter(Boolean).join('/') : null;
392
+ const env = {
393
+ HOME: join(SCRATCH, 'home'), TMPDIR: join(SCRATCH, 'tmp'), PATH: `${dirname(goBin)}:/usr/bin:/bin`,
394
+ GOCACHE: join(SCRATCH, 'gocache'), GOPATH: join(SCRATCH, 'gopath'), ...(goEnv.modcache && existsSync(goEnv.modcache) ? { GOMODCACHE: goEnv.modcache } : {}),
395
+ GOPROXY: 'off', GOSUMDB: 'off', GOTOOLCHAIN: 'local', GOTELEMETRY: 'off', GOENV: 'off', GOFLAGS: '', CGO_ENABLED: '0',
396
+ };
397
+ console.log(`[trace] ${SANDBOX_LABEL[box.kind]} · ${copied} files copied to scratch · go test in ${mod.dir || 'the root'} module: ${tests.length} tests, one run each under -coverprofile (limit ${timeoutSec}s in all)`);
398
+ const deadline = Date.now() + timeoutSec * 1000, t0 = Date.now();
399
+ const results = new Map(), units = [], failures = [], denied = [], logs = [];
400
+ let timedOut = false;
401
+ for (const [i, t] of tests.entries()) {
402
+ const left = deadline - Date.now();
403
+ if (left <= 0) { timedOut = true; break; }
404
+ const profile = join(TRACE_OUT, `cover-${i}.out`);
405
+ const argv = goTestArgv({ go: goBin, flags: goCmd.flags, name: t.name, profile, pkgDir: t.pkgDir });
406
+ const run = await runSandboxed(argv, env, { box: box.kind, readOnly: shown, limitMs: left, cwd: join(COPY, mod.dir) });
407
+ // the log keeps what go test printed, not its -json envelopes
408
+ const printed = run.log.toString('utf8').split('\n').map(l => { if (!l.startsWith('{')) return l + '\n'; try { return JSON.parse(l).Output ?? ''; } catch { return l + '\n'; } }).join('');
409
+ logs.push(`== ${t.id} (exit ${run.status ?? run.signal}${run.timedOut ? ', cut off by the time limit' : ''})\n${printed}`);
410
+ const ev = parseGoTestJson(run.stdout);
411
+ const r = ev.tests.find(x => x.name === t.name);
412
+ const nodeid = `${t.pkgDir || '.'}::${t.name}`;
413
+ const status = r?.status || (run.timedOut ? 'error' : ev.buildErrors.length || run.status ? 'error' : 'not run');
414
+ const message = r?.message || ev.buildErrors.join('\n') || (run.timedOut ? 'cut off by the time limit' : '');
415
+ results.set(t.id, { status, message, nodeid });
416
+ const prof = readRunFile(profile), ran = prof == null ? new Set() : symbolsRan(m, parseCoverProfile(prof, importDir));
417
+ units.push({ key: t.id, tests: [t.id], ran });
418
+ if (status === 'failed' || status === 'error') {
419
+ failures.push({ test: t.id, nodeid, status, phase: status === 'error' ? 'collect' : 'call', message });
420
+ const network = [...new Set([...message.matchAll(GO_NETWORK)].map(x => x[0].trim()))];
421
+ const permission = !network.length && /operation not permitted|permission denied|read-only file system/i.test(message);
422
+ if (network.length || permission) denied.push({ test: t.id, nodeid, status, network, permission, message });
423
+ }
424
+ if (run.timedOut) { timedOut = true; break; }
425
+ }
426
+ for (const t of tests) if (!results.has(t.id)) results.set(t.id, { status: 'not run', message: 'the time limit was reached first', nodeid: `${t.pkgDir || '.'}::${t.name}` });
427
+ const durationMs = Date.now() - t0;
428
+ const logText = redactSecrets(logs.join('\n'));
429
+ writeFileSync(join(OUT, 'trace.log'), logText);
430
+ const tail = logText.trimEnd().split('\n').slice(-12).join('\n');
431
+ if (![...results.values()].some(r => r.status === 'passed' || r.status === 'failed' || r.status === 'skipped')) {
432
+ console.error(tail);
433
+ console.error(`[trace] the run ran no test (${timedOut ? 'timed out' : 'every run failed to build or start'}); its output is in ${join(OUT, 'trace.log')}`);
434
+ return 2;
435
+ }
436
+ const o = observeCoverage(m, { units, results, failures, denied, granularity: 'test', ownFile: f => f.endsWith('.go'), runtime: { go: goEnv.version || null } });
437
+ return reportCoverage(o, { durationMs, tail, changedFiles, timedOut, exitCode: failures.length ? 1 : 0, cwd: mod.dir || '.', ran: tests.length });
438
+ }
439
+
440
+ /* ---------------- C / C++: the make target once, with coverage per test binary, read by gcov ---------------- */
441
+ async function runC({ copied, copiedFiles, changedFiles, shown }) {
442
+ const dir = makeCmd.dir;
443
+ const makefile = ['GNUmakefile', 'makefile', 'Makefile'].find(f => existsSync(join(COPY, dir, f)));
444
+ if (!makefile) { console.error(`[trace] refused: no Makefile in ${dir || 'the repo root'}`); return 1; }
445
+ const shell = join(SCRATCH, 'trace', 'fmtrace-sh'), UNITS = join(TRACE_OUT, 'units'), recipeShell = makefileShell(readRunFile(join(COPY, dir, makefile)) || '');
446
+ copyFileSync(C_SHELL, shell); chmodSync(shell, 0o755); mkdirSync(UNITS);
447
+ const env = { HOME: join(SCRATCH, 'home'), TMPDIR: join(SCRATCH, 'tmp'), PATH: [...new Set([dirname(exe), '/usr/local/bin', '/usr/bin', '/bin'])].join(':'), FM_TRACE_UNITS: UNITS,
448
+ ...(recipeShell ? { FM_TRACE_SHELL: recipeShell } : {}) };
449
+ const argv = makeArgv({ make: exe, dir, target: makeCmd.target, shell });
450
+ console.log(`[trace] ${SANDBOX_LABEL[box.kind]} · ${copied} files copied to scratch · running: ${argv.join(' ')}, coverage per test binary (limit ${timeoutSec}s)`);
451
+ const t0 = Date.now();
452
+ const run = await runSandboxed(argv, env, { box: box.kind, readOnly: shown, limitMs: timeoutSec * 1000 });
453
+ // each recipe line fmtrace-sh ran, in order: its command, the commands it runs, the dir it ran in (inside the copy,
454
+ // else null), exit status, output and the counters it wrote. The run wrote these files, so only plain files inside its own line dirs are read, never through a symlink
455
+ const gcdasIn = d => { const out = []; (function walk(p) { let st; try { st = lstatSync(p); } catch { return; }
456
+ if (st.isDirectory()) for (const n of readdirSync(p)) walk(join(p, n)); else if (st.isFile() && p.endsWith('.gcda')) out.push(p); })(d); return out; };
457
+ const lines = [...new Set((readRunFile(join(UNITS, 'order')) || '').split('\n').map(l => basename(l.trim())).filter(n => /^line-\w+$/.test(n)))]
458
+ .filter(n => lstatSync(join(UNITS, n), { throwIfNoEntry: false })?.isDirectory())
459
+ .map(n => { const d = join(UNITS, n), st = readRunFile(join(d, 'status')), command = readRunFile(join(d, 'command')) ?? '';
460
+ const cwd = readRunFile(join(d, 'cwd'))?.trim(), inCopy = cwd === COPY || cwd?.startsWith(COPY + '/');
461
+ return { dir: d, command, heads: lineHeads(command), cwd: inCopy ? cwd : null, status: st == null ? null : Number(st.trim()), output: readRunFile(join(d, 'output')) || '', gcdas: gcdasIn(join(d, 'gcda')) }; });
462
+ // inside the same sandbox, each counter file and the notes file the compiler wrote beside its object are copied into
463
+ // a fresh dir and read there by gcov: nothing outside the sandbox opens what the run wrote; a source is named as the
464
+ // compiler saw it, from the dir the line ran in
465
+ const cFiles = new Set((m.files || []).filter(f => f.lang === 'c' || f.lang === 'cpp').map(f => f.path));
466
+ const toRepo = (src, cwd) => { const a = posix.normalize(src.startsWith('/') ? src : posix.join(cwd, src));
467
+ const rel = a.startsWith(COPY + '/') ? a.slice(COPY.length + 1) : null;
468
+ if (rel && cFiles.has(rel)) return rel; const tail = src.replace(/^(\.\.?\/)+/, ''); const hits = [...cFiles].filter(f => f === tail || f.endsWith('/' + tail)); return hits.length === 1 ? hits[0] : null; };
469
+ // a head word that names, from the dir its line ran in, a native executable in the copy which the repo did not hold:
470
+ // a binary the build produced (each distinct file is read once, its first 4 bytes only)
471
+ const repoHeld = new Set(copiedFiles), native = new Map();
472
+ const built = (h, cwd) => { if (!cwd || !h.includes('/')) return false;
473
+ const p = posix.normalize(h.startsWith('/') ? h : posix.join(cwd, h));
474
+ if (!p.startsWith(COPY + '/') || repoHeld.has(p.slice(COPY.length + 1))) return false;
475
+ if (!native.has(p)) native.set(p, isNativeExecutable(readRunHead(p, 4)));
476
+ return native.get(p); };
477
+ const GCOV_COPY = 'd=$(mktemp -d "$TMPDIR/gcov-XXXXXX") && cp -- "$1" "$d/x.gcda" && cp -- "$2" "$d/x.gcno" && exec "$3" -t -o "$d" "$d/x.gcda"';
478
+ const units = [], seen = new Map();
479
+ for (const l of lines.filter(x => x.gcdas.length)) {
480
+ const counts = new Map(), cwd = l.cwd || join(COPY, dir);
481
+ for (const g of l.gcdas) {
482
+ const gcno = g.slice(join(l.dir, 'gcda').length).replace(/\.gcda$/, '.gcno');
483
+ const r = await runSandboxed(['/bin/sh', '-c', GCOV_COPY, 'fmtrace-gcov', g, gcno, gcov], env, { box: box.kind, readOnly: shown, limitMs: 60000, cwd });
484
+ for (const [src, ls] of parseGcov(r.stdout)) { const f = toRepo(src, cwd); if (f) (counts.get(f) || counts.set(f, []).get(f)).push(...linesToBlocks(ls)); }
485
+ }
486
+ const n = (seen.get(l.command) || 0) + 1; seen.set(l.command, n);
487
+ const binaries = !!l.heads && l.heads.every(h => built(h, l.cwd));
488
+ units.push({ key: n > 1 ? `${l.command} #${n}` : l.command, line: l, tests: [], ran: symbolsRan(m, counts), provesPass: binaries, provesFail: binaries && l.heads.length === 1 });
489
+ }
490
+ const durationMs = Date.now() - t0;
491
+ const logText = redactSecrets(run.log.toString('utf8'));
492
+ writeFileSync(join(OUT, 'trace.log'), logText);
493
+ const tail = logText.trimEnd().split('\n').slice(-12).join('\n');
494
+ if (!units.length) {
495
+ console.error(tail);
496
+ console.error(`[trace] the run wrote no coverage counters (${run.timedOut ? 'timed out' : `exit ${run.status ?? run.signal}`}): the target built nothing with $(CC) / $(CXX), or ran no test; its output is in ${join(OUT, 'trace.log')}`);
497
+ return 2;
498
+ }
499
+ // a test belongs to the first binary its own lines ran in; it passed or failed as that binary's recipe line did, when
500
+ // every command the line runs is a binary the build produced (one: either way; an && chain of them: 0 only), else
501
+ // unknown, unless it printed a status of its own
502
+ const printed = parseCTestOutput(logText);
503
+ const tests = (m.tests || []).filter(t => cFiles.has(t.file) && (!dir || t.file.startsWith(dir + '/')));
504
+ const results = new Map();
505
+ for (const t of tests) {
506
+ const s = m.symbols.find(x => x.id === t.id), name = s?.name || t.id.split(':').pop(), u = units.find(x => x.ran.has(t.id));
507
+ if (u) u.tests.push(t.id);
508
+ const status = printed.get(name) || (!u ? 'not run' : u.line.status === 0 ? (u.provesPass ? 'passed' : 'unknown') : u.provesFail ? 'failed' : 'unknown');
509
+ results.set(t.id, { status, message: status === 'failed' || status === 'unknown' ? (u?.line.output.trim() || tail) : '', nodeid: `${t.file}::${name}` });
510
+ }
511
+ const failures = [...results].filter(([, r]) => r.status === 'failed').map(([id, r]) => ({ test: id, nodeid: r.nodeid, status: 'failed', phase: 'call', message: r.message }));
512
+ const failedLine = lines.find(l => l.status);
513
+ if ((run.status || run.timedOut) && !failures.length)
514
+ failures.push({ test: null, nodeid: failedLine?.command || `make ${makeCmd.target}`, status: 'error', phase: 'run', message: run.timedOut ? 'cut off by the time limit' : failedLine?.output.trim() || tail });
515
+ // a binary whose output shows a refused connect or a permission error was denied by the sandbox
516
+ const denied = lines.flatMap(l => {
517
+ const network = [...new Set([...l.output.matchAll(C_NETWORK)].map(x => x[0].trim()))];
518
+ const permission = !network.length && !!l.status && /Operation not permitted|Permission denied|Read-only file system/.test(l.output);
519
+ if (!network.length && !permission) return [];
520
+ const f = failures.find(x => units.find(u => u.line === l)?.tests.includes(x.test)) || failures.find(x => !x.test && x.nodeid === l.command);
521
+ return [{ test: f?.test || null, nodeid: f?.nodeid || l.command, status: f?.status || (l.status ? 'failed' : 'passed'), network, permission, message: l.output.trim() }];
522
+ });
523
+ const o = observeCoverage(m, { units: units.map(u => ({ key: u.key, tests: u.tests, ran: u.ran })), results, failures, denied, granularity: 'binary', ownFile: f => cFiles.has(f), runtime: {} });
524
+ return reportCoverage(o, { durationMs, tail, changedFiles, timedOut: run.timedOut, exitCode: run.status, cwd: dir || '.', ran: tests.length });
525
+ }
526
+
527
+ function reportCoverage(o, { durationMs, tail, changedFiles, timedOut, exitCode, cwd, ran }) {
528
+ const sha = p => createHash('sha256').update(readFileSync(p)).digest('hex');
529
+ const trace = {
530
+ schema: 1, tool: { name: 'trace.mjs', ...o.runtime },
531
+ optIn: { by: 'user', at: new Date().toISOString(), command: COMMAND },
532
+ sandbox: SANDBOX_LABEL[box.kind], runner, cwd, argv: testArgv, timeoutSec, timedOut, exitCode, durationMs,
533
+ codemap: { file: relative(OUT, CODEMAP), sha256: sha(CODEMAP), commit: m.commit ?? null, treeHash: m.treeHash ?? null, changedFiles },
534
+ granularity: o.granularity, outcome: o.outcome, failures: o.failures.map(redactMessage), deniedBySandbox: o.deniedBySandbox.map(redactMessage), summary: o.summary,
535
+ pairs: o.pairs, edges: o.edges, noStaticEdge: o.noStaticEdge, callbacks: o.callbacks, ran: o.ran, byUnit: o.byUnit, byTest: o.byTest,
536
+ };
537
+ writeFileSync(join(OUT, 'trace.json'), JSON.stringify(trace, null, 1) + '\n');
538
+ const s = o.summary, oc = o.outcome, pct = (a, b) => b ? `${(100 * a / b).toFixed(1)}%` : '-';
539
+ console.log(tail.split('\n').slice(-3).map(l => ` | ${l}`).join('\n'));
540
+ console.log(`[trace] ${oc.collected} tests (${ran} in scope): ${oc.passed} passed, ${oc.failed} failed, ${oc.errors} errors, ${oc.skipped} skipped${oc.unknown ? `, ${oc.unknown} ran with no status of their own` : ''}${oc.notRun ? `, ${oc.notRun} not run` : ''} · ${(durationMs / 1000).toFixed(1)}s${timedOut ? ' · TIMED OUT, partial trace' : ''}`);
541
+ console.log(`[trace] functions that ran (coverage, per ${o.granularity}): ${Object.entries(s.functionsRan).map(([g, v]) => `${g} ${v.ran}/${v.total}`).join(', ')}`);
542
+ console.log(`[trace] per ${o.granularity}: direct links whose target ran ${s.directLinksPerTest.observed}/${s.directLinksPerTest.total} (${pct(s.directLinksPerTest.observed, s.directLinksPerTest.total)}, ${s.directLinksPerTest.tests} tests) · hooks that ran ${s.fixtureLinks.observed}/${s.fixtureLinks.total}`);
543
+ console.log(`[trace] caller -> callee pairs: ${s.pairs}`);
544
+ console.log(`[trace] denied by the sandbox: ${o.deniedBySandbox.length ? o.deniedBySandbox.map(d => `${d.nodeid}${d.network.length ? ` [${d.network.join('; ')}]` : ''}${d.permission ? ' [permission]' : ''}`).join(' · ') : 'none'}`);
545
+ if (changedFiles.length) console.log(`[trace] warning: ${changedFiles.length} mined files changed since the code map was written (${changedFiles.slice(0, 5).join(', ')}): mine again, then trace`);
546
+ console.log(`[trace] wrote ${relative(process.cwd(), join(OUT, 'trace.json'))} and trace.log`);
547
+ return timedOut ? 5 : 0;
548
+ }
549
+
550
+ /* ---------------- trace.json and the summary ---------------- */
551
+ function redactMessage(f) { return { ...f, message: redactSecrets(f.message) }; }
552
+ function report(o, { run, durationMs, tail, changedFiles }) {
553
+ const sha = p => createHash('sha256').update(readFileSync(p)).digest('hex');
554
+ const trace = {
555
+ schema: 1, tool: { name: 'trace.mjs', python: o.python },
556
+ optIn: { by: 'user', at: new Date().toISOString(), command: COMMAND },
557
+ sandbox: SANDBOX_LABEL[box.kind], runner, argv: testArgv, timeoutSec, timedOut: run.timedOut, exitCode: run.status, durationMs,
558
+ codemap: { file: relative(OUT, CODEMAP), sha256: sha(CODEMAP), commit: m.commit ?? null, treeHash: m.treeHash ?? null, changedFiles },
559
+ outcome: o.outcome, failures: o.failures.map(redactMessage), deniedBySandbox: o.deniedBySandbox.map(redactMessage), summary: o.summary,
560
+ pairs: o.pairs, edges: o.edges, noStaticEdge: o.noStaticEdge, callbacks: o.callbacks, byTest: o.byTest,
561
+ };
562
+ writeFileSync(join(OUT, 'trace.json'), JSON.stringify(trace, null, 1) + '\n');
563
+
564
+ const s = o.summary, pct = (a, b) => b ? `${(100 * a / b).toFixed(1)}%` : '-';
565
+ const oc = o.outcome;
566
+ console.log(tail.split('\n').slice(-3).map(l => ` | ${l}`).join('\n'));
567
+ console.log(`[trace] ${oc.collected} collected: ${oc.passed} passed, ${oc.failed} failed, ${oc.errors} errors, ${oc.skipped} skipped${oc.xfailed || oc.xpassed ? `, ${oc.xfailed} xfailed, ${oc.xpassed} xpassed` : ''} · ${(durationMs / 1000).toFixed(1)}s${run.timedOut ? ' · TIMED OUT, partial trace' : ''}`);
568
+ console.log(`[trace] ${s.distinctPairs} distinct caller -> callee pairs · functions that ran: ${Object.entries(s.functionsRan).map(([g, v]) => `${g} ${v.ran}/${v.total}`).join(', ')}`);
569
+ console.log(`[trace] static edges observed ${s.edges.all.observed}/${s.edges.all.total} (${pct(s.edges.all.observed, s.edges.all.total)}) · production -> production ${s.edges.prodToProd.observed}/${s.edges.prodToProd.total} · test -> non-test ${s.edges.testToNonTest.observed}/${s.edges.testToNonTest.total}`);
570
+ console.log(`[trace] per test: direct links that ran under their own test ${s.directLinksPerTest.observed}/${s.directLinksPerTest.total} (${pct(s.directLinksPerTest.observed, s.directLinksPerTest.total)}, ${s.directLinksPerTest.tests} tests) · fixture links ${s.fixtureLinks.observed}/${s.fixtureLinks.total}`);
571
+ console.log(`[trace] callback links observed: ${o.callbacks.length}${o.callbacks.map(c => `\n ${c.from} -> ${c.to} (handed over by ${c.passedBy}, ${c.tests} tests)`).join('')}`);
572
+ console.log(`[trace] observed calls with no static edge: ${s.noStaticEdge.pairs} (${Object.entries(s.noStaticEdge.byCategory).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'})${o.noStaticEdge.filter(x => x.category === 'other').map(x => `\n ${x.from} -> ${x.to}`).join('')}`);
573
+ console.log(`[trace] denied by the sandbox: ${o.deniedBySandbox.length ? o.deniedBySandbox.map(d => `${d.nodeid}${d.network.length ? ` [${d.network.join('; ')}]` : ''}${d.permission ? ' [permission]' : ''}`).join(' · ') : 'none'}`);
574
+ if (changedFiles.length) console.log(`[trace] warning: ${changedFiles.length} mined files changed since the code map was written (${changedFiles.slice(0, 5).join(', ')}): mine again, then trace`);
575
+ console.log(`[trace] wrote ${relative(process.cwd(), join(OUT, 'trace.json'))} and trace.log`);
576
+ return run.timedOut ? 5 : 0;
577
+ }
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014 Max Brunsfeld
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.