explainer-video 0.0.0-stage → 0.4.0

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 (150) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -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 +69 -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/explainer-video-from-codebase/FLOWS.md +577 -0
  24. package/skills/explainer-video-from-codebase/SKILL.md +659 -0
  25. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  26. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  27. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  28. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  29. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  30. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  31. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  32. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  33. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  35. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  37. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  38. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  45. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  59. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  60. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  61. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  62. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  63. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  81. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  88. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  89. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  106. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +59 -0
  107. package/skills/explainer-video-from-coursework/PIPELINE.md +343 -0
  108. package/skills/explainer-video-from-coursework/ROADMAP.md +725 -0
  109. package/skills/explainer-video-from-coursework/SERIES.md +232 -0
  110. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  111. package/skills/explainer-video-from-coursework/SKILL.md +858 -0
  112. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  118. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  120. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  121. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  122. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  124. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  125. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  126. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  127. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  128. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  129. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  130. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  131. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  132. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  133. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  134. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  135. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  136. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  137. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  138. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  139. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  140. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  141. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  142. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  143. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  144. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  145. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  146. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  147. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  148. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  149. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  150. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,111 @@
1
+ /* Rule pack: frontend and Node HTTP clients. `fetch(url, { method })` (the global, not an in-repo function of that
2
+ name), `axios.get/post/…(url)`, `axios(config)` / `axios.request(config)`, `ky.get/…(url)` and `ky(url, { method })`,
3
+ and instances made by `axios.create({ baseURL })` or `ky.create/extend({ prefixUrl })`, whose base is prefixed.
4
+ The URL is folded to a string (B15): string and template literals, `+`, and identifiers or object members that
5
+ name a const, here or imported; `process.env.X ?? '/api'` folds to its default; what cannot be known is `{}`.
6
+ Any scheme://host is dropped. The path is matched against every route the repo serves, in any language
7
+ (FastAPI, Express, Next.js): a placeholder matches a route parameter, and the route with the most literal
8
+ segments wins. A method that cannot be folded links only when every method the path serves leads to one handler;
9
+ otherwise the call stays unlinked, with why (`unlinked`). A URL whose leading base could not be folded is matched by its tail instead, only when one route
10
+ has that tail more literally than every other (`match: "suffix"`). A match is an `http` crossing from the caller to the route handler. */
11
+ import { addTsEdge, foldString, constOf } from '../lang/typescript.mjs';
12
+
13
+ const VERB = /^(get|post|put|patch|delete|head|options)$/;
14
+ const segsOf = p => p.replace(/\?.*$/, '').split('/').filter(Boolean);
15
+ const isParam = s => /^\{[^}]+\}$/.test(s);
16
+
17
+ // does a route (its segments) answer a call path (its segments, `{}` where unknown)?
18
+ const fits = (rs, cs) => rs.length === cs.length && rs.every((r, i) => isParam(r) ? cs[i] !== '' : r === cs[i]);
19
+ const literal = rs => rs.filter(s => !isParam(s)).length;
20
+
21
+ // the route `best` picks among those serving the method (`best` gives [] for none, [route], or the routes that tie);
22
+ // when the method is unknown, the one handler every method the routes serve would pick, with no tie for any of them,
23
+ // else { ambiguous: the routes it could be }
24
+ function byMethod(routes, method, best) {
25
+ const serving = m => routes.filter(r => r.method === '*' || r.method === m);
26
+ if (method) { const c = best(serving(method)); return { route: c.length === 1 ? c[0] : null }; }
27
+ const picks = [...new Set(routes.map(r => r.method))].map(m => best(serving(m))), all = picks.flat();
28
+ if (picks.every(c => c.length <= 1) && new Set(all.map(r => r.id)).size <= 1) return { route: all[0] || null };
29
+ return { route: null, ambiguous: [...new Set(all.map(r => `${r.method} ${r.path}`))].sort() };
30
+ }
31
+
32
+ export function matchRoute(routes, method, path, { suffix = false } = {}) {
33
+ const cs = segsOf(path);
34
+ const result = (r, match) => r.route ? { route: r.route, match } : r.ambiguous ? { route: null, ambiguous: r.ambiguous } : null;
35
+ const exact = byMethod(routes, method, rs => rs.filter(r => fits(r.segs, cs)).sort((a, b) => literal(b.segs) - literal(a.segs)).slice(0, 1));
36
+ if (exact.route || exact.ambiguous || !suffix || !cs.some(s => s !== '{}')) return result(exact, 'exact');
37
+ // the tail match must be unambiguous: one route, or one with more literal segments in its tail than any other
38
+ const tailLit = r => literal(r.segs.slice(r.segs.length - cs.length));
39
+ const tailOf = rs => { const tail = rs.filter(r => r.segs.length > cs.length && fits(r.segs.slice(r.segs.length - cs.length), cs)).sort((a, b) => tailLit(b) - tailLit(a));
40
+ return tail.filter(r => tailLit(r) === tailLit(tail[0])); };
41
+ return result(byMethod(routes, method, tailOf), 'suffix');
42
+ }
43
+
44
+ export function routeTable(httpRoutes) {
45
+ return httpRoutes.map(e => ({ ...e, segs: segsOf(e.path.replace(/\{\*?(\w+)\.{0,3}\}/g, '{$1}')) }));
46
+ }
47
+
48
+ export function linkHttpCalls(ts, R, httpRoutes) {
49
+ const routes = routeTable(httpRoutes);
50
+ const calls = [];
51
+ const extOf = (name, sc) => /^[\w$]+$/.test(name) ? R.resolveName(name, sc)?.ext || null : null;
52
+ // an axios / ky instance: a variable made by axios.create({ baseURL }) or ky.create/extend({ prefixUrl })
53
+ const instanceOf = (name, c) => {
54
+ const spec = c.types?.get(name) || (() => { const r = R.resolveName(name, c); return r?.var ? r.var : null; })();
55
+ if (!spec || spec.t !== 'call') return null;
56
+ const m = /^([\w$]+)\.(create|extend)$/.exec(spec.expr);
57
+ const ext = m && extOf(m[1], spec.module ? spec : c);
58
+ if (!ext || !/^(axios|ky)$/.test(ext)) return null;
59
+ const cfg = spec.a0?.k === 'obj' ? spec.a0.pairs : {};
60
+ const baseIr = cfg.baseURL || cfg.prefixUrl || cfg.prefixURL;
61
+ const base = baseIr ? foldString(ts, R, baseIr, { module: spec.module || c.module, consts: spec.module === c.module ? c.consts : null }) : null;
62
+ return { client: ext, base };
63
+ };
64
+ // the method an options / config argument sets: GET when there is none or it sets none, null (any) when it cannot be known
65
+ const methodOf = (arg, sc) => {
66
+ if (!arg) return 'GET';
67
+ let o = arg, osc = sc;
68
+ if (arg.k === 'id') { const local = sc.consts?.get(arg.name), found = local ? null : constOf(ts, R, arg.name, sc.module);
69
+ o = local || found?.ir; if (found) osc = { module: found.module, consts: null }; }
70
+ if (o?.k !== 'obj') return null;
71
+ if (!o.pairs.method) return o.pairs['...'] ? null : 'GET';
72
+ const m = foldString(ts, R, o.pairs.method, osc).s;
73
+ return /^[a-z]+$/i.test(m) ? m.toUpperCase() : null;
74
+ };
75
+ for (const c of ts.rawCalls) {
76
+ if (c.jsx || c.isNew) continue;
77
+ let client = null, method = null, urlIr = null, base = null;
78
+ const a0 = c.args[0]?.ir, a1 = c.args[1]?.ir;
79
+ if (!c.attr && /^(fetch|window\.fetch|globalThis\.fetch)$/.test(c.expr) && !R.resolveName('fetch', c)?.id) { client = 'fetch'; urlIr = a0; method = methodOf(a1, c); }
80
+ else if (!c.attr && /^(axios|ky)$/.test(extOf(c.expr, c) || '')) {
81
+ client = extOf(c.expr, c);
82
+ if (a0?.k === 'obj') { urlIr = a0.pairs.url; method = methodOf(a0, c); } else { urlIr = a0; method = methodOf(a1, c); }
83
+ } else if (c.attr && c.obj && /^[\w$]+$/.test(c.obj)) {
84
+ const ext = extOf(c.obj, c), inst = ext ? null : instanceOf(c.obj, c);
85
+ const lib = /^(axios|ky)$/.test(ext || '') ? ext : inst?.client;
86
+ if (!lib) continue;
87
+ if (VERB.test(c.attr)) { client = lib; method = c.attr.toUpperCase(); urlIr = a0; }
88
+ else if (c.attr === 'request' && a0?.k === 'obj') { client = lib; method = methodOf(a0, c); urlIr = a0.pairs.url; }
89
+ else continue;
90
+ base = inst?.base || null;
91
+ } else continue;
92
+ if (!urlIr) continue;
93
+ const f = foldString(ts, R, urlIr, c);
94
+ let url = f.s.replace(/^[a-z][\w+.-]*:\/\/[^/]*/i, '');
95
+ let baseStr = base ? base.s.replace(/^[a-z][\w+.-]*:\/\/[^/]*/i, '') : '';
96
+ if (/^[a-z][\w+.-]*:\/\//i.test(f.s)) baseStr = ''; // an absolute URL ignores the instance's base
97
+ const unknownLead = url.startsWith('{}') || (!!base && baseStr.startsWith('{}'));
98
+ url = `${baseStr.replace(/^\{\}/, '').replace(/\/$/, '')}${baseStr && !url.startsWith('/') && !url.startsWith('{}') ? '/' : ''}${url.replace(/^\{\}/, '')}`;
99
+ if (!url.startsWith('/')) { if (!unknownLead) continue; url = '/' + url; }
100
+ const path = url.replace(/\?.*$/, '').replace(/#.*$/, '');
101
+ const m = matchRoute(routes, method, path, { suffix: unknownLead }), hit = m?.route ? m : null;
102
+ const folded = [...new Set([...f.acc.folded, ...(base?.acc.folded || [])])], env = [...new Set([...f.acc.env, ...(base?.acc.env || [])])];
103
+ const group = ts.modules.get(c.module)?.group;
104
+ calls.push({ from: c.from, file: c.file, line: c.line, client, method, url: path, handler: hit?.route.id || null, group,
105
+ ...(m?.ambiguous ? { unlinked: `the method is unknown and the path is served by ${m.ambiguous.join(', ')}` } : {}),
106
+ ...(hit && hit.match !== 'exact' ? { match: hit.match } : {}), ...(folded.length ? { folded } : {}), ...(env.length ? { env } : {}) });
107
+ if (hit) addTsEdge(ts, c.from, hit.route.id, 'http', c.line, { rule: 'js.http-client', group, evidence: `${c.file}:${c.line} ${method || '*'} ${path}${folded.length ? ` (folded ${folded.join(', ')})` : ''}${hit.match === 'suffix' ? ` (tail match: ${hit.route.method} ${hit.route.path})` : ''}`,
108
+ ...(hit.match !== 'exact' ? { match: hit.match } : {}) });
109
+ }
110
+ return calls;
111
+ }
@@ -0,0 +1,53 @@
1
+ /* Rule pack: Next.js, read from the file layout of an app that depends on `next` (a package.json naming it, or a
2
+ next.config file beside one). App Router: a `page.*` file under `app/` is a `ui-route` at its default export,
3
+ and a `route.*` file an `http` entry per exported GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS. Pages Router:
4
+ a file under `pages/api/` is an `http` entry (any method) at its default export, every other page a `ui-route`. Route groups
5
+ `(x)` and slots `@x` add no segment, `_x` folders are private; `[id]` is `:id` in a UI path and `{id}` in an
6
+ HTTP one; `[...x]` and `[[...x]]` catch the rest. `src/app` and `src/pages` count as well. */
7
+ import { posix } from 'node:path';
8
+
9
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
10
+ const SPECIAL_PAGE = /^_(app|document|error)$/;
11
+
12
+ export function nextRoots(packageJsons, files) {
13
+ const roots = new Set();
14
+ for (const { dir, json } of packageJsons) if (json && ['dependencies', 'devDependencies', 'peerDependencies'].some(k => json[k]?.next)) roots.add(dir);
15
+ for (const f of files) if (/(^|\/)next\.config\.(js|mjs|cjs|ts|mts)$/.test(f)) roots.add(posix.dirname(f) === '.' ? '' : posix.dirname(f));
16
+ return [...roots].sort();
17
+ }
18
+
19
+ export function nextEntries(ts, R, roots) {
20
+ const entries = [];
21
+ const segs = (parts, http) => {
22
+ const out = [];
23
+ for (const s of parts) {
24
+ if (/^\(.*\)$/.test(s) || s.startsWith('@')) continue;
25
+ const m = /^\[\[?\.\.\.(\w+)\]\]?$/.exec(s) || /^\[(\w+)\]$/.exec(s);
26
+ out.push(m ? (s.includes('...') ? (http ? `{${m[1]}}` : '*') : http ? `{${m[1]}}` : `:${m[1]}`) : s);
27
+ }
28
+ return '/' + out.join('/');
29
+ };
30
+ for (const root of roots) {
31
+ const at = d => root ? `${root}/${d}` : d;
32
+ for (const [rel] of ts.modules) {
33
+ for (const base of [at('app'), at('src/app')]) {
34
+ if (!rel.startsWith(base + '/')) continue;
35
+ const parts = rel.slice(base.length + 1).split('/'), file = parts.pop(), name = file.replace(/\.[^.]+$/, '');
36
+ if (parts.some(p => p.startsWith('_'))) continue;
37
+ if (name === 'page') { const r = R.exportOf(rel, 'default'); if (r?.id) entries.push({ kind: 'ui-route', path: segs(parts, false), id: r.id, file: rel, line: ts.symbols.get(r.id)?.start, rule: 'nextjs.app' }); }
38
+ if (name === 'route') for (const m of HTTP_METHODS) { const r = R.exportOf(rel, m); if (r?.id) entries.push({ kind: 'http', method: m, path: segs(parts, true), id: r.id, file: rel, line: ts.symbols.get(r.id)?.start, rule: 'nextjs.route' }); }
39
+ }
40
+ for (const base of [at('pages'), at('src/pages')]) {
41
+ if (!rel.startsWith(base + '/')) continue;
42
+ const parts = rel.slice(base.length + 1).split('/'), file = parts.pop(), name = file.replace(/\.[^.]+$/, '');
43
+ if (SPECIAL_PAGE.test(name) || parts.some(p => p.startsWith('_'))) continue;
44
+ const all = name === 'index' ? parts : [...parts, name];
45
+ const r = R.exportOf(rel, 'default');
46
+ if (!r?.id) continue;
47
+ if (all[0] === 'api') entries.push({ kind: 'http', method: '*', path: segs(all, true), id: r.id, file: rel, line: ts.symbols.get(r.id)?.start, rule: 'nextjs.api' });
48
+ else entries.push({ kind: 'ui-route', path: segs(all, false), id: r.id, file: rel, line: ts.symbols.get(r.id)?.start, rule: 'nextjs.pages' });
49
+ }
50
+ }
51
+ }
52
+ return entries;
53
+ }
@@ -0,0 +1,49 @@
1
+ /* Rule pack: process spawn. `subprocess.Popen/run/call/check_*([...])` or `asyncio.create_subprocess_exec(...)`
2
+ with a literal argv (inline, or a local list) that names an in-repo app starts that app's entries: a `spawn`
3
+ crossing from the spawning function to them.
4
+ uvicorn mod:app -> the entries defined in the app's module (its lifespan)
5
+ taskiq worker mod:broker -> every queue consumer celery -A mod worker -> every queue consumer
6
+ taskiq scheduler mod:x -> every scheduled task celery -A mod beat -> every scheduled task
7
+ python -m pkg -> pkg's `__main__` entry
8
+ The app module is looked up as the spawned process would find it from the spawning file: its directory, then each
9
+ parent, then as an absolute import from that file (the repo root, then the package roots). */
10
+ import { dirname } from 'node:path';
11
+ import { addEdge } from '../lang/python.mjs';
12
+
13
+ const SUBPROCESS = /^(Popen|run|call|check_call|check_output)$/;
14
+ // `subprocess.run(…)`, `asyncio.create_subprocess_exec(…)`, or the bare name imported from either module
15
+ const isSpawn = c => c.attr ? (c.obj === 'subprocess' && SUBPROCESS.test(c.attr)) || (c.obj === 'asyncio' && c.attr === 'create_subprocess_exec')
16
+ : (c.imports.get(c.expr)?.from === 'subprocess' && SUBPROCESS.test(c.expr)) || (c.imports.get(c.expr)?.from === 'asyncio' && c.expr === 'create_subprocess_exec');
17
+
18
+ function argvOf(py, c) {
19
+ const items = /create_subprocess_exec$/.test(c.expr) ? c.args.filter(a => a.type !== 'keyword_argument')
20
+ : c.args[0]?.items ?? (c.args[0]?.type === 'identifier' ? py.fnLists.get(c.from)?.[c.args[0].text] : null);
21
+ return items ? items.map(a => (a.type === 'string' ? a.s : null)) : null;
22
+ }
23
+
24
+ export function linkSpawns(py, R, entries) {
25
+ const spawns = [];
26
+ const appModule = spec => spec && spec.includes(':') ? spec.split(':')[0] : spec;
27
+ const near = (c, name) => { const dirs = dirname(c.file) === '.' ? [] : dirname(c.file).split('/');
28
+ return [...dirs.map((_, i) => [...dirs.slice(0, dirs.length - i), name].join('.')), R.findModule(name, c.module)].filter(Boolean); };
29
+ for (const c of py.rawCalls) {
30
+ if (!isSpawn(c)) continue;
31
+ const argv = argvOf(py, c);
32
+ if (!argv) continue;
33
+ const at = w => argv.indexOf(w);
34
+ let targets = [], what = null;
35
+ if (at('uvicorn') !== -1) { const spec = appModule(argv[at('uvicorn') + 1]); what = `uvicorn ${argv[at('uvicorn') + 1]}`;
36
+ const mod = spec && near(c, spec).find(m => py.modules.has(m));
37
+ targets = mod ? entries.filter(e => e.file && py.modules.get(mod).file === e.file) : []; }
38
+ else if (at('taskiq') !== -1 && argv[at('taskiq') + 1] === 'worker') { what = `taskiq worker ${argv[at('taskiq') + 2]}`; targets = entries.filter(e => e.kind === 'queue-consumer'); }
39
+ else if (at('taskiq') !== -1 && argv[at('taskiq') + 1] === 'scheduler') { what = `taskiq scheduler ${argv[at('taskiq') + 2]}`; targets = entries.filter(e => e.kind === 'scheduled'); }
40
+ else if (at('celery') !== -1) { const beat = argv.includes('beat'); what = `celery ${beat ? 'beat' : 'worker'}`; targets = entries.filter(e => e.kind === (beat ? 'scheduled' : 'queue-consumer')); }
41
+ else if (at('-m') !== -1 && argv[at('-m') + 1]) { what = `python -m ${argv[at('-m') + 1]}`;
42
+ const mod = near(c, argv[at('-m') + 1]).find(m => py.modules.has(`${m}.__main__`) || py.modules.has(m));
43
+ targets = mod ? entries.filter(e => e.kind === 'main' && (e.id === `${mod}.__main__:<main>` || e.id === `${mod}:<main>`)) : []; }
44
+ if (!what) continue;
45
+ spawns.push({ from: c.from, file: c.file, line: c.line, argv: what, targets: targets.map(e => e.id) });
46
+ for (const e of targets) addEdge(py, c.from, e.id, 'spawn', c.line, { rule: 'process-spawn', evidence: `${c.file}:${c.line} spawns ${what}` });
47
+ }
48
+ return spawns;
49
+ }
@@ -0,0 +1,26 @@
1
+ /* Rule pack: Python HTTP clients (httpx / requests / aiohttp / a test client). `<client>.get/post/…(url)` where url
2
+ is a string or f-string literal, or a local bound to one: a leading `{base}` and any scheme://host are dropped,
3
+ then the path is matched against the mined route table. A match is an `http` crossing to the route handler. */
4
+ import { addEdge } from '../lang/python.mjs';
5
+
6
+ export const routeRe = e => new RegExp('^' + e.path.replace(/\{[^}]+\}/g, '[^/]+') + '$');
7
+
8
+ export function linkHttpCalls(py, entries) {
9
+ const routes = entries.filter(e => e.kind === 'http').map(e => ({ e, re: routeRe(e) }));
10
+ const calls = [];
11
+ for (const c of py.rawCalls) {
12
+ const a0 = c.args[0];
13
+ if (!c.attr || !/^(get|post|put|patch|delete)$/.test(c.attr) || !a0) continue;
14
+ const lit = a0.type === 'string' ? a0.text : a0.type === 'identifier' ? py.fnAlias.get(c.from)?.[a0.text] : null;
15
+ if (!lit || !/^[rbfRBF]*["']/.test(lit)) continue;
16
+ const url = lit.replace(/^[rbfRBF]*("""|'''|"|')/, '').replace(/("""|'''|"|')$/, '').replace(/\{[^}]*\}/g, '{}').replace(/^\{\}/, '').replace(/^https?:\/\/[^/]+/, '');
17
+ if (!url.startsWith('/')) continue;
18
+ const path = url.replace(/\?.*$/, '').replace(/\{\}/g, 'X');
19
+ const method = c.attr.toUpperCase();
20
+ const hit = routes.find(r => r.e.method === method && r.re.test(path))?.e;
21
+ const group = py.modules.get(c.module)?.group;
22
+ calls.push({ from: c.from, file: c.file, line: c.line, method, url, handler: hit?.id || null, group });
23
+ if (hit) addEdge(py, c.from, hit.id, 'http', c.line, { rule: 'py.http-client', group, evidence: `${c.file}:${c.line} ${method} ${url}` });
24
+ }
25
+ return calls;
26
+ }
@@ -0,0 +1,107 @@
1
+ /* Rule pack: pytest (and unittest-style test classes, and unittest.mock targets).
2
+ - tests: `test*` at module level, or `test*` methods of a `Test*` / `TestCase` class, in a test-group file;
3
+ - fixtures: `@pytest.fixture` / `@pytest_asyncio.fixture`; a test-side parameter named like a visible fixture (its
4
+ class, its module, then conftest.py in its directory and each parent) is bound to it, `autouse=True` binds to
5
+ every test in scope, and the fixture's returned or yielded class types the parameter;
6
+ - mock targets: `patch("a.b.c")` (decorator, context manager, `mocker.patch`), `patch.object(X, "m")`,
7
+ `monkeypatch.setattr(…)`, resolved the way `patch` looks them up. A patch started in `pytest_configure`,
8
+ `pytest_sessionstart` or at conftest module level is session-wide: it applies to every test under that conftest;
9
+ - direct test links: every edge from a test's own side (its body, fixtures, helpers, test class) into non-test
10
+ code, minus whatever that test mocks. */
11
+ import { dirname } from 'node:path';
12
+ import { addEdge } from '../lang/python.mjs';
13
+
14
+ const isConftest = f => /(^|\/)conftest\.py$/.test(f);
15
+ const inScope = (conftest, file) => { const d = dirname(conftest); return d === '.' || file.startsWith(d + '/'); };
16
+ const SESSION_HOOK = /:(pytest_configure|pytest_sessionstart|<module>)$/;
17
+
18
+ export function findTests(py, R) {
19
+ const isTestClass = k => /^Test/.test(k.name) || R.mro(k.id).some(c => py.classes.get(c)?.baseExprs.some(b => /(^|\.)(Async)?TestCase$/.test(b)));
20
+ const tests = [];
21
+ for (const s of py.symbols.values()) {
22
+ if (s.group !== 'test' || (s.kind !== 'function' && s.kind !== 'method') || s.id.includes('<locals>') || s.id.includes('<main>') || !/^test/.test(s.name)) continue;
23
+ if (s.cls && !isTestClass(py.classes.get(s.cls))) continue;
24
+ tests.push({ id: s.id, file: s.file, line: s.start, end: s.end, asserts: s.asserts, assertLines: s.assertLines });
25
+ }
26
+ return tests;
27
+ }
28
+
29
+ export function findFixtures(py) {
30
+ return [...py.symbols.values()].filter(s => (s.kind === 'function' || s.kind === 'method') && s.decos.some(d => /(^|\.)fixture(\(|$)/.test(d)))
31
+ .map(s => ({ id: s.id, name: s.name, file: s.file, line: s.start, cls: s.cls || null, autouse: s.decos.some(d => /autouse\s*=\s*True/.test(d)) }));
32
+ }
33
+
34
+ // before call resolution: a bound parameter is typed by its fixture's returns
35
+ export function bindFixtures(py, tests, fixtures) {
36
+ const bindings = [];
37
+ const typesOf = new Map(); for (const c of py.rawCalls) if (!typesOf.has(c.from)) typesOf.set(c.from, c.types);
38
+ const fixtureFor = (s, p) => {
39
+ const cands = fixtures.filter(f => f.name === p && f.id !== s.id);
40
+ return cands.find(f => f.cls && f.cls === s.cls) || cands.find(f => !f.cls && f.file === s.file)
41
+ || cands.filter(f => !f.cls && isConftest(f.file) && inScope(f.file, s.file)).sort((a, b) => b.file.length - a.file.length)[0] || null;
42
+ };
43
+ for (const s of py.symbols.values()) if (s.group === 'test' && s.params && (s.kind === 'function' || s.kind === 'method')) {
44
+ for (const p of s.params) { if (p === 'self' || p === 'cls') continue; const f = fixtureFor(s, p); if (!f) continue;
45
+ bindings.push({ fn: s.id, param: p, fixture: f.id });
46
+ const t = typesOf.get(s.id); if (t && !t.has(p)) t.set(p, { t: 'returns', fn: f.id }); }
47
+ }
48
+ for (const t of tests) for (const f of fixtures.filter(x => x.autouse))
49
+ if (f.file === t.file || (isConftest(f.file) && inScope(f.file, t.file))) bindings.push({ fn: t.id, param: null, fixture: f.id, autouse: true });
50
+ return bindings;
51
+ }
52
+
53
+ export function linkFixtures(py, bindings) {
54
+ const s = id => py.symbols.get(id);
55
+ for (const b of bindings) addEdge(py, b.fn, b.fixture, 'framework', s(b.fn)?.start, { rule: b.autouse ? 'pytest.fixture.autouse' : 'pytest.fixture',
56
+ evidence: `${s(b.fn)?.file}: parameter ${b.param ?? '(autouse)'} -> fixture ${b.fixture.split(':').pop()} (${s(b.fixture)?.file}:${s(b.fixture)?.start})` });
57
+ }
58
+
59
+ export function mockTargets(py, R) {
60
+ const resolveDotted = (raw, from) => { const parts = raw.split('.');
61
+ for (let i = parts.length - 1; i >= 1; i--) { const mod = R.findModule(parts.slice(0, i).join('.'), from); if (!py.modules.has(mod)) continue;
62
+ const rest = parts.slice(i), sc = { imports: py.modules.get(mod).imports, module: mod };
63
+ const r = R.resolveName(rest[0], sc);
64
+ if (rest.length === 1) return r?.id || null;
65
+ if (rest.length === 2 && r?.id && py.classes.has(r.id)) return R.lookup(r.id, rest[1]);
66
+ return null; }
67
+ return null; };
68
+ const fileOf = fn => py.symbols.get(fn)?.file || py.modules.get(fn.split(':')[0])?.file || '';
69
+ const scoped = m => { const file = fileOf(m.fn); return isConftest(file) && SESSION_HOOK.test(m.fn) ? { ...m, scope: 'session', conftest: file } : m; };
70
+ const mocks = [];
71
+ for (const d of py.decorated) if (/(^|\.)patch$/.test(d.expr) && d.strArgs[0] && /\./.test(d.strArgs[0]))
72
+ mocks.push({ fn: d.id, raw: d.strArgs[0], target: resolveDotted(d.strArgs[0], d.module), line: d.line, how: '@patch' });
73
+ for (const c of py.rawCalls) {
74
+ const a0 = c.args[0], s0 = a0?.type === 'string' ? a0.s : null, s1 = c.args[1]?.type === 'string' ? c.args[1].s : null;
75
+ if ((/(^|\.)patch$/.test(c.expr) || /(^|\.)monkeypatch\.setattr$/.test(c.expr)) && s0 && /\./.test(s0))
76
+ mocks.push(scoped({ fn: c.from, raw: s0, target: resolveDotted(s0, c.module), line: c.line, how: c.expr.split('.').pop() }));
77
+ else if ((/(^|\.)patch\.object$/.test(c.expr) || /(^|\.)monkeypatch\.setattr$/.test(c.expr)) && a0 && s1) {
78
+ let k = null; const r = R.resolveExpr(a0.text, c); if (r?.id && py.classes.has(r.id)) k = r.id;
79
+ else if (c.types?.has(a0.text)) { const rc = R.receiverOf(c.types.get(a0.text), c); if (rc) k = rc.cls[0]; }
80
+ mocks.push(scoped({ fn: c.from, raw: `${a0.text}.${s1}`, target: k ? R.lookup(k, s1) : null, line: c.line, how: 'patch.object' }));
81
+ }
82
+ }
83
+ return mocks;
84
+ }
85
+
86
+ const FOLLOW = new Set(['static', 'ctor', 'framework', 'dispatch', 'queue', 'http', 'virtual']);
87
+ export function directLinks(py, tests, fixtures, mocks) {
88
+ const out = new Map(); for (const e of py.edges) (out.get(e.from) || out.set(e.from, []).get(e.from)).push(e);
89
+ const G = id => py.symbols.get(id)?.group || (id.startsWith('ext:') ? 'ext' : 'prod');
90
+ const fixtureIds = new Set(fixtures.map(f => f.id));
91
+ const mocksIn = new Map(); for (const m of mocks) if (m.target && !m.scope) (mocksIn.get(m.fn) || mocksIn.set(m.fn, new Set()).get(m.fn)).add(m.target);
92
+ const session = mocks.filter(m => m.target && m.scope === 'session');
93
+ for (const t of tests) {
94
+ const side = new Set([t.id]), q = [t.id];
95
+ while (q.length) { const x = q.pop(); for (const e of out.get(x) || []) if (FOLLOW.has(e.kind) && G(e.to) === 'test' && !side.has(e.to)) { side.add(e.to); q.push(e.to); } }
96
+ const tcls = py.symbols.get(t.id)?.cls; if (tcls) side.add(tcls);
97
+ const mocked = new Set(session.filter(m => inScope(m.conftest, t.file)).map(m => m.target));
98
+ for (const s of side) for (const m of mocksIn.get(s) || []) mocked.add(m);
99
+ const links = new Map();
100
+ for (const s of side) for (const e of out.get(s) || []) {
101
+ if (!FOLLOW.has(e.kind) || G(e.to) === 'test' || e.to.startsWith('ext:') || mocked.has(e.to) || py.symbols.get(e.to)?.kind === 'class' || links.has(e.to)) continue;
102
+ links.set(e.to, { to: e.to, via: s === t.id ? 'body' : fixtureIds.has(s) ? 'fixture' : 'helper', viaId: s, kind: e.kind, ...(e.rule ? { rule: e.rule } : {}), line: e.line, ...(e.recvClass ? { recvClass: e.recvClass } : {}) });
103
+ }
104
+ t.mocked = [...mocked].sort();
105
+ t.direct = [...links.values()];
106
+ }
107
+ }
@@ -0,0 +1,17 @@
1
+ /* Rule pack: React Router, both styles. Route objects in an array (`createBrowserRouter([...])`, `useRoutes`, or any
2
+ route table in a file that imports react-router) with `path` or `index` and an `element`, `Component`,
3
+ `component` or `lazy: () => import(…)`; and `<Route path element={<X/>}>` elements, nested `<Route>`s and
4
+ `children` arrays joining their parents' paths. Each one is a `ui-route` entry at the component it renders (a
5
+ `lazy(() => import('./X'))` component is that module's default export). */
6
+ export function uiRoutes(ts, R) {
7
+ const entries = [];
8
+ for (const d of ts.routeDefs) {
9
+ let r = null;
10
+ if (d.lazy) { const mod = R.resolveModule(d.lazy, d.file); r = mod ? R.exportOf(mod, 'default') : null; }
11
+ else r = R.resolveExpr(d.comp, { module: d.file, locals: null });
12
+ const s = r?.id && ts.symbols.get(r.id);
13
+ if (!s || s.kind === 'main') continue;
14
+ entries.push({ kind: 'ui-route', path: d.path, id: r.id, file: d.file, line: d.line, rule: d.rule, ...(d.lazy ? { lazy: d.lazy } : {}) });
15
+ }
16
+ return entries;
17
+ }
@@ -0,0 +1,31 @@
1
+ /* Rule pack: registry dispatch. A `{TypeName: "key"}` table read by a dispatcher whose task name is dynamic routes
2
+ each message type to the consumer keyed "key". A function that constructs TypeName links to that consumer only
3
+ when it (or a caller up to 3 levels) reaches a dispatcher within 3 calls, i.e. the message really is enqueued.
4
+ The link carries the producer's group: a test that builds a job and reaches the (mocked) dispatcher is test
5
+ evidence ("this test enqueues X"), never a production crossing. */
6
+ import { addEdge } from '../lang/python.mjs';
7
+
8
+ export function linkRegistry(py, dispatchers, consumerByKey) {
9
+ const fwd = new Map(), rev = new Map();
10
+ for (const e of py.edges) if (!e.rule && /^(static|virtual|dispatch|ctor)$/.test(e.kind)) {
11
+ (fwd.get(e.from) || fwd.set(e.from, new Set()).get(e.from)).add(e.to);
12
+ (rev.get(e.to) || rev.set(e.to, new Set()).get(e.to)).add(e.from); }
13
+ const reaches = (start, targets, depth) => { let frontier = [start]; const seen = new Set(frontier);
14
+ for (let d = 0; d < depth; d++) { const nx = []; for (const x of frontier) for (const y of fwd.get(x) || []) { if (targets.has(y)) return true; if (!seen.has(y)) { seen.add(y); nx.push(y); } } frontier = nx; } return false; };
15
+ const ancestors = (f, depth) => { const out = new Set([f]); let fr = [f];
16
+ for (let d = 0; d < depth; d++) { const nx = []; for (const x of fr) for (const y of rev.get(x) || []) if (!out.has(y)) { out.add(y); nx.push(y); } fr = nx; } return out; };
17
+ const classByName = new Map([...py.classes.values()].filter(k => k.id === `${k.module}:${k.name}`).map(k => [k.name, k.id]));
18
+ const groupOf = id => py.symbols.get(id)?.group || py.modules.get(id.split(':')[0])?.group || 'prod';
19
+ const keys = [];
20
+ for (const p of py.dictPairs) {
21
+ const consumer = consumerByKey.get(p.value), cls = classByName.get(p.key);
22
+ if (!consumer || !cls) continue;
23
+ const all = [...new Set(py.edges.filter(e => e.kind === 'ctor' && e.to === cls).map(e => e.from))];
24
+ const kept = all.filter(f => [...ancestors(f, 3)].some(a => reaches(a, dispatchers, 3)));
25
+ keys.push({ key: p.value, messageType: cls, consumer, evidence: `${p.file}:${p.line}`, candidates: all.length, kept: kept.length,
26
+ dropped: all.filter(f => !kept.includes(f)) });
27
+ for (const f of kept) addEdge(py, f, consumer, 'queue', null, { rule: 'registry', group: groupOf(f),
28
+ evidence: `constructs ${p.key} and reaches a dispatcher; ${p.file}:${p.line} maps it to "${p.value}"` });
29
+ }
30
+ return keys;
31
+ }
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ # fmtrace-sh — trace.mjs's recipe shell for `make` (SHELL=<this>): each recipe line runs, with the .SHELLFLAGS make
3
+ # hands it, under the shell the Makefile itself names ($FM_TRACE_SHELL, else /bin/sh), with the coverage counters it
4
+ # writes routed below a dir of its own (GCOV_PREFIX), so each test binary a line runs is measured on its own. Beside
5
+ # them: the line, the dir it runs in (a sub-make's own), its exit status and a copy of its output (stdout and stderr
6
+ # still go out apart, so a $(shell …) reads only stdout). $FM_TRACE_UNITS/order lists the dirs in the order the lines
7
+ # ran.
8
+ u=$(mktemp -d "$FM_TRACE_UNITS/line-XXXXXX") || exit 70
9
+ echo "$u" >> "$FM_TRACE_UNITS/order"
10
+ for a in "$@"; do line=$a; done
11
+ printf '%s' "$line" > "$u/command"
12
+ pwd -P > "$u/cwd"
13
+ { { { GCOV_PREFIX=$u/gcda ${FM_TRACE_SHELL:-/bin/sh} "$@"; echo $? > "$u/status"; } | tee -a "$u/output"; } 2>&1 1>&3 | tee -a "$u/output" 1>&2; } 3>&1
14
+ exit "$(cat "$u/status")"
@@ -0,0 +1,33 @@
1
+ // fmtrace-net.cjs — loaded with `node --require` into every Node process of a traced JavaScript / TypeScript test run.
2
+ // It records each outbound connect and DNS lookup the run attempts (the sandbox denies them all), so a test that failed
3
+ // for want of the network is listed as denied by the sandbox. It changes nothing the code sees: every call goes on to
4
+ // the real function. Written as it happens to FM_TRACE_OUT (a worker thread its runner terminates never reaches its
5
+ // exit), tagged with the test file this process ran (FM_TRACE_FILE).
6
+ 'use strict';
7
+ const net = require('node:net');
8
+ const dns = require('node:dns');
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const { threadId } = require('node:worker_threads');
12
+
13
+ const out = process.env.FM_TRACE_OUT;
14
+ const seen = new Set();
15
+ const note = s => {
16
+ if (seen.has(s) || seen.size >= 50) return;
17
+ seen.add(s);
18
+ if (out) try { fs.writeFileSync(path.join(out, `net-${process.pid}-${threadId}-${String(seen.size).padStart(2, '0')}.json`), JSON.stringify({ file: process.env.FM_TRACE_FILE || null, attempts: [s] })); } catch {}
19
+ };
20
+
21
+ const connect = net.Socket.prototype.connect;
22
+ net.Socket.prototype.connect = function (...args) {
23
+ try {
24
+ const o = Array.isArray(args[0]) ? args[0][0] : args[0];
25
+ if (o && typeof o === 'object') note(o.path ? `connect ${o.path}` : `connect ${o.host || 'localhost'}:${o.port}`);
26
+ else if (typeof o === 'string' && isNaN(Number(o))) note(`connect ${o}`);
27
+ else note(`connect ${typeof args[1] === 'string' ? args[1] : 'localhost'}:${o}`);
28
+ } catch {}
29
+ return connect.apply(this, args);
30
+ };
31
+ const lookup = dns.lookup;
32
+ dns.lookup = function (host, ...rest) { note(`getaddrinfo ${host}`); return lookup.call(this, host, ...rest); };
33
+
@@ -0,0 +1,41 @@
1
+ // fmtrace-srcmap.cjs — loaded with `node --require` into every Node process and worker thread of a traced JavaScript /
2
+ // TypeScript test run. jest and vitest compile each file they transform through node:vm (jest: new vm.Script, vitest:
3
+ // vm.runInThisContext) with an inline source map, and Node's coverage dump records no source map for those scripts.
4
+ // So each one's map is recorded here, in the shape of Node's own source-map-cache entry (the compiled code's line
5
+ // lengths and the map's `mappings`), keyed by the script's file. It changes nothing the code sees: every call goes on
6
+ // to the real function with the same arguments. Written as it happens to FM_TRACE_OUT, tagged with the test file this
7
+ // process ran (FM_TRACE_FILE).
8
+ 'use strict';
9
+ const vm = require('node:vm');
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const { fileURLToPath } = require('node:url');
13
+ const { threadId } = require('node:worker_threads');
14
+
15
+ const out = process.env.FM_TRACE_OUT;
16
+ const INLINE = /\/\/# sourceMappingURL=data:application\/json[^,\n]*;base64,([A-Za-z0-9+/=]+)/y;
17
+ let n = 0;
18
+ const note = (code, options) => {
19
+ try {
20
+ const file = typeof options === 'string' ? options : options?.filename;
21
+ if (!out || typeof code !== 'string' || typeof file !== 'string' || file.includes('/node_modules/')) return;
22
+ // the last inline map: the runner's own wrapper may close after it
23
+ const at = code.lastIndexOf('//# sourceMappingURL=data:application/json');
24
+ if (at < 0) return;
25
+ INLINE.lastIndex = at;
26
+ const b64 = INLINE.exec(code)?.[1];
27
+ if (!b64) return;
28
+ const { mappings } = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
29
+ if (typeof mappings !== 'string') return;
30
+ const key = file.startsWith('file://') ? fileURLToPath(file) : file;
31
+ fs.writeFileSync(path.join(out, `srcmap-${process.pid}-${threadId}-${n++}.json`),
32
+ JSON.stringify({ file: process.env.FM_TRACE_FILE || null, url: key, lineLengths: code.split('\n').map(l => l.length), data: { mappings } }));
33
+ } catch {}
34
+ };
35
+
36
+ const RealScript = vm.Script;
37
+ vm.Script = class Script extends RealScript { constructor(code, options) { note(code, options); super(code, options); } };
38
+ for (const name of ['runInThisContext', 'runInContext', 'runInNewContext']) {
39
+ const real = vm[name], optionsAt = name === 'runInThisContext' ? 0 : 1;
40
+ vm[name] = function (code, ...rest) { note(code, rest[optionsAt]); return real.call(this, code, ...rest); };
41
+ }
@@ -0,0 +1,87 @@
1
+ """trace.mjs's pytest plugin: tells the collector which test is running, so a call made by a framework (a fixture, a DI
2
+ provider, a route reached through a test client thread) is still attributed to the test it ran under; and records each
3
+ test's outcome per phase, with whether the sandbox denied it (a permission error, or a hidden path not found). Loaded
4
+ with PYTEST_PLUGINS=fmtrace_plugin.
5
+ It records only the repo's own tests: those of the first pytest config in a process, in files inside the scratch copy.
6
+ A pytest run inside a test (pytester, in process or in a child process, which inherits PYTEST_PLUGINS) runs tests
7
+ from its own tmp dir, so it records no outcome and moves no current test; the collector still records its calls. An
8
+ xdist worker runs the copy's tests, and records them."""
9
+ import os
10
+
11
+ import pytest
12
+ import sitecustomize as fm
13
+
14
+ _CONFIG = [None]
15
+
16
+
17
+ def pytest_configure(config):
18
+ if _CONFIG[0] is None:
19
+ _CONFIG[0] = config
20
+
21
+
22
+ def _path(node):
23
+ return os.path.realpath(str(getattr(node, "path", None) or node.fspath))
24
+
25
+
26
+ def _in_copy(path, root):
27
+ return (path + os.sep).startswith(root)
28
+
29
+
30
+ def _ours(config, node):
31
+ path = _path(node)
32
+ return config is _CONFIG[0] and any(_in_copy(path, r) for r in fm.ROOTS)
33
+
34
+
35
+ def _key(item):
36
+ # the test's file relative to the repo root, then the rest of its node id: independent of pytest's rootdir. An
37
+ # item a plugin parents at the session (its path is the root) is `::<name>`
38
+ path = _path(item)
39
+ rel = next((path[len(r):] for r in fm.ROOTS if _in_copy(path, r)), "")
40
+ rest = item.nodeid.split("::", 1)[1] if "::" in item.nodeid else ""
41
+ return rel + "::" + rest
42
+
43
+
44
+ def _last_line(longrepr):
45
+ crash = getattr(longrepr, "reprcrash", None)
46
+ lines = (crash.message if crash else str(longrepr or "")).strip().splitlines()
47
+ return lines[-1][:200] if lines else ""
48
+
49
+
50
+ @pytest.hookimpl(hookwrapper=True)
51
+ def pytest_runtest_protocol(item, nextitem):
52
+ if not _ours(item.config, item):
53
+ yield
54
+ return
55
+ fm.CURRENT[0] = _key(item)
56
+ fm.OUTCOMES.setdefault(fm.CURRENT[0], {"nodeid": item.nodeid})
57
+ yield
58
+ fm.CURRENT[0] = None
59
+
60
+
61
+ @pytest.hookimpl(hookwrapper=True)
62
+ def pytest_runtest_makereport(item, call):
63
+ outcome = yield
64
+ if not _ours(item.config, item):
65
+ return
66
+ rep = outcome.get_result()
67
+ rec = fm.OUTCOMES.setdefault(_key(item), {"nodeid": item.nodeid})
68
+ rec[rep.when] = rep.outcome
69
+ if getattr(rep, "wasxfail", None) is not None:
70
+ rec["xfail"] = True
71
+ if rep.failed:
72
+ rec.setdefault("message", _last_line(rep.longrepr))
73
+ if call.excinfo is not None and fm.denied(call.excinfo.value):
74
+ rec["permission"] = True
75
+
76
+
77
+ @pytest.hookimpl(hookwrapper=True)
78
+ def pytest_make_collect_report(collector):
79
+ outcome = yield
80
+ report = outcome.get_result()
81
+ if _ours(collector.config, collector) and report.failed:
82
+ fm.COLLECT_ERRORS.append({"nodeid": report.nodeid, "message": _last_line(report.longrepr)})
83
+
84
+
85
+ def pytest_collection_finish(session):
86
+ if session.config is _CONFIG[0]:
87
+ fm.COLLECTED[0] = len([i for i in session.items if _ours(session.config, i)])