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,116 @@
1
+ /* codemap.md: the human-readable code map. Every group, every entry, every rule link with its evidence, the test
2
+ inventory with its session-wide and autouse mocks, config facts and profiles, and the self-check. Pytest
3
+ fixture links are listed per fixture (each individual link is in codemap.json). */
4
+ const short = id => id.split(':').pop();
5
+ const loc = (file, line) => line ? `${file}:${line}` : file;
6
+ const table = (head, rows) => rows.length ? [`| ${head.join(' | ')} |`, `|${head.map(() => '---').join('|')}|`, ...rows.map(r => `| ${r.map(c => String(c ?? '').replace(/\|/g, '\\|')).join(' | ')} |`)].join('\n') : '_none_';
7
+
8
+ export function renderCodemap(m) {
9
+ const G = ['prod', 'eval', 'dev', 'test'];
10
+ const sym = new Map(m.symbols.map(s => [s.id, s]));
11
+ const fileOf = id => sym.get(id)?.file || id.split(':')[0];
12
+ const out = [];
13
+ const ts = m.testSummary, sc = m.selfCheck;
14
+ out.push(`# Code map: ${m.root.split('/').pop()}`, '',
15
+ `- root: \`${m.root}\`${m.commit ? ` at \`${m.commit.slice(0, 7)}\`` : ''} · tree ${m.treeHash.slice(0, 19)}…`,
16
+ `- tools: ${Object.entries(m.tool.versions).map(([k, v]) => `${k}@${v}`).join(', ')}`,
17
+ `- ${m.files.length} files: ${m.languages.python.files} Python (${m.languages.python.parseErrors} with syntax errors)` +
18
+ (m.languages.typescript ? `, ${m.languages.typescript.files} TypeScript (${m.languages.typescript.parseErrors} with syntax errors), ${m.languages.javascript.files} JavaScript (${m.languages.javascript.parseErrors} with syntax errors)` : '') +
19
+ [['go', 'Go'], ['c', 'C'], ['cpp', 'C++']].filter(([k]) => m.languages[k]).map(([k, n]) => `, ${m.languages[k].files} ${n} (${m.languages[k].parseErrors} with syntax errors)`).join('') +
20
+ `, ${m.unsupported.files} not mined yet ${JSON.stringify(m.unsupported.byExt)}`,
21
+ `- ${m.symbols.length} symbols · ${m.edges.length} edges · ${m.entries.length} entries · ${ts.tests} tests`, '');
22
+
23
+ out.push('## Self-check', '',
24
+ `- **${sc.ok ? 'ok' : 'FAILED'}**: ${sc.defsExtracted} of ${sc.defNodes} parsed definitions extracted (${sc.defLinesRaw} \`def\`/\`class\` lines by regex) · ${sc.defScopes} distinct definitions, ${sc.symbols} symbols`,
25
+ `- calls: ${sc.calls.resolved} resolved · unresolved by origin ${JSON.stringify(sc.unresolvedByOrigin)} · in-repo miss rate ${(sc.inRepoMissRate * 100).toFixed(1)}%`,
26
+ `- Python package roots below the repo root: ${sc.packageRoots.length ? sc.packageRoots.map(r => `\`${r}/\``).join(', ') : 'none'}`,
27
+ ...(sc.ambiguousImports.length ? [`- **${sc.ambiguousImports.reduce((n, x) => n + x.calls, 0)} calls are not linked: their import names a module several package roots claim and none holds the importing file**: ` +
28
+ sc.ambiguousImports.map(x => `\`${x.name}\` ${x.calls} (${x.roots.map(r => `\`${r}/\``).join(', ')})`).join(', ')] : []),
29
+ ...(sc.crossRootImports.length ? [`- ${sc.crossRootImports.reduce((n, x) => n + x.calls, 0)} calls resolve through a package root that does not hold the importing file (right only if that root is on its sys.path): ` +
30
+ sc.crossRootImports.map(x => `\`${x.name}\` → \`${x.root}/\` ${x.calls}`).join(', ')] : []),
31
+ ...(sc.externalLikeRepoDirs.calls ? [`- **${sc.externalLikeRepoDirs.calls} calls (${(sc.externalLikeRepoDirs.rate * 100).toFixed(1)}%) go to imports resolved as external although an in-repo directory with no \`__init__.py\` has their name**: ` +
32
+ sc.externalLikeRepoDirs.names.map(x => `\`${x.name}\` ${x.calls} (${x.dirs.map(d => `\`${d}/\``).join(', ')})`).join(', ') + ' — if these are in-repo namespace packages, these calls are not linked'] : []),
33
+ ...(sc.typescript ? [`- TypeScript / JavaScript: ${sc.typescript.defsExtracted} of ${sc.typescript.defNodes} parsed definitions extracted · ${sc.typescript.symbols} symbols · calls: ${sc.typescript.calls.resolved} resolved · unresolved by origin ${JSON.stringify(sc.typescript.unresolvedByOrigin)} · in-repo miss rate ${(sc.typescript.inRepoMissRate * 100).toFixed(1)}%`,
34
+ ...(sc.typescript.unresolvedImports.length ? [`- **${sc.typescript.unresolvedImports.length} relative imports name no file in the repo** (their calls are not linked): ` +
35
+ sc.typescript.unresolvedImports.slice(0, 20).map(x => `\`${x.spec}\` in \`${x.from}\``).join(', ') + (sc.typescript.unresolvedImports.length > 20 ? ', …' : '')] : [])] : []),
36
+ ...(sc.go ? [`- Go: ${sc.go.defsExtracted} of ${sc.go.defNodes} parsed definitions extracted · ${sc.go.symbols} symbols · modules ${sc.go.modules.map(x => `\`${x.module}\` (${x.dir}/)`).join(', ') || 'none (imports of in-repo packages cannot be told from dependencies)'} · calls: ${sc.go.calls.resolved} resolved · unresolved by origin ${JSON.stringify(sc.go.unresolvedByOrigin)} · in-repo miss rate ${(sc.go.inRepoMissRate * 100).toFixed(1)}%`] : []),
37
+ ...(sc.c ? [`- C / C++: ${sc.c.defsExtracted} of ${sc.c.defNodes} parsed definitions extracted · ${sc.c.symbols} symbols · calls: ${sc.c.calls.resolved} resolved · unresolved by origin ${JSON.stringify(sc.c.unresolvedByOrigin)} (an ALL_CAPS macro counts as builtin) · in-repo miss rate ${(sc.c.inRepoMissRate * 100).toFixed(1)}%`,
38
+ ...(sc.c.unresolvedIncludes.length ? [`- ${sc.c.unresolvedIncludes.length} quoted includes name no repo file (a vendored or generated header): ` +
39
+ sc.c.unresolvedIncludes.slice(0, 20).map(x => `\`${x.include}\` in \`${x.from}\``).join(', ') + (sc.c.unresolvedIncludes.length > 20 ? ', …' : '')] : [])] : []),
40
+ `- files with syntax errors: ${(m.files.filter(f => f.parseError).map(f => f.path)).length ? m.files.filter(f => f.parseError).map(f => `\`${f.path}\``).join(', ') : 'none'}`,
41
+ ...(sc.dependencyFilesNotParsed.length ? [`- **dependency files that are not valid TOML, their names read line by line**: ${sc.dependencyFilesNotParsed.map(f => `\`${f}\``).join(', ')}`] : []), '');
42
+
43
+ out.push('## Groups', '', 'Rules, first match wins (a `--group` override wins over all of them):', '', ...m.groups.rules.map((r, i) => `${i + 1}. ${r}`), '',
44
+ m.groups.overrides.length ? `Overrides: ${m.groups.overrides.map(o => `\`${o}\``).join(', ')}` : 'Overrides: none', '',
45
+ table(['group', 'files', 'symbols', 'entries'], G.map(g => [g, m.groups.files[g], m.groups.symbols[g], m.groups.entries[g]])), '');
46
+ const testEntries = m.entries.filter(e => e.group === 'test');
47
+ if (testEntries.length) out.push('**Entries in the test group** (test-group entries are not flows; if this is a script, override its group):', '',
48
+ ...testEntries.map(e => `- \`${e.id}\` (${loc(e.file, e.line)}) — e.g. \`--group '${e.file}=dev'\``), '');
49
+ out.push('### Every file and the rule that placed it', '', table(['file', 'group', 'rule'], m.files.map(f => [`\`${f.path}\``, f.group, f.rule + (f.unsupported ? ' · not mined yet' : '') + (f.parseError ? ' · syntax error' : '')])), '');
50
+
51
+ out.push('## Entries', '');
52
+ for (const g of G) {
53
+ const es = m.entries.filter(e => e.group === g);
54
+ out.push(`### ${g} (${es.length})`, '', table(['kind', 'entry', 'route / key / via', 'where', 'rule'],
55
+ es.map(e => [e.kind, `\`${short(e.id)}\``, e.path ? [e.method, e.path].filter(Boolean).join(' ') + (e.alsoAt ? ` (also ${e.alsoAt.join(', ')})` : '') : e.key || e.via || '', loc(e.file, e.line), e.rule || 'python'])), '');
56
+ }
57
+
58
+ out.push('## Rule links', '');
59
+ const ruleEdges = m.edges.filter(e => e.rule && !/^pytest\.fixture/.test(e.rule));
60
+ const byRule = new Map(); for (const e of ruleEdges) (byRule.get(e.rule) || byRule.set(e.rule, []).get(e.rule)).push(e);
61
+ for (const [rule, es] of [...byRule].sort()) {
62
+ out.push(`### ${rule} (${es.length})`, '', table(['from', 'to', 'kind', 'groups', 'evidence'],
63
+ es.map(e => [`\`${short(e.from)}\``, `\`${short(e.to)}\``, e.kind, e.groups ? e.groups.join('→') : e.group || '', e.evidence || loc(fileOf(e.from), e.line)])), '');
64
+ }
65
+ const reg = m.rules.registry;
66
+ if (reg.length) out.push('### registry tables', '', table(['key', 'message type', 'consumer', 'evidence', 'constructors', 'linked'],
67
+ reg.map(r => [r.key, `\`${short(r.messageType)}\``, `\`${short(r.consumer)}\``, r.evidence, r.candidates, r.kept])), '');
68
+ const unlinked = m.rules.pyHttp.filter(h => !h.handler);
69
+ if (m.rules.pyHttp.length) out.push(`### HTTP client calls with no matching route (${unlinked.length} of ${m.rules.pyHttp.length})`, '',
70
+ table(['from', 'call', 'where'], unlinked.map(h => [`\`${short(h.from)}\``, `${h.method} ${h.url}`, loc(h.file, h.line)])), '');
71
+ const jsUnlinked = (m.rules.jsHttp || []).filter(h => !h.handler);
72
+ if (m.rules.jsHttp?.length) out.push(`### TypeScript / JavaScript HTTP client calls (${m.rules.jsHttp.length - jsUnlinked.length} of ${m.rules.jsHttp.length} linked)`, '',
73
+ 'A URL is folded from literals and consts (`folded`); one whose leading base could not be folded is linked only when exactly one route has its tail (`tail match`).', '',
74
+ table(['from', 'client', 'call', 'route handler', 'how', 'where'], m.rules.jsHttp.map(h => [`\`${short(h.from)}\``, h.client, `${h.method || '*'} ${h.url}`, h.handler ? `\`${short(h.handler)}\`` : h.unlinked ? `**not linked**: ${h.unlinked}` : '**no matching route**',
75
+ [h.folded ? `folded ${h.folded.join(', ')}` : '', h.env ? `env ${h.env.join(', ')}` : '', h.match === 'suffix' ? 'tail match' : ''].filter(Boolean).join(' · '), loc(h.file, h.line)])), '');
76
+ if (m.rules.routeAliases?.length) out.push('### Handlers a second route also reaches', '', table(['route', 'handler', 'where'],
77
+ m.rules.routeAliases.map(r => [`${r.method} ${r.path}`, `\`${short(r.id)}\``, loc(r.file, r.line)])), '');
78
+ const onFailure = m.edges.filter(e => e.onFailure);
79
+ out.push(`### Calls on failure paths (${onFailure.length})`, '', 'Calls inside an `except` clause run only when the guarded code raised.', '',
80
+ table(['from', 'to', 'on failure', 'where'], onFailure.map(e => [`\`${short(e.from)}\``, `\`${short(e.to)}\``, e.onFailure, loc(fileOf(e.from), e.line)])), '');
81
+ out.push('### Frameworks imported without a rule pack', '', m.frameworksWithoutPack.length
82
+ ? m.frameworksWithoutPack.map(f => `- \`${f.name}\`${f.lang ? ` (${f.lang})` : ''} (first seen in \`${f.firstSeen}\`): its wiring is not linked; declare links where a flow needs them`).join('\n') : '_none_', '');
83
+
84
+ out.push('## Tests', '',
85
+ `${ts.tests} tests (${ts.asserting} with at least one assertion) · ${ts.fixtures} fixtures (${ts.autouse} autouse) · ${ts.fixtureLinks} fixture links · ` +
86
+ `${ts.mocks} mock targets (${ts.mocksResolved} resolved in the repo, ${ts.sessionMocks} session-wide) · ` +
87
+ `**${ts.prodFunctionsWithDirectLink} of ${ts.prodFunctions} production functions have a direct test link**`, '');
88
+ const session = m.mocks.filter(x => x.scope === 'session');
89
+ const autouse = new Set(m.fixtures.filter(f => f.autouse).map(f => f.id));
90
+ const autouseMocks = m.mocks.filter(x => autouse.has(x.fn));
91
+ out.push('### Mocks every test in scope runs under', '', 'No test is evidence for what these replace; narration must not call it "tested".', '',
92
+ table(['mock', 'replaces', 'set up in', 'applies to'], [
93
+ ...session.map(x => [x.how, `\`${x.target ? short(x.target) : x.raw}\``, loc(fileOf(x.fn), x.line) + ` (\`${short(x.fn)}\`)`, x.covers != null ? `every test under \`${x.covers || '.'}/\` (session-wide, set up in \`${x.conftest}\`)` : `every test under \`${x.conftest}\` (session-wide)`]),
94
+ ...autouseMocks.map(x => [x.how, `\`${x.target ? short(x.target) : x.raw}\``, loc(fileOf(x.fn), x.line) + ` (\`${short(x.fn)}\`)`, 'every test in scope (autouse fixture)'])]), '');
95
+ const binds = new Map(); for (const b of m.fixtureBindings) binds.set(b.fixture, (binds.get(b.fixture) || 0) + 1);
96
+ out.push('### Fixtures', '', table(['fixture', 'where', 'autouse', 'bound by'], m.fixtures.map(f => [`\`${short(f.id)}\``, loc(f.file, f.line), f.autouse ? 'yes' : '', binds.get(f.id) || 0])), '');
97
+ const perFile = new Map(); for (const t of m.tests) { const x = perFile.get(t.file) || perFile.set(t.file, { n: 0, direct: 0 }).get(t.file); x.n++; x.direct += t.direct.length; }
98
+ out.push('### Tests per file', '', table(['file', 'tests', 'direct links'], [...perFile].map(([f, x]) => [`\`${f}\``, x.n, x.direct])), '');
99
+
100
+ out.push('## Config', '', `${m.config.settingsFields.length} settings fields · ${m.config.settingsProps.length} settings properties · ${m.config.kv.length} config facts in ${m.config.files.length} git-tracked files · ${m.config.untracked} untracked config files not read`, '',
101
+ ...m.config.notGitWorkTree.map(d => `**Config not read from \`${d}\`: not a git work tree, so no config file can be proven tracked.**`), ...(m.config.notGitWorkTree.length ? [''] : []),
102
+ 'A value is kept only for a key a dispatch guard reads; a secret-looking key or value is shown as `set (redacted)`.', '',
103
+ table(['config file', 'group', 'reader', 'facts'], m.config.files.map(f => [`\`${f.path}\``, f.group, f.reader, f.facts])), '',
104
+ ...(m.config.envReads ? ['### Environment variables and flags the code reads', '', 'TypeScript / JavaScript `process.env` and `import.meta.env`, Go `os.Getenv` / `os.LookupEnv` and `flag.*` (key `-name`), C `getenv`. A default is the literal after `??` or `||`, or a flag\'s default; a secret-looking one is shown as `set (redacted)`.', '',
105
+ table(['variable', 'via', 'default', 'where', 'group'], m.config.envReads.map(r => [r.key, r.via, r.default ?? '', loc(r.file, r.line), r.group])), ''] : []),
106
+ '### Profiles', '', table(['profile', 'group', 'keys a guard reads'], m.config.profiles.map(p => [p.name, p.group, p.keys.join(', ')])), '',
107
+ '### Config-selected dispatch', '');
108
+ for (const s of m.config.sites) {
109
+ out.push(`- \`${s.factory}\` (${s.group}) → **${s.classification}**`);
110
+ for (const a of s.alternatives) out.push(` - \`${short(a.cls)}\`${a.group && a.group !== s.group ? ` (${a.group} group)` : ''} when ${a.guards.join(' & ') || '(fallthrough)'} · ${Object.entries(a.sel).map(([k, v]) => `${k.split('/').pop()}=${v}`).join(' ')}`);
111
+ for (const v of s.inRepoValues) out.push(` - reads settings.${v.setting} (env ${v.env}, default ${v.default}) set in: ${v.files.join(', ') || 'no in-repo file'}`);
112
+ if (s.params.length) out.push(` - runtime inputs: ${s.params.join(', ')}`);
113
+ }
114
+ out.push('');
115
+ return out.join('\n');
116
+ }
@@ -0,0 +1,13 @@
1
+ /* Config facts from `.env`-style files: `KEY=value`, `export KEY=value`, trailing `# comment` dropped. */
2
+ export const matches = rel => /(^|\/)(\.env(\.[\w-]+)?|[\w.-]+\.env(\.example)?|sample\.env)$/.test(rel);
3
+
4
+ export function readFacts(text) {
5
+ const out = [];
6
+ text.split('\n').forEach((ln, i) => {
7
+ const m = ln.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
8
+ if (!m) return;
9
+ const value = m[2].replace(/\s+#.*$/, '').replace(/^["']|["']$/g, '').trim();
10
+ if (value !== '') out.push({ key: m[1], value, line: i + 1 });
11
+ });
12
+ return out;
13
+ }
@@ -0,0 +1,14 @@
1
+ /* Config facts from JSON, line by line: `"key": value` pairs (leaf scalars; objects and arrays are skipped).
2
+ package.json, lockfiles and tsconfig are tooling, not configuration of the code. */
3
+ export const matches = rel => /\.json$/.test(rel) && !/(^|\/)(package(-lock)?\.json|tsconfig[\w.-]*\.json)$/.test(rel);
4
+
5
+ export function readFacts(text) {
6
+ const out = [];
7
+ text.split('\n').forEach((ln, i) => {
8
+ const m = ln.match(/^\s*"([A-Za-z][A-Za-z0-9_.-]*)"\s*:\s*(.*?)\s*,?\s*$/);
9
+ if (!m || /^[[{]/.test(m[2])) return;
10
+ const value = m[2].replace(/^"|"$/g, '').trim();
11
+ if (value !== '') out.push({ key: m[1], value, line: i + 1 });
12
+ });
13
+ return out;
14
+ }
@@ -0,0 +1,16 @@
1
+ /* What the code map may show of a config value. A secret-looking key (…KEY, TOKEN, SECRET, PASSWORD, AUTH, …) or a
2
+ credential-shaped value (a URL with a password, a private key block, a JWT, a provider token, a long random
3
+ string) is shown only as set. */
4
+ export const REDACTED = 'set (redacted)';
5
+ const SECRET_KEY = /KEY|TOKEN|SECRET|PASS(WORD|WD|PHRASE)?|PWD|CREDENTIAL|PRIVATE|AUTH|CERT|SIGNATURE|SALT|COOKIE|SESSION|DSN/i;
6
+ const CREDENTIAL = [
7
+ /^[a-z][\w+.-]*:\/\/[^/\s:@]*:[^/\s@]+@/i, // scheme://user:password@host
8
+ /-----BEGIN [A-Z ]*PRIVATE KEY/,
9
+ /^eyJ[\w-]+\.[\w-]+\./, // JWT
10
+ /^(sk|pk|rk)[-_]\w[\w-]{15,}$/, /^(gh[pousr]|github_pat)_\w{20,}$/, /^xox[abposr]-/, /^AKIA[0-9A-Z]{16}$/, /^AIza[\w-]{30,}$/,
11
+ /^Bearer\s+\S/i,
12
+ /^[0-9a-f]{32,}$/i,
13
+ /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z0-9+/=_-]{32,}$/,
14
+ ];
15
+ export const shownValue = (key, value) =>
16
+ value == null ? value : SECRET_KEY.test(key || '') || CREDENTIAL.some(re => re.test(String(value))) ? REDACTED : value;
@@ -0,0 +1,13 @@
1
+ /* Config facts from TOML, line by line: `key = value` leaves (tables are not tracked; keys are leaf names). */
2
+ export const matches = rel => /\.toml$/.test(rel);
3
+
4
+ export function readFacts(text) {
5
+ const out = [];
6
+ text.split('\n').forEach((ln, i) => {
7
+ const m = ln.match(/^\s*["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*=\s*(.*)$/);
8
+ if (!m) return;
9
+ const value = m[2].replace(/\s+#.*$/, '').replace(/^["']|["']$/g, '').trim();
10
+ if (value !== '') out.push({ key: m[1], value, line: i + 1 });
11
+ });
12
+ return out;
13
+ }
@@ -0,0 +1,18 @@
1
+ /* Config facts from YAML (incl. Helm values and k8s manifests), line by line: `key: value` leaves, and the
2
+ container-env shape `- name: KEY` followed by `value: v`, which sets KEY. Block scalars (`|`, `>`) are skipped. */
3
+ export const matches = rel => /\.ya?ml$/.test(rel);
4
+
5
+ export function readFacts(text) {
6
+ const out = [];
7
+ let envName = null;
8
+ text.split('\n').forEach((ln, i) => {
9
+ const m = ln.match(/^\s*(?:-\s*)?["']?([A-Za-z][A-Za-z0-9_.-]*)["']?\s*:\s*(.*)$/);
10
+ if (!m) return;
11
+ const value = m[2].replace(/\s+#.*$/, '').replace(/^["']|["'],?$/g, '').trim();
12
+ if (m[1] === 'name' && /^\s*-\s*name\s*:/.test(ln)) envName = value;
13
+ else if (m[1] === 'value' && envName) { if (value !== '') out.push({ key: envName, value, line: i + 1 }); envName = null; return; }
14
+ if (value === '' || value === '|' || value === '>' || /^[|>][-+]?$/.test(value)) return;
15
+ out.push({ key: m[1], value, line: i + 1 });
16
+ });
17
+ return out;
18
+ }
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ excerpt-check.mjs — prove every code panel of a code video shows the repo as
4
+ mined, and that the narration is the reading time (design §4.5 C6).
5
+
6
+ Usage:
7
+ node tools/excerpt-check.mjs --work out/<name>/<NN> --repo <root> --script out/<name>/scripts/NN-*.json
8
+
9
+ Reads the video's code scenes (manifest.json's `code` segments), lines.json
10
+ (stage 4b) and the video's script. Two checks:
11
+
12
+ DRIFT (exit 2) — every panel carries the `source` excerpt.mjs wrote, and is
13
+ byte-identical to the file as mined: the working tree's file when its sha256
14
+ is the mined one, else the file at the mined commit (`git show`), else the
15
+ file changed and the panel cannot be trusted. Every numbered line equals that
16
+ line of the file, inside the span, in order; the span's sha256 still matches;
17
+ every unnumbered line is an elision marker ("⋯ N lines") standing for exactly
18
+ the gap it sits in, and no line of the span is hidden without one; at most 22
19
+ lines are shown.
20
+
21
+ FOCUS (exit 3) — every shown, non-blank line is inside a focus window that is
22
+ synced to the narration: the focus act names `narration` (a line index of
23
+ the segment in lines.json, or [from, to]) and starts at that line's onset
24
+ and ends between its end and the next line's onset (scene time, ±0.06 s, as
25
+ resolve-beats measures). Only a pause segment (one whose script segment
26
+ carries `pause`) may show lines outside a focus window; there are at most 3,
27
+ each with a badge. A code scene names its segment (`segment: <key>`): its
28
+ acts are timed from that segment's window, and the code host plays a scene
29
+ that names none on the whole video's clock, where every window after the
30
+ first would be missed.
31
+
32
+ Exit: 0 ok · 1 bad flags, no manifest, no scenes, no lines.json or no script · 2 drift · 3 a shown line
33
+ outside every narration-synced focus window, or a pause rule broken
34
+ ========================================================================== */
35
+ import { readFileSync, existsSync } from 'node:fs';
36
+ import { join, resolve, basename } from 'node:path';
37
+ import { pathToFileURL } from 'node:url';
38
+ import { createHash } from 'node:crypto';
39
+ import { spawnSync } from 'node:child_process';
40
+ import { MAX_PAUSES } from '../../explainer-video-from-coursework/tools/model.mjs';
41
+
42
+ const fail = (code, msg) => { console.error(`[excerpt-check] ${msg}`); process.exit(code); };
43
+
44
+ /* ---------------- args ---------------- */
45
+ const argv = process.argv.slice(2);
46
+ const VALUED = new Set(['--work', '--repo', '--script']);
47
+ const flags = new Map();
48
+ for (let i = 0; i < argv.length; i++) {
49
+ if (VALUED.has(argv[i])) { if (argv[i + 1] == null) fail(1, `${argv[i]} needs a value`); flags.set(argv[i], argv[++i]); }
50
+ else fail(1, `unknown argument ${argv[i]}`);
51
+ }
52
+ if (!flags.has('--work') || !flags.has('--repo') || !flags.has('--script')) fail(1, 'usage: excerpt-check.mjs --work out/<name>/<NN> --repo <root> --script out/<name>/scripts/NN-*.json');
53
+ const WORK = resolve(flags.get('--work')), REPO = resolve(flags.get('--repo'));
54
+ const MAX_LINES = 22, TOL = 0.06;
55
+ const MARKER = /^\s*⋯ (\d+) lines?$/;
56
+ const sha = t => createHash('sha256').update(t).digest('hex');
57
+ const readJson = (f, what) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch (e) { fail(1, `cannot read ${what} ${f}: ${e.message}`); } };
58
+
59
+ /* ---------------- the code scenes ---------------- */
60
+ const scenesDir = join(WORK, 'scenes');
61
+ if (!existsSync(scenesDir)) fail(1, `no scenes/ in ${WORK}: author the scenes first (stage 5)`);
62
+ // a scene is read by executing it with a stubbed `window`, as resolve-beats and the scene host do
63
+ async function sceneConfig(file) {
64
+ const prev = global.window;
65
+ global.window = {};
66
+ try { await import(pathToFileURL(file).href + '?v=' + Date.now()); return global.window.SCENE_CONFIG || null; }
67
+ catch (e) { fail(1, `${basename(file)} threw: ${e.message}`); }
68
+ finally { global.window = prev; }
69
+ }
70
+ const manifestPath = join(WORK, 'manifest.json');
71
+ if (!existsSync(manifestPath)) fail(1, `no manifest.json in ${WORK}: write it with the scenes (stage 5); its code segments are the panels checked`);
72
+ const man = readJson(manifestPath, 'the manifest');
73
+ const isCode = host => host === 'code' || /scene-code\.html$/.test((man.hosts || {})[host] || '');
74
+ const segs = Object.entries(man.segments || {}).filter(([, s]) => isCode(s.host)).map(([key, s]) => ({ key, file: join(scenesDir, `${s.scene}.js`) }));
75
+ const scenes = [];
76
+ for (const s of segs) {
77
+ if (!existsSync(s.file)) fail(1, `segment ${s.key}: scene ${s.file} does not exist`);
78
+ const cfg = await sceneConfig(s.file);
79
+ if (cfg && Array.isArray(cfg.panels)) scenes.push({ ...s, cfg });
80
+ }
81
+ if (!scenes.length) fail(1, `no code scenes in ${WORK}`);
82
+ const linesPath = join(WORK, 'lines.json');
83
+ if (!existsSync(linesPath)) fail(1, `no lines.json in ${WORK}: run align.mjs first (stage 4b); focus windows are checked against the measured narration`);
84
+ const segLines = readJson(linesPath, 'lines.json').segments || {};
85
+ const script = readJson(resolve(flags.get('--script')), 'the script');
86
+
87
+ /* ---------------- the file a panel was cut from, as mined ---------------- */
88
+ const fileCache = new Map();
89
+ function minedFile(src) {
90
+ const key = `${src.file}\0${src.fileSha256}\0${src.commit}`;
91
+ if (fileCache.has(key)) return fileCache.get(key);
92
+ let got = null;
93
+ const path = join(REPO, src.file);
94
+ if (existsSync(path)) { const t = readFileSync(path, 'utf8'); if (sha(t) === src.fileSha256) got = { text: t, from: 'working tree' }; }
95
+ if (!got && src.commit) {
96
+ const r = spawnSync('git', ['-C', REPO, 'show', `${src.commit}:./${src.file}`], { encoding: 'utf8', maxBuffer: 1 << 28 });
97
+ if (r.status === 0 && sha(r.stdout) === src.fileSha256) got = { text: r.stdout, from: `commit ${src.commit.slice(0, 12)}` };
98
+ }
99
+ fileCache.set(key, got);
100
+ return got;
101
+ }
102
+
103
+ /* ---------------- checks ---------------- */
104
+ const drift = [], focus = [], report = [];
105
+ const pauseSegs = new Set((script.segments || []).filter(s => s.pause != null).map(s => s.key));
106
+
107
+ for (const { key, cfg } of scenes) {
108
+ const acts = cfg.acts || [];
109
+ const L = segLines[key];
110
+ const isPause = pauseSegs.has(key);
111
+ const hasBadge = acts.some(a => a.op === 'pause');
112
+ if (isPause && !hasBadge) focus.push(`${key}: a pause segment shows no pause badge (add a \`pause\` act over the spoken prompt)`);
113
+ if (!isPause && hasBadge) focus.push(`${key}: a pause badge on a segment whose narration does not ask for a pause`);
114
+ if (cfg.segment !== key) focus.push(`${key}: the scene ${cfg.segment == null ? 'names no segment' : `names segment "${cfg.segment}"`} (set \`segment: "${key}"\`): its acts are timed from this segment's window, and the code host plays them on that window only when the scene names it`);
115
+
116
+ // the focus windows synced to this segment's narration; an unsynced one covers nothing
117
+ const synced = new Map(); // panel id -> Set of 1-based panel lines
118
+ for (const a of acts.filter(x => x.op === 'focus')) {
119
+ const at = `${key}: focus on ${a.panel} at ${a.from}–${a.to}s`;
120
+ const n = a.narration;
121
+ const [f, t] = Array.isArray(n) ? n : [n, n];
122
+ if (!Number.isInteger(f) || !Number.isInteger(t) || t < f) { focus.push(`${at} names no narration line (\`narration: <line>\` or \`[from, to]\` in lines.json)`); continue; }
123
+ if (typeof a.from !== 'number' || typeof a.to !== 'number') { focus.push(`${at} needs numeric from and to (scene seconds)`); continue; }
124
+ if (!L) { focus.push(`${at}: lines.json has no segment ${key} (re-run align.mjs)`); continue; }
125
+ if (f < 0 || t >= L.lines.length) { focus.push(`${at}: narration lines ${f}…${t} do not exist (segment has ${L.lines.length})`); continue; }
126
+ const lead = L.lead ?? 0, start = L.lines[f].start + lead, end = L.lines[t].end + lead;
127
+ const next = t + 1 < L.lines.length ? L.lines[t + 1].start + lead : (cfg.total ?? end);
128
+ if (Math.abs(a.from - start) > TOL) { focus.push(`${at} starts ${(a.from - start).toFixed(2)}s off narration line ${f}'s onset (${start.toFixed(3)}s)`); continue; }
129
+ if (a.to < end - TOL || a.to > Math.max(end, next) + TOL) { focus.push(`${at} must end between narration line ${t}'s end (${end.toFixed(3)}s) and the next line's onset (${next.toFixed(3)}s)`); continue; }
130
+ const set = synced.get(a.panel) || synced.set(a.panel, new Set()).get(a.panel);
131
+ for (const i of a.lines || []) set.add(i);
132
+ }
133
+
134
+ for (const p of cfg.panels) {
135
+ const at = `${key}/${p.id}`;
136
+ const src = p.source;
137
+ if (!src || !src.file || !src.fileSha256 || !src.sha256 || !Number.isInteger(src.start) || !Number.isInteger(src.end)) {
138
+ drift.push(`${at}: no excerpt source — every code panel of a code video is generated by excerpt.mjs, never typed`); continue;
139
+ }
140
+ const lines = p.lines || [], nums = p.numbers || [];
141
+ if (nums.length !== lines.length) { drift.push(`${at}: numbers[] has ${nums.length} entries for ${lines.length} lines`); continue; }
142
+ const mined = minedFile(src);
143
+ if (!mined) { drift.push(`${at}: ${src.file} is not the file excerpt.mjs read (sha256 ${src.fileSha256.slice(0, 12)}…) in the working tree${src.commit ? ` or at commit ${src.commit.slice(0, 12)}` : ''}: re-mine and re-excerpt`); continue; }
144
+ const file = mined.text.split('\n');
145
+ if (sha(file.slice(src.start - 1, src.end).join('\n')) !== src.sha256) { drift.push(`${at}: the span ${src.file}:${src.start}-${src.end} no longer matches its sha256`); continue; }
146
+ const elided = new Map((p.elided || []).map(e => [e.at, e]));
147
+ let prev = src.start - 1, bad = 0;
148
+ const visible = nums.filter(n => n != null).length;
149
+ if (visible > MAX_LINES) drift.push(`${at}: ${visible} lines shown, over the ${MAX_LINES}-line cap`);
150
+ // the gap an elision marker stands for runs to the next numbered line, or the span's end
151
+ const nextNum = i => { for (let j = i + 1; j < nums.length; j++) if (nums[j] != null) return nums[j]; return src.end + 1; };
152
+ lines.forEach((ln, i) => {
153
+ const n = nums[i];
154
+ if (n == null) {
155
+ const e = elided.get(i), mk = MARKER.exec(ln);
156
+ const from = prev + 1, to = nextNum(i) - 1;
157
+ if (!e || !mk || e.from !== from || e.to !== to || Number(mk[1]) !== to - from + 1) {
158
+ drift.push(`${at} line ${i + 1}: "${ln}" is unnumbered but not the elision marker for lines ${from}-${to}`); bad++;
159
+ }
160
+ prev = to;
161
+ return;
162
+ }
163
+ if (!Number.isInteger(n) || n <= prev || n < src.start || n > src.end) { drift.push(`${at} line ${i + 1}: number ${n} is out of order or outside ${src.file}:${src.start}-${src.end}`); bad++; prev = Math.max(prev, Number(n) || prev); return; }
164
+ if (n !== prev + 1) { drift.push(`${at}: ${src.file}:${prev + 1}-${n - 1} hidden without an elision marker`); bad++; }
165
+ if (ln !== file[n - 1]) { drift.push(`${at}: ${src.file}:${n} differs from the file (${JSON.stringify(ln)} vs ${JSON.stringify(file[n - 1])})`); bad++; }
166
+ prev = n;
167
+ });
168
+ if (prev < src.end) { drift.push(`${at}: ${src.file}:${prev + 1}-${src.end} hidden without an elision marker`); bad++; }
169
+
170
+ // narration is the reading time: every shown line in a narration-synced focus window, except in a pause segment
171
+ const covered = synced.get(p.id) || new Set();
172
+ const unread = lines.map((ln, i) => ({ ln, i, n: nums[i] })).filter(x => x.n != null && x.ln.trim() && !covered.has(x.i + 1));
173
+ if (unread.length && !isPause) focus.push(`${at}: ${unread.length} shown line(s) outside every narration-synced focus window: ${src.file}:${unread.map(x => x.n).join(',')}`);
174
+ report.push(` ${bad ? '✗' : '✓'} ${at.padEnd(24)} ${src.test ? `test ${src.test.split(':').pop()}` : src.symbol || src.file} · ${visible} lines · ${mined.from}${isPause ? ' · pause segment' : ''}${unread.length ? ` · ${unread.length} unfocused` : ''}`);
175
+ }
176
+ }
177
+ for (const k of pauseSegs) if (!scenes.some(s => s.key === k)) focus.push(`${k}: a pause segment shows no pause badge (only a code scene with panels shows one; host it there)`);
178
+ if (pauseSegs.size > MAX_PAUSES) focus.push(`${pauseSegs.size} pause segments (max ${MAX_PAUSES}): a video that asks for that much silent reading must be split`);
179
+
180
+ console.log(`\nexcerpt-check ${WORK}`);
181
+ console.log(` ${scenes.length} code scene(s) · ${scenes.reduce((a, s) => a + s.cfg.panels.length, 0)} panel(s) · ${pauseSegs.size} pause segment(s)`);
182
+ report.forEach(r => console.log(r));
183
+ if (drift.length) { console.log(`\n ✗ ${drift.length} drift problem(s):`); drift.forEach(d => console.log(` - ${d}`)); }
184
+ if (focus.length) { console.log(`\n ✗ ${focus.length} focus problem(s):`); focus.forEach(d => console.log(` - ${d}`)); }
185
+ if (!drift.length && !focus.length) console.log('\n ✓ every panel is the mined code, and every shown line is narrated');
186
+ process.exitCode = drift.length ? 2 : focus.length ? 3 : 0;
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ excerpt.mjs — one code panel for a scene, cut from the repo as it was mined.
4
+
5
+ Usage:
6
+ node tools/excerpt.mjs --codemap out/<name>/codemap.json --symbol <id> [--lines a-b,…]
7
+ node tools/excerpt.mjs --codemap out/<name>/codemap.json --symbol <id> --test <test id> [--lines a-b,…]
8
+
9
+ Prints a panel fragment for scene-code.js (design §4.6):
10
+ { file, lang, lines[], numbers[], elided[], source }
11
+ `lines` are the file's lines, byte for byte; `numbers` their real line numbers.
12
+ Narration is the reading time, so only narrated lines are shown: `--lines`
13
+ ("54-65,70", inside the symbol's span) picks them, and every unshown run inside
14
+ the span collapses into one elision marker ("⋯ 4 lines"), whose number is null
15
+ and whose index is in `elided` ({at, from, to}). A run of blank lines is shown,
16
+ not elided. Without `--lines` the whole span is shown.
17
+
18
+ With --test, the panel is the test's side of a "pinned by" pair: the test's
19
+ def line (its name, which the narration says), the line where it calls the hop
20
+ (its direct link) and its assertion lines, elided between. The test must link
21
+ to --symbol directly and must not mock it.
22
+
23
+ `source` {file, symbol | test, start, end, sha256, fileSha256, commit} is what
24
+ excerpt-check.mjs verifies the panel against: `sha256` is the shown span's
25
+ (for a symbol, the code map's own), `fileSha256` the file's when mined.
26
+
27
+ Exit: 0 ok · 1 bad flags or unreadable code map · 2 symbol or test missing, a
28
+ line outside the span, a test that does not pin the symbol, the file
29
+ changed since it was mined, or more than 22 shown lines
30
+ ========================================================================== */
31
+ import { readFileSync, existsSync } from 'node:fs';
32
+ import { join, resolve } from 'node:path';
33
+ import { createHash } from 'node:crypto';
34
+
35
+ const fail = (code, msg) => { console.error(`[excerpt] ${msg}`); process.exit(code); };
36
+
37
+ /* ---------------- args ---------------- */
38
+ const argv = process.argv.slice(2);
39
+ const VALUED = new Set(['--codemap', '--symbol', '--lines', '--test']);
40
+ const flags = new Map();
41
+ for (let i = 0; i < argv.length; i++) {
42
+ if (VALUED.has(argv[i])) { if (argv[i + 1] == null) fail(1, `${argv[i]} needs a value`); flags.set(argv[i], argv[++i]); }
43
+ else fail(1, `unknown argument ${argv[i]}`);
44
+ }
45
+ if (!flags.has('--codemap') || !flags.has('--symbol')) fail(1, 'usage: excerpt.mjs --codemap out/<name>/codemap.json --symbol <id> [--lines a-b,…] [--test <test id>]');
46
+ const MAX = 22;
47
+ let m;
48
+ try { m = JSON.parse(readFileSync(resolve(flags.get('--codemap')), 'utf8')); } catch (e) { fail(1, `cannot read the code map: ${e.message}`); }
49
+ if (m?.schema !== 1 || !Array.isArray(m.symbols)) fail(1, 'not a mine.mjs code map (schema 1)');
50
+
51
+ const sha = t => createHash('sha256').update(t).digest('hex');
52
+ const symId = flags.get('--symbol'), testId = flags.get('--test');
53
+ const sym = m.symbols.find(s => s.id === symId);
54
+ if (!sym) fail(2, `no symbol ${symId} in the code map`);
55
+
56
+ /* ---------------- what span, which file ---------------- */
57
+ let span, shown, test = null;
58
+ if (testId) {
59
+ test = (m.tests || []).find(t => t.id === testId);
60
+ if (!test) fail(2, `no test ${testId} in the code map`);
61
+ if (test.mocked.includes(symId)) fail(2, `test ${testId} mocks ${symId}: a test that replaces a hop cannot pin it`);
62
+ const link = test.direct.find(l => l.to === symId);
63
+ if (!link) fail(2, `test ${testId} has no direct link to ${symId}: only a direct, unmocked link may be shown as pinning it`);
64
+ span = { file: test.file, start: test.line, end: test.end };
65
+ // its name, the call line when it is in the test's own body (a fixture or helper calls from elsewhere), the assertions
66
+ const call = link.line >= test.line && link.line <= test.end ? [link.line] : [];
67
+ if (!call.length && !test.assertLines.length) fail(2, `test ${testId} has neither a call line nor an assertion line inside its body to show`);
68
+ shown = [...new Set([test.line, ...call, ...test.assertLines])].sort((a, b) => a - b);
69
+ } else span = { file: sym.file, start: sym.start, end: sym.end };
70
+
71
+ const file = (m.files || []).find(f => f.path === span.file);
72
+ const path = join(m.root, span.file);
73
+ if (!file || !existsSync(path)) fail(2, `${span.file} is not in the mined repo at ${m.root}`);
74
+ const text = readFileSync(path, 'utf8');
75
+ if (sha(text) !== file.sha256) fail(2, `${span.file} changed since it was mined: re-mine before excerpting, so the panel shows the code the flow was built from`);
76
+ const all = text.split('\n');
77
+ const spanSha = sha(all.slice(span.start - 1, span.end).join('\n'));
78
+ if (!test && spanSha !== sym.sha256) fail(2, `${symId}'s span no longer matches the code map: re-mine`);
79
+
80
+ if (flags.has('--lines')) {
81
+ shown = [];
82
+ for (const part of flags.get('--lines').split(',')) {
83
+ const r = /^\s*(\d+)(?:\s*-\s*(\d+))?\s*$/.exec(part);
84
+ if (!r) fail(1, `--lines takes ranges like "54-65,70", not "${part}"`);
85
+ const a = Number(r[1]), b = Number(r[2] ?? r[1]);
86
+ if (b < a || a < span.start || b > span.end) fail(2, `lines ${a}-${b} are outside ${testId || symId} (lines ${span.start}-${span.end} of ${span.file})`);
87
+ for (let n = a; n <= b; n++) shown.push(n);
88
+ }
89
+ shown = [...new Set(shown)].sort((a, b) => a - b);
90
+ } else if (!test) shown = Array.from({ length: span.end - span.start + 1 }, (_, i) => span.start + i);
91
+
92
+ /* ---------------- lines, numbers, elision markers ---------------- */
93
+ const blank = n => !all[n - 1].trim();
94
+ const lines = [], numbers = [], elided = [];
95
+ const gap = (from, to) => {
96
+ if (from > to) return;
97
+ const hidden = Array.from({ length: to - from + 1 }, (_, i) => from + i);
98
+ if (hidden.every(blank)) { for (const n of hidden) { lines.push(all[n - 1]); numbers.push(n); } return; }
99
+ // the marker sits at the hidden block's outer indent: its least-indented non-blank line
100
+ const indent = hidden.filter(n => !blank(n)).map(n => /^\s*/.exec(all[n - 1])[0]).reduce((a, b) => (b.length < a.length ? b : a));
101
+ elided.push({ at: lines.length, from, to });
102
+ lines.push(`${indent}⋯ ${to - from + 1} line${to === from ? '' : 's'}`);
103
+ numbers.push(null);
104
+ };
105
+ let prev = span.start - 1;
106
+ for (const n of shown) { gap(prev + 1, n - 1); lines.push(all[n - 1]); numbers.push(n); prev = n; }
107
+ gap(prev + 1, span.end);
108
+ const visible = numbers.filter(n => n != null).length;
109
+ if (visible > MAX) fail(2, `${visible} lines shown, over the ${MAX}-line cap: narrow them with --lines (only narrated lines belong on screen), or split the hop`);
110
+
111
+ const panel = {
112
+ file: span.file, lang: file.lang, lines, numbers, elided,
113
+ source: { file: span.file, ...(test ? { test: test.id, pins: symId, via: test.direct.find(l => l.to === symId).via } : { symbol: symId }),
114
+ start: span.start, end: span.end, sha256: spanSha, fileSha256: file.sha256, commit: m.commit },
115
+ };
116
+ console.log(JSON.stringify(panel, null, 2));