picopilot 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. package/LICENSE +661 -0
  2. package/dist/bin.d.ts +3 -0
  3. package/dist/bin.d.ts.map +1 -0
  4. package/dist/bin.js +4 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/cli.d.ts +15 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +59 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/audio.d.ts +17 -0
  11. package/dist/commands/audio.d.ts.map +1 -0
  12. package/dist/commands/audio.js +315 -0
  13. package/dist/commands/audio.js.map +1 -0
  14. package/dist/commands/export.d.ts +36 -0
  15. package/dist/commands/export.d.ts.map +1 -0
  16. package/dist/commands/export.js +257 -0
  17. package/dist/commands/export.js.map +1 -0
  18. package/dist/commands/gfx.d.ts +28 -0
  19. package/dist/commands/gfx.d.ts.map +1 -0
  20. package/dist/commands/gfx.js +521 -0
  21. package/dist/commands/gfx.js.map +1 -0
  22. package/dist/commands/init.d.ts +34 -0
  23. package/dist/commands/init.d.ts.map +1 -0
  24. package/dist/commands/init.js +151 -0
  25. package/dist/commands/init.js.map +1 -0
  26. package/dist/commands/lint.d.ts +20 -0
  27. package/dist/commands/lint.d.ts.map +1 -0
  28. package/dist/commands/lint.js +90 -0
  29. package/dist/commands/lint.js.map +1 -0
  30. package/dist/commands/minify.d.ts +20 -0
  31. package/dist/commands/minify.d.ts.map +1 -0
  32. package/dist/commands/minify.js +129 -0
  33. package/dist/commands/minify.js.map +1 -0
  34. package/dist/commands/music.d.ts +13 -0
  35. package/dist/commands/music.d.ts.map +1 -0
  36. package/dist/commands/music.js +241 -0
  37. package/dist/commands/music.js.map +1 -0
  38. package/dist/commands/playtest.d.ts +29 -0
  39. package/dist/commands/playtest.d.ts.map +1 -0
  40. package/dist/commands/playtest.js +631 -0
  41. package/dist/commands/playtest.js.map +1 -0
  42. package/dist/commands/run.d.ts +30 -0
  43. package/dist/commands/run.d.ts.map +1 -0
  44. package/dist/commands/run.js +224 -0
  45. package/dist/commands/run.js.map +1 -0
  46. package/dist/commands/serve.d.ts +50 -0
  47. package/dist/commands/serve.d.ts.map +1 -0
  48. package/dist/commands/serve.js +213 -0
  49. package/dist/commands/serve.js.map +1 -0
  50. package/dist/commands/sfx.d.ts +13 -0
  51. package/dist/commands/sfx.d.ts.map +1 -0
  52. package/dist/commands/sfx.js +254 -0
  53. package/dist/commands/sfx.js.map +1 -0
  54. package/dist/commands/tokens.d.ts +36 -0
  55. package/dist/commands/tokens.d.ts.map +1 -0
  56. package/dist/commands/tokens.js +129 -0
  57. package/dist/commands/tokens.js.map +1 -0
  58. package/dist/commands/verify.d.ts +57 -0
  59. package/dist/commands/verify.d.ts.map +1 -0
  60. package/dist/commands/verify.js +213 -0
  61. package/dist/commands/verify.js.map +1 -0
  62. package/dist/commands/version.d.ts +12 -0
  63. package/dist/commands/version.d.ts.map +1 -0
  64. package/dist/commands/version.js +24 -0
  65. package/dist/commands/version.js.map +1 -0
  66. package/dist/engine/audio/index.d.ts +14 -0
  67. package/dist/engine/audio/index.d.ts.map +1 -0
  68. package/dist/engine/audio/index.js +14 -0
  69. package/dist/engine/audio/index.js.map +1 -0
  70. package/dist/engine/audio/music.d.ts +116 -0
  71. package/dist/engine/audio/music.d.ts.map +1 -0
  72. package/dist/engine/audio/music.js +138 -0
  73. package/dist/engine/audio/music.js.map +1 -0
  74. package/dist/engine/audio/section.d.ts +34 -0
  75. package/dist/engine/audio/section.d.ts.map +1 -0
  76. package/dist/engine/audio/section.js +67 -0
  77. package/dist/engine/audio/section.js.map +1 -0
  78. package/dist/engine/audio/sfx.d.ts +141 -0
  79. package/dist/engine/audio/sfx.d.ts.map +1 -0
  80. package/dist/engine/audio/sfx.js +506 -0
  81. package/dist/engine/audio/sfx.js.map +1 -0
  82. package/dist/engine/cart/cart.d.ts +75 -0
  83. package/dist/engine/cart/cart.d.ts.map +1 -0
  84. package/dist/engine/cart/cart.js +153 -0
  85. package/dist/engine/cart/cart.js.map +1 -0
  86. package/dist/engine/cart/errors.d.ts +29 -0
  87. package/dist/engine/cart/errors.d.ts.map +1 -0
  88. package/dist/engine/cart/errors.js +26 -0
  89. package/dist/engine/cart/errors.js.map +1 -0
  90. package/dist/engine/cart/gfx.d.ts +79 -0
  91. package/dist/engine/cart/gfx.d.ts.map +1 -0
  92. package/dist/engine/cart/gfx.js +171 -0
  93. package/dist/engine/cart/gfx.js.map +1 -0
  94. package/dist/engine/cart/index.d.ts +15 -0
  95. package/dist/engine/cart/index.d.ts.map +1 -0
  96. package/dist/engine/cart/index.js +15 -0
  97. package/dist/engine/cart/index.js.map +1 -0
  98. package/dist/engine/cart/map.d.ts +46 -0
  99. package/dist/engine/cart/map.d.ts.map +1 -0
  100. package/dist/engine/cart/map.js +118 -0
  101. package/dist/engine/cart/map.js.map +1 -0
  102. package/dist/engine/config/config.d.ts +74 -0
  103. package/dist/engine/config/config.d.ts.map +1 -0
  104. package/dist/engine/config/config.js +103 -0
  105. package/dist/engine/config/config.js.map +1 -0
  106. package/dist/engine/config/index.d.ts +7 -0
  107. package/dist/engine/config/index.d.ts.map +1 -0
  108. package/dist/engine/config/index.js +7 -0
  109. package/dist/engine/config/index.js.map +1 -0
  110. package/dist/engine/gfx/grid.d.ts +51 -0
  111. package/dist/engine/gfx/grid.d.ts.map +1 -0
  112. package/dist/engine/gfx/grid.js +116 -0
  113. package/dist/engine/gfx/grid.js.map +1 -0
  114. package/dist/engine/gfx/index.d.ts +17 -0
  115. package/dist/engine/gfx/index.d.ts.map +1 -0
  116. package/dist/engine/gfx/index.js +17 -0
  117. package/dist/engine/gfx/index.js.map +1 -0
  118. package/dist/engine/gfx/label.d.ts +34 -0
  119. package/dist/engine/gfx/label.d.ts.map +1 -0
  120. package/dist/engine/gfx/label.js +190 -0
  121. package/dist/engine/gfx/label.js.map +1 -0
  122. package/dist/engine/gfx/overlap.d.ts +42 -0
  123. package/dist/engine/gfx/overlap.d.ts.map +1 -0
  124. package/dist/engine/gfx/overlap.js +57 -0
  125. package/dist/engine/gfx/overlap.js.map +1 -0
  126. package/dist/engine/gfx/palette.d.ts +29 -0
  127. package/dist/engine/gfx/palette.d.ts.map +1 -0
  128. package/dist/engine/gfx/palette.js +46 -0
  129. package/dist/engine/gfx/palette.js.map +1 -0
  130. package/dist/engine/gfx/png.d.ts +31 -0
  131. package/dist/engine/gfx/png.d.ts.map +1 -0
  132. package/dist/engine/gfx/png.js +165 -0
  133. package/dist/engine/gfx/png.js.map +1 -0
  134. package/dist/engine/gfx/render.d.ts +42 -0
  135. package/dist/engine/gfx/render.d.ts.map +1 -0
  136. package/dist/engine/gfx/render.js +72 -0
  137. package/dist/engine/gfx/render.js.map +1 -0
  138. package/dist/engine/index.d.ts +22 -0
  139. package/dist/engine/index.d.ts.map +1 -0
  140. package/dist/engine/index.js +22 -0
  141. package/dist/engine/index.js.map +1 -0
  142. package/dist/engine/pico8/adapter.d.ts +267 -0
  143. package/dist/engine/pico8/adapter.d.ts.map +1 -0
  144. package/dist/engine/pico8/adapter.js +37 -0
  145. package/dist/engine/pico8/adapter.js.map +1 -0
  146. package/dist/engine/pico8/drive.d.ts +308 -0
  147. package/dist/engine/pico8/drive.d.ts.map +1 -0
  148. package/dist/engine/pico8/drive.js +581 -0
  149. package/dist/engine/pico8/drive.js.map +1 -0
  150. package/dist/engine/pico8/harness.d.ts +146 -0
  151. package/dist/engine/pico8/harness.d.ts.map +1 -0
  152. package/dist/engine/pico8/harness.js +251 -0
  153. package/dist/engine/pico8/harness.js.map +1 -0
  154. package/dist/engine/pico8/index.d.ts +21 -0
  155. package/dist/engine/pico8/index.d.ts.map +1 -0
  156. package/dist/engine/pico8/index.js +21 -0
  157. package/dist/engine/pico8/index.js.map +1 -0
  158. package/dist/engine/pico8/sentinel.d.ts +63 -0
  159. package/dist/engine/pico8/sentinel.d.ts.map +1 -0
  160. package/dist/engine/pico8/sentinel.js +86 -0
  161. package/dist/engine/pico8/sentinel.js.map +1 -0
  162. package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
  163. package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
  164. package/dist/engine/pico8/session-daemon-entry.js +11 -0
  165. package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
  166. package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
  167. package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
  168. package/dist/engine/pico8/session-daemon-main.js +71 -0
  169. package/dist/engine/pico8/session-daemon-main.js.map +1 -0
  170. package/dist/engine/pico8/session-daemon.d.ts +68 -0
  171. package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
  172. package/dist/engine/pico8/session-daemon.js +264 -0
  173. package/dist/engine/pico8/session-daemon.js.map +1 -0
  174. package/dist/engine/pico8/session.d.ts +140 -0
  175. package/dist/engine/pico8/session.d.ts.map +1 -0
  176. package/dist/engine/pico8/session.js +250 -0
  177. package/dist/engine/pico8/session.js.map +1 -0
  178. package/dist/engine/pico8/shell.d.ts +103 -0
  179. package/dist/engine/pico8/shell.d.ts.map +1 -0
  180. package/dist/engine/pico8/shell.js +324 -0
  181. package/dist/engine/pico8/shell.js.map +1 -0
  182. package/dist/engine/pico8/supervisor.d.ts +137 -0
  183. package/dist/engine/pico8/supervisor.d.ts.map +1 -0
  184. package/dist/engine/pico8/supervisor.js +140 -0
  185. package/dist/engine/pico8/supervisor.js.map +1 -0
  186. package/dist/engine/scaffold/index.d.ts +8 -0
  187. package/dist/engine/scaffold/index.d.ts.map +1 -0
  188. package/dist/engine/scaffold/index.js +8 -0
  189. package/dist/engine/scaffold/index.js.map +1 -0
  190. package/dist/engine/scaffold/scaffold.d.ts +49 -0
  191. package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
  192. package/dist/engine/scaffold/scaffold.js +259 -0
  193. package/dist/engine/scaffold/scaffold.js.map +1 -0
  194. package/dist/engine/shrinko/adapter.d.ts +167 -0
  195. package/dist/engine/shrinko/adapter.d.ts.map +1 -0
  196. package/dist/engine/shrinko/adapter.js +107 -0
  197. package/dist/engine/shrinko/adapter.js.map +1 -0
  198. package/dist/engine/shrinko/index.d.ts +13 -0
  199. package/dist/engine/shrinko/index.d.ts.map +1 -0
  200. package/dist/engine/shrinko/index.js +13 -0
  201. package/dist/engine/shrinko/index.js.map +1 -0
  202. package/dist/engine/shrinko/shell.d.ts +69 -0
  203. package/dist/engine/shrinko/shell.d.ts.map +1 -0
  204. package/dist/engine/shrinko/shell.js +156 -0
  205. package/dist/engine/shrinko/shell.js.map +1 -0
  206. package/dist/engine/skills/index.d.ts +8 -0
  207. package/dist/engine/skills/index.d.ts.map +1 -0
  208. package/dist/engine/skills/index.js +8 -0
  209. package/dist/engine/skills/index.js.map +1 -0
  210. package/dist/engine/skills/skills.d.ts +104 -0
  211. package/dist/engine/skills/skills.d.ts.map +1 -0
  212. package/dist/engine/skills/skills.js +149 -0
  213. package/dist/engine/skills/skills.js.map +1 -0
  214. package/dist/index.d.ts +10 -0
  215. package/dist/index.d.ts.map +1 -0
  216. package/dist/index.js +10 -0
  217. package/dist/index.js.map +1 -0
  218. package/dist/version.d.ts +2 -0
  219. package/dist/version.d.ts.map +1 -0
  220. package/dist/version.js +19 -0
  221. package/dist/version.js.map +1 -0
  222. package/package.json +48 -2
  223. package/src/bin.ts +4 -0
  224. package/src/cli.ts +61 -0
  225. package/src/commands/audio.test.ts +280 -0
  226. package/src/commands/audio.ts +410 -0
  227. package/src/commands/export.test.ts +310 -0
  228. package/src/commands/export.ts +316 -0
  229. package/src/commands/gfx-io.test.ts +178 -0
  230. package/src/commands/gfx.test.ts +470 -0
  231. package/src/commands/gfx.ts +645 -0
  232. package/src/commands/init.test.ts +482 -0
  233. package/src/commands/init.ts +193 -0
  234. package/src/commands/lint.test.ts +137 -0
  235. package/src/commands/lint.ts +117 -0
  236. package/src/commands/minify.test.ts +166 -0
  237. package/src/commands/minify.ts +156 -0
  238. package/src/commands/music.test.ts +272 -0
  239. package/src/commands/music.ts +288 -0
  240. package/src/commands/playtest.test.ts +317 -0
  241. package/src/commands/playtest.ts +773 -0
  242. package/src/commands/run.test.ts +264 -0
  243. package/src/commands/run.ts +285 -0
  244. package/src/commands/serve.test.ts +227 -0
  245. package/src/commands/serve.ts +272 -0
  246. package/src/commands/sfx.test.ts +349 -0
  247. package/src/commands/sfx.ts +300 -0
  248. package/src/commands/tokens.test.ts +212 -0
  249. package/src/commands/tokens.ts +168 -0
  250. package/src/commands/verify.test.ts +238 -0
  251. package/src/commands/verify.ts +256 -0
  252. package/src/commands/version.test.ts +70 -0
  253. package/src/commands/version.ts +24 -0
  254. package/src/engine/audio/index.ts +47 -0
  255. package/src/engine/audio/music.test.ts +188 -0
  256. package/src/engine/audio/music.ts +218 -0
  257. package/src/engine/audio/section.test.ts +124 -0
  258. package/src/engine/audio/section.ts +79 -0
  259. package/src/engine/audio/sfx.test.ts +410 -0
  260. package/src/engine/audio/sfx.ts +694 -0
  261. package/src/engine/cart/cart.test.ts +304 -0
  262. package/src/engine/cart/cart.ts +211 -0
  263. package/src/engine/cart/errors.ts +39 -0
  264. package/src/engine/cart/fixtures/crlf.p8 +6 -0
  265. package/src/engine/cart/fixtures/full.p8 +174 -0
  266. package/src/engine/cart/fixtures/header-only.p8 +2 -0
  267. package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
  268. package/src/engine/cart/fixtures/minimal.p8 +10 -0
  269. package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
  270. package/src/engine/cart/gfx.ts +195 -0
  271. package/src/engine/cart/index.ts +27 -0
  272. package/src/engine/cart/map.ts +134 -0
  273. package/src/engine/config/config.ts +115 -0
  274. package/src/engine/config/index.ts +12 -0
  275. package/src/engine/gfx/grid.test.ts +128 -0
  276. package/src/engine/gfx/grid.ts +136 -0
  277. package/src/engine/gfx/index.ts +34 -0
  278. package/src/engine/gfx/label.test.ts +105 -0
  279. package/src/engine/gfx/label.ts +229 -0
  280. package/src/engine/gfx/overlap.test.ts +71 -0
  281. package/src/engine/gfx/overlap.ts +66 -0
  282. package/src/engine/gfx/palette.test.ts +35 -0
  283. package/src/engine/gfx/palette.ts +54 -0
  284. package/src/engine/gfx/png.test.ts +111 -0
  285. package/src/engine/gfx/png.ts +196 -0
  286. package/src/engine/gfx/render.test.ts +180 -0
  287. package/src/engine/gfx/render.ts +106 -0
  288. package/src/engine/index.ts +21 -0
  289. package/src/engine/pico8/adapter.ts +303 -0
  290. package/src/engine/pico8/drive.test.ts +415 -0
  291. package/src/engine/pico8/drive.ts +729 -0
  292. package/src/engine/pico8/harness.test.ts +181 -0
  293. package/src/engine/pico8/harness.ts +354 -0
  294. package/src/engine/pico8/index.ts +20 -0
  295. package/src/engine/pico8/sentinel.test.ts +80 -0
  296. package/src/engine/pico8/sentinel.ts +93 -0
  297. package/src/engine/pico8/session-daemon-entry.ts +12 -0
  298. package/src/engine/pico8/session-daemon-main.ts +98 -0
  299. package/src/engine/pico8/session-daemon.test.ts +212 -0
  300. package/src/engine/pico8/session-daemon.ts +306 -0
  301. package/src/engine/pico8/session.test.ts +239 -0
  302. package/src/engine/pico8/session.ts +321 -0
  303. package/src/engine/pico8/shell.test.ts +583 -0
  304. package/src/engine/pico8/shell.ts +412 -0
  305. package/src/engine/pico8/supervisor.test.ts +105 -0
  306. package/src/engine/pico8/supervisor.ts +197 -0
  307. package/src/engine/scaffold/index.ts +15 -0
  308. package/src/engine/scaffold/scaffold.ts +278 -0
  309. package/src/engine/shrinko/adapter.ts +241 -0
  310. package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
  311. package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
  312. package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
  313. package/src/engine/shrinko/index.ts +33 -0
  314. package/src/engine/shrinko/shell.ts +224 -0
  315. package/src/engine/shrinko/shrinko.test.ts +182 -0
  316. package/src/engine/skills/index.ts +14 -0
  317. package/src/engine/skills/skills.test.ts +376 -0
  318. package/src/engine/skills/skills.ts +205 -0
  319. package/src/index.ts +13 -0
  320. package/src/skills/game-design-reference/SKILL.md +70 -0
  321. package/src/skills/game-jam/SKILL.md +45 -0
  322. package/src/skills/picopilot-art/SKILL.md +62 -0
  323. package/src/skills/picopilot-audio/SKILL.md +194 -0
  324. package/src/skills/picopilot-code/SKILL.md +97 -0
  325. package/src/skills/picopilot-code/reference/README.md +42 -0
  326. package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
  327. package/src/skills/picopilot-code/reference/platformer.md +119 -0
  328. package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
  329. package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
  330. package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
  331. package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
  332. package/src/skills/picopilot-debug/SKILL.md +195 -0
  333. package/src/skills/picopilot-overview/SKILL.md +52 -0
  334. package/src/version.ts +22 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The shrinko adapter SEAM: a typed interface every shrinko-backed command
3
+ * (`tokens`, and later `lint`, `minify`, `gfx import/export`, `verify`) talks
4
+ * to, plus the discriminated result shape they all return.
5
+ *
6
+ * The seam exists so the command layer never knows HOW shrinko is reached. The
7
+ * v1 implementation shells out to a user-installed Python `shrinko8` (see
8
+ * `shell.ts` and ADR-0001); a future native-TS implementation can implement the
9
+ * SAME {@link ShrinkoAdapter} interface and drop in without touching a single
10
+ * command. The command-facing contract is entirely in this file.
11
+ *
12
+ * The two-tier capability contract (ADR-0002) lives in the RESULT type: every
13
+ * adapter method returns a {@link ShrinkoResult}, a discriminated union of a
14
+ * success value OR the structured {@link ShrinkoNotFound} failure. Absent
15
+ * shrinko is therefore NOT an exception a command must try/catch, it is a
16
+ * first-class `{ok:false, reason:'shrinko-not-found', ...}` value a command maps
17
+ * onto incur's error envelope with a nonzero exit. Never a crash, never a hollow
18
+ * success.
19
+ */
20
+
21
+ /** The PyPI package name in the remedy: `uv pip install shrinko` (NOT `shrinko8`). */
22
+ export const SHRINKO_REMEDY = 'uv pip install shrinko' as const;
23
+
24
+ /** What shrinko needs to run: Python 3.8+ (the module is `shrinko8`). */
25
+ export const SHRINKO_NEEDS = ['python>=3.8'] as const;
26
+
27
+ /**
28
+ * The structured "shrinko is not installed" failure (ADR-0002). Its shape is
29
+ * fixed by the prd's two-tier contract (US #17): `ok:false`, a machine-readable
30
+ * `reason`, the EXACT `remedy` string, and the `needs` list. A command turns
31
+ * this into an incur `error({ code: reason, ... })` with a nonzero exit.
32
+ */
33
+ export interface ShrinkoNotFound {
34
+ readonly ok: false;
35
+ readonly reason: 'shrinko-not-found';
36
+ /** The EXACT remedy: `uv pip install shrinko` (package is `shrinko`, module `shrinko8`). */
37
+ readonly remedy: typeof SHRINKO_REMEDY;
38
+ /** Prerequisites the remedy assumes are present. */
39
+ readonly needs: readonly string[];
40
+ }
41
+
42
+ /** A successful adapter call carrying the parsed value. */
43
+ export interface ShrinkoOk<T> {
44
+ readonly ok: true;
45
+ readonly value: T;
46
+ }
47
+
48
+ /**
49
+ * The result every {@link ShrinkoAdapter} method returns: either a parsed value
50
+ * or the structured {@link ShrinkoNotFound}. Absence is a value, not a throw, so
51
+ * the two-tier contract is total and type-checked at every call site.
52
+ */
53
+ export type ShrinkoResult<T> = ShrinkoOk<T> | ShrinkoNotFound;
54
+
55
+ /**
56
+ * The parsed `--count` report (US #3). `tokens`/`chars`/`compressed` are the
57
+ * raw counts shrinko prints; `pct` fields are shrinko's own reported
58
+ * percentages (its `--count` prints `tokens: 8053 98%`, i.e. against PICO-8's
59
+ * limits), preserved verbatim so a command can show them without recomputing.
60
+ * `compressed`/`compressedPct` are optional because shrinko omits the
61
+ * compressed line for input formats where it does not apply.
62
+ */
63
+ export interface CountReport {
64
+ readonly tokens: number;
65
+ readonly tokensPct: number;
66
+ readonly chars: number;
67
+ readonly charsPct: number;
68
+ readonly compressed: number | undefined;
69
+ readonly compressedPct: number | undefined;
70
+ }
71
+
72
+ /**
73
+ * The shrinko capability seam. One method per shrinko-backed operation; only
74
+ * `count` exists in this task, the rest (lint, minify, convert) land with their
75
+ * commands and extend THIS interface rather than forking a new one.
76
+ *
77
+ * Every method is async (the shell-out is async) and returns a
78
+ * {@link ShrinkoResult}, so absence is handled uniformly at each seam.
79
+ */
80
+ export interface ShrinkoAdapter {
81
+ /**
82
+ * Runs shrinko's `--count` on the cart at `cartPath` and parses the
83
+ * `tokens/chars/compressed` report. Returns {@link ShrinkoNotFound} when
84
+ * shrinko is not installed.
85
+ */
86
+ count(cartPath: string): Promise<ShrinkoResult<CountReport>>;
87
+
88
+ /**
89
+ * Runs shrinko's `--lint` on the cart at `cartPath` and parses the warnings
90
+ * (undefined globals, unused/duplicate locals, etc.) into structured
91
+ * {@link LintFinding}s. Lint warnings are DATA, not a failure: the report is
92
+ * returned with `findings` (possibly empty), not a throw. Returns
93
+ * {@link ShrinkoNotFound} when shrinko is not installed.
94
+ */
95
+ lint(cartPath: string): Promise<ShrinkoResult<LintReport>>;
96
+
97
+ /**
98
+ * Runs shrinko's SAFE minification on `cartPath` and writes the result to
99
+ * `outPath` (a SEPARATE artifact; the source is never touched, see ADR-0007).
100
+ * Returns the before/after token counts. Returns {@link ShrinkoNotFound} when
101
+ * shrinko is not installed.
102
+ */
103
+ minify(
104
+ cartPath: string,
105
+ outPath: string,
106
+ ): Promise<ShrinkoResult<MinifyReport>>;
107
+
108
+ /**
109
+ * Exports the cart's spritesheet as a 128x128 PNG to `pngPath` (a raw
110
+ * round-trip image for external tools, distinct from `gfx render`'s upscaled
111
+ * JUDGE image). Returns {@link ShrinkoNotFound} when shrinko is not installed.
112
+ */
113
+ exportSpritesheet(
114
+ cartPath: string,
115
+ pngPath: string,
116
+ ): Promise<ShrinkoResult<void>>;
117
+
118
+ /**
119
+ * Imports a 128x128 spritesheet PNG into `srcCartPath`'s `__gfx__`, MERGING
120
+ * (only the gfx section is replaced; code/map/sfx/music are preserved) and
121
+ * writing the result to `outCartPath`. Returns {@link ShrinkoNotFound} when
122
+ * shrinko is not installed.
123
+ */
124
+ importSpritesheet(
125
+ srcCartPath: string,
126
+ pngPath: string,
127
+ outCartPath: string,
128
+ ): Promise<ShrinkoResult<void>>;
129
+ }
130
+
131
+ /** One shrinko lint warning, parsed from a `file:line:col: message` line. */
132
+ export interface LintFinding {
133
+ readonly line: number;
134
+ readonly col: number;
135
+ readonly message: string;
136
+ }
137
+
138
+ /** The parsed `--lint` result: every warning shrinko reported (empty when clean). */
139
+ export interface LintReport {
140
+ readonly findings: readonly LintFinding[];
141
+ }
142
+
143
+ /** The parsed `minify` result: token counts before and after (ADR-0007's delta report). */
144
+ export interface MinifyReport {
145
+ /** Tokens in the source cart. */
146
+ readonly beforeTokens: number;
147
+ /** Tokens in the minified artifact. */
148
+ readonly afterTokens: number;
149
+ /** Absolute path of the written minified cart. */
150
+ readonly outPath: string;
151
+ }
152
+
153
+ /** The frozen {@link ShrinkoNotFound} value, so every call site returns the same shape. */
154
+ export function shrinkoNotFound(): ShrinkoNotFound {
155
+ return {
156
+ ok: false,
157
+ reason: 'shrinko-not-found',
158
+ remedy: SHRINKO_REMEDY,
159
+ needs: [...SHRINKO_NEEDS],
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Parses shrinko's `--count` output into a {@link CountReport}.
165
+ *
166
+ * shrinko prints lines like:
167
+ *
168
+ * ```
169
+ * tokens: 8053 98%
170
+ * chars: 30320 46%
171
+ * compressed: 12176 77%
172
+ * ```
173
+ *
174
+ * (the compressed line is absent for some input formats). Parsing is
175
+ * line-oriented and tolerant of surrounding output/whitespace: it scans for the
176
+ * `tokens:`/`chars:`/`compressed:` labels anywhere in the combined stdout+stderr
177
+ * and pulls the count and percentage off each. Throws if the required
178
+ * `tokens:`/`chars:` lines are missing, so a shrinko that ran but produced
179
+ * unparseable output surfaces as a real error rather than a silent zero.
180
+ */
181
+ export function parseCount(output: string): CountReport {
182
+ const line = (label: string): {n: number; pct: number} | undefined => {
183
+ // `label: <count> <pct>%` (count and percent may be separated by any run
184
+ // of spaces; the `%` is optional-tolerant but shrinko always prints it).
185
+ const re = new RegExp(`^\\s*${label}:\\s*(\\d+)\\s+(\\d+)\\s*%`, 'm');
186
+ const m = re.exec(output);
187
+ if (m === null) return undefined;
188
+ return {n: Number(m[1]), pct: Number(m[2])};
189
+ };
190
+
191
+ const tokens = line('tokens');
192
+ const chars = line('chars');
193
+ if (tokens === undefined || chars === undefined) {
194
+ throw new ShrinkoParseError(
195
+ `could not parse shrinko --count output (missing tokens/chars lines):\n${output}`,
196
+ );
197
+ }
198
+ const compressed = line('compressed');
199
+
200
+ return {
201
+ tokens: tokens.n,
202
+ tokensPct: tokens.pct,
203
+ chars: chars.n,
204
+ charsPct: chars.pct,
205
+ compressed: compressed?.n,
206
+ compressedPct: compressed?.pct,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Parses shrinko's `--lint` output into {@link LintFinding}s. shrinko prints a
212
+ * `Lint warnings:` header then one `file:line:col: message` per warning; a clean
213
+ * cart prints nothing. Parsing scans the combined stdout+stderr for the
214
+ * `:line:col: message` shape (tolerant of the filename prefix, which we drop),
215
+ * so an empty result is a CLEAN cart, not an error. Unlike parseCount this never
216
+ * throws: no recognisable lines simply means zero findings.
217
+ */
218
+ export function parseLint(output: string): LintReport {
219
+ const findings: LintFinding[] = [];
220
+ // Match `<anything>:<line>:<col>: <message>` (the filename may itself contain
221
+ // colons/paths, so anchor on the two trailing numeric groups before the msg).
222
+ const re = /:(\d+):(\d+):\s*(.+?)\s*$/gm;
223
+ for (const m of output.matchAll(re)) {
224
+ findings.push({
225
+ line: Number(m[1]),
226
+ col: Number(m[2]),
227
+ message: m[3] ?? '',
228
+ });
229
+ }
230
+ return {findings};
231
+ }
232
+
233
+ /**
234
+ * Thrown when shrinko RAN but its output could not be parsed (a shrinko
235
+ * version/format drift), distinct from shrinko being ABSENT (which is the
236
+ * structured {@link ShrinkoNotFound} value, not a throw). Commands surface this
237
+ * as a `shrinko-failed` error, separate from `shrinko-not-found`.
238
+ */
239
+ export class ShrinkoParseError extends Error {
240
+ override readonly name = 'ShrinkoParseError';
241
+ }
@@ -0,0 +1,2 @@
1
+ tokens: 300 3%
2
+ chars: 1200 1%
@@ -0,0 +1,3 @@
1
+ tokens: 8053 98%
2
+ chars: 30320 46%
3
+ compressed: 12176 77%
@@ -0,0 +1,3 @@
1
+ tokens: 512 6%
2
+ chars: 1840 2%
3
+ compressed: 980 6%
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The shrinko adapter seam: how every shrinko-backed command reaches shrinko8.
3
+ *
4
+ * `adapter.ts` is the command-facing CONTRACT (the {@link ShrinkoAdapter}
5
+ * interface, the {@link ShrinkoResult} discriminated union, the structured
6
+ * {@link ShrinkoNotFound} value, and the `--count` parser). `shell.ts` is the v1
7
+ * IMPLEMENTATION that shells out to a user-installed Python `shrinko8`
8
+ * (ADR-0001). A future native-TS implementation implements the same interface
9
+ * and drops in without touching the command layer.
10
+ */
11
+ export {
12
+ type CountReport,
13
+ type LintFinding,
14
+ type LintReport,
15
+ type MinifyReport,
16
+ parseCount,
17
+ parseLint,
18
+ SHRINKO_NEEDS,
19
+ SHRINKO_REMEDY,
20
+ type ShrinkoAdapter,
21
+ type ShrinkoNotFound,
22
+ shrinkoNotFound,
23
+ type ShrinkoOk,
24
+ ShrinkoParseError,
25
+ type ShrinkoResult,
26
+ } from './adapter.js';
27
+ export {
28
+ type ChildResult,
29
+ type ChildRunner,
30
+ execFileRunner,
31
+ ShellShrinkoAdapter,
32
+ type ShellShrinkoOptions,
33
+ } from './shell.js';
@@ -0,0 +1,224 @@
1
+ import {execFile} from 'node:child_process';
2
+
3
+ import {
4
+ type CountReport,
5
+ type LintReport,
6
+ type MinifyReport,
7
+ parseCount,
8
+ parseLint,
9
+ type ShrinkoAdapter,
10
+ type ShrinkoResult,
11
+ shrinkoNotFound,
12
+ } from './adapter.js';
13
+
14
+ /**
15
+ * The result of running one child process: its exit `code` (null when killed by
16
+ * a signal), captured `stdout`/`stderr`, and `spawnError` set when the binary
17
+ * could not be launched AT ALL (ENOENT etc.) rather than launched-and-exited.
18
+ * `spawnError` is how the shell adapter distinguishes "shrinko is not installed"
19
+ * from "shrinko ran and failed".
20
+ */
21
+ export interface ChildResult {
22
+ readonly code: number | null;
23
+ readonly stdout: string;
24
+ readonly stderr: string;
25
+ readonly spawnError: NodeJS.ErrnoException | undefined;
26
+ }
27
+
28
+ /**
29
+ * The child-process seam. The real implementation shells out with the CALLER's
30
+ * `env` (so a test overrides the child's `PATH` and nothing else). Tests inject
31
+ * a stub runner to simulate shrinko present/absent WITHOUT ever requiring the
32
+ * real binary, and to assert exactly what argv/env the adapter would spawn.
33
+ */
34
+ export type ChildRunner = (
35
+ file: string,
36
+ args: string[],
37
+ env: NodeJS.ProcessEnv,
38
+ ) => Promise<ChildResult>;
39
+
40
+ /**
41
+ * The default {@link ChildRunner}: an `execFile` that captures stdout/stderr and
42
+ * NEVER rejects. A failed spawn (ENOENT) becomes `spawnError`; a nonzero exit
43
+ * becomes a `code`, so both outcomes are data the adapter maps to a structured
44
+ * result. The child inherits ONLY the `env` it is handed (the command layer
45
+ * passes the incur-resolved env, whose `PATH` a test can override) so there is
46
+ * no ambient-environment leak.
47
+ */
48
+ export const execFileRunner: ChildRunner = (file, args, env) =>
49
+ new Promise((resolve) => {
50
+ execFile(file, args, {env, encoding: 'utf8'}, (error, stdout, stderr) => {
51
+ const err = error as NodeJS.ErrnoException | null;
52
+ resolve({
53
+ // `execFile` sets error.code to the string 'ENOENT' on spawn failure and
54
+ // to the numeric exit code on a nonzero exit; separate the two.
55
+ code:
56
+ err !== null && typeof err.code === 'number'
57
+ ? err.code
58
+ : err === null
59
+ ? 0
60
+ : null,
61
+ stdout,
62
+ stderr,
63
+ spawnError:
64
+ err !== null && typeof err.code === 'string' ? err : undefined,
65
+ });
66
+ });
67
+ });
68
+
69
+ /**
70
+ * The candidate ways to invoke shrinko, tried in order. First the `shrinko8`
71
+ * entry-point on `PATH`, then `python -m shrinko8` / `python3 -m shrinko8` as
72
+ * fallbacks (the module is `shrinko8`; the pip package is `shrinko`). The first
73
+ * candidate that SPAWNS (does not ENOENT) is the one shrinko is reached through.
74
+ */
75
+ const SHRINKO_INVOCATIONS: readonly (readonly string[])[] = [
76
+ ['shrinko8'],
77
+ ['python', '-m', 'shrinko8'],
78
+ ['python3', '-m', 'shrinko8'],
79
+ ];
80
+
81
+ /** Options for {@link ShellShrinkoAdapter}. */
82
+ export interface ShellShrinkoOptions {
83
+ /**
84
+ * The environment handed to the child process. The command layer passes the
85
+ * incur-resolved env here; its `PATH` is the isolation lever a test overrides
86
+ * (an empty/`PATH`-less env makes every invocation ENOENT ⇒ shrinko-absent).
87
+ */
88
+ readonly env: NodeJS.ProcessEnv;
89
+ /** The child runner (defaults to {@link execFileRunner}); tests inject a stub. */
90
+ readonly run?: ChildRunner;
91
+ }
92
+
93
+ /**
94
+ * The v1 {@link ShrinkoAdapter}: shells out to a user-installed Python
95
+ * `shrinko8` (ADR-0001). It is the ONLY place that knows shrinko is an external
96
+ * process; every command talks to the {@link ShrinkoAdapter} interface, so a
97
+ * future native-TS adapter replaces this class without touching a command.
98
+ *
99
+ * Presence detection and invocation are the SAME act: the adapter tries each
100
+ * {@link SHRINKO_INVOCATIONS} candidate and uses the first that spawns. If every
101
+ * candidate ENOENTs, shrinko is absent and the method returns the structured
102
+ * {@link shrinkoNotFound} value (never a throw, never a hollow success).
103
+ */
104
+ export class ShellShrinkoAdapter implements ShrinkoAdapter {
105
+ private readonly env: NodeJS.ProcessEnv;
106
+ private readonly run: ChildRunner;
107
+
108
+ constructor(options: ShellShrinkoOptions) {
109
+ this.env = options.env;
110
+ this.run = options.run ?? execFileRunner;
111
+ }
112
+
113
+ async count(cartPath: string): Promise<ShrinkoResult<CountReport>> {
114
+ const outcome = await this.invoke([cartPath, '--count']);
115
+ if (outcome === undefined) {
116
+ return shrinkoNotFound();
117
+ }
118
+ // shrinko prints the count report; some builds send it to stderr, so parse
119
+ // the combined stream. parseCount throws on unparseable output (ShrinkoParseError).
120
+ return {
121
+ ok: true,
122
+ value: parseCount(`${outcome.stdout}\n${outcome.stderr}`),
123
+ };
124
+ }
125
+
126
+ async lint(cartPath: string): Promise<ShrinkoResult<LintReport>> {
127
+ // `-l` lints; it exits nonzero (2) WHEN it finds warnings, which is a normal
128
+ // result (findings are data), NOT a spawn failure. Only a spawnError means
129
+ // shrinko is absent, so we key absence off `outcome === undefined`, and parse
130
+ // the warnings regardless of the (0-or-2) exit code.
131
+ const outcome = await this.invoke([cartPath, '-l']);
132
+ if (outcome === undefined) {
133
+ return shrinkoNotFound();
134
+ }
135
+ return {
136
+ ok: true,
137
+ value: parseLint(`${outcome.stdout}\n${outcome.stderr}`),
138
+ };
139
+ }
140
+
141
+ async minify(
142
+ cartPath: string,
143
+ outPath: string,
144
+ ): Promise<ShrinkoResult<MinifyReport>> {
145
+ // Count the source first (the "before"): if shrinko is absent, this returns
146
+ // the not-found value and we never write anything.
147
+ const before = await this.count(cartPath);
148
+ if (!before.ok) return before;
149
+
150
+ // Minify SAFE-only (ADR-0007): the three safe-only knobs keep rename/reorder/
151
+ // builtins behaviour-preserving. Output goes to a SEPARATE cart (outPath).
152
+ const out = await this.invoke([
153
+ cartPath,
154
+ '-m',
155
+ '--rename-safe-only',
156
+ '--reorder-safe-only',
157
+ '--builtins-safe-only',
158
+ outPath,
159
+ ]);
160
+ if (out === undefined) return shrinkoNotFound();
161
+
162
+ // Count the artifact (the "after"). It exists now, so this is a normal count.
163
+ const after = await this.count(outPath);
164
+ if (!after.ok) return after;
165
+
166
+ return {
167
+ ok: true,
168
+ value: {
169
+ beforeTokens: before.value.tokens,
170
+ afterTokens: after.value.tokens,
171
+ outPath,
172
+ },
173
+ };
174
+ }
175
+
176
+ async exportSpritesheet(
177
+ cartPath: string,
178
+ pngPath: string,
179
+ ): Promise<ShrinkoResult<void>> {
180
+ // `<cart> <out.png> -f spritesheet` writes the 128x128 spritesheet PNG.
181
+ const outcome = await this.invoke([cartPath, pngPath, '-f', 'spritesheet']);
182
+ if (outcome === undefined) return shrinkoNotFound();
183
+ return {ok: true, value: undefined};
184
+ }
185
+
186
+ async importSpritesheet(
187
+ srcCartPath: string,
188
+ pngPath: string,
189
+ outCartPath: string,
190
+ ): Promise<ShrinkoResult<void>> {
191
+ // `<cart> <out.p8> --merge <png> gfx spritesheet` merges ONLY the gfx section
192
+ // from the PNG into the source cart, preserving code/map/sfx/music. (A plain
193
+ // `-F spritesheet` would drop everything but gfx, wiping the user's code.)
194
+ const outcome = await this.invoke([
195
+ srcCartPath,
196
+ outCartPath,
197
+ '--merge',
198
+ pngPath,
199
+ 'gfx',
200
+ 'spritesheet',
201
+ ]);
202
+ if (outcome === undefined) return shrinkoNotFound();
203
+ return {ok: true, value: undefined};
204
+ }
205
+
206
+ /**
207
+ * Runs shrinko with the given trailing args, trying each invocation candidate
208
+ * until one SPAWNS. Returns the {@link ChildResult} of the first candidate
209
+ * that launched, or `undefined` when every candidate ENOENTs (shrinko absent).
210
+ */
211
+ private async invoke(args: string[]): Promise<ChildResult | undefined> {
212
+ for (const inv of SHRINKO_INVOCATIONS) {
213
+ const [file, ...prefix] = inv as [string, ...string[]];
214
+ const result = await this.run(file, [...prefix, ...args], this.env);
215
+ if (result.spawnError === undefined) {
216
+ return result;
217
+ }
218
+ // ENOENT on this candidate: fall through to the next. Any other spawn
219
+ // error (EACCES etc.) also falls through, so a broken candidate never
220
+ // masks a working one.
221
+ }
222
+ return undefined;
223
+ }
224
+ }
@@ -0,0 +1,182 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {fileURLToPath} from 'node:url';
3
+ import {describe, expect, it} from 'vitest';
4
+
5
+ import {
6
+ type ChildResult,
7
+ type ChildRunner,
8
+ parseCount,
9
+ SHRINKO_REMEDY,
10
+ ShellShrinkoAdapter,
11
+ ShrinkoParseError,
12
+ } from './index.js';
13
+
14
+ /** Reads a captured `--count` sample as raw text. */
15
+ function sample(name: string): string {
16
+ return readFileSync(
17
+ fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)),
18
+ 'utf8',
19
+ );
20
+ }
21
+
22
+ /** A recorded spawn, so tests can assert the exact argv + env the adapter used. */
23
+ interface Spawn {
24
+ file: string;
25
+ args: string[];
26
+ env: NodeJS.ProcessEnv;
27
+ }
28
+
29
+ /**
30
+ * Builds a stub {@link ChildRunner} that returns a fixed {@link ChildResult} for
31
+ * a chosen `file`, and ENOENT for every other candidate. Records each spawn so a
32
+ * test can assert which invocation was used and with what env. This is how every
33
+ * test drives shrinko present/absent WITHOUT the real binary.
34
+ */
35
+ function stubRunner(opts: {
36
+ presentFile?: string;
37
+ stdout?: string;
38
+ stderr?: string;
39
+ code?: number;
40
+ }): {run: ChildRunner; spawns: Spawn[]} {
41
+ const spawns: Spawn[] = [];
42
+ const run: ChildRunner = async (file, args, env) => {
43
+ spawns.push({file, args, env});
44
+ if (opts.presentFile !== undefined && file === opts.presentFile) {
45
+ return {
46
+ code: opts.code ?? 0,
47
+ stdout: opts.stdout ?? '',
48
+ stderr: opts.stderr ?? '',
49
+ spawnError: undefined,
50
+ } satisfies ChildResult;
51
+ }
52
+ // Not the present binary → simulate ENOENT (not installed on PATH).
53
+ const err = Object.assign(new Error(`spawn ${file} ENOENT`), {
54
+ code: 'ENOENT',
55
+ }) as NodeJS.ErrnoException;
56
+ return {code: null, stdout: '', stderr: '', spawnError: err};
57
+ };
58
+ return {run, spawns};
59
+ }
60
+
61
+ describe('parseCount: parses shrinko --count output', () => {
62
+ it('parses the documented over-budget sample', () => {
63
+ const report = parseCount(sample('count-over-budget.txt'));
64
+ expect(report).toEqual({
65
+ tokens: 8053,
66
+ tokensPct: 98,
67
+ chars: 30320,
68
+ charsPct: 46,
69
+ compressed: 12176,
70
+ compressedPct: 77,
71
+ });
72
+ });
73
+
74
+ it('parses a sample with no compressed line (compressed undefined)', () => {
75
+ const report = parseCount(sample('count-no-compressed.txt'));
76
+ expect(report.tokens).toBe(300);
77
+ expect(report.chars).toBe(1200);
78
+ expect(report.compressed).toBeUndefined();
79
+ expect(report.compressedPct).toBeUndefined();
80
+ });
81
+
82
+ it('parses the count even when embedded in surrounding output/whitespace', () => {
83
+ const noisy = `some banner line\n\n tokens: 8053 98%\n chars: 30320 46%\n compressed: 12176 77%\ntrailing\n`;
84
+ const report = parseCount(noisy);
85
+ expect(report.tokens).toBe(8053);
86
+ expect(report.chars).toBe(30320);
87
+ expect(report.compressed).toBe(12176);
88
+ });
89
+
90
+ it('throws ShrinkoParseError when the required lines are absent', () => {
91
+ expect(() => parseCount('unexpected shrinko output\n')).toThrow(
92
+ ShrinkoParseError,
93
+ );
94
+ });
95
+ });
96
+
97
+ describe('ShellShrinkoAdapter.count: present', () => {
98
+ it('parses --count from the shrinko8 entry-point on PATH', async () => {
99
+ const {run, spawns} = stubRunner({
100
+ presentFile: 'shrinko8',
101
+ stdout: sample('count-over-budget.txt'),
102
+ });
103
+ const adapter = new ShellShrinkoAdapter({env: {PATH: '/fake/bin'}, run});
104
+ const result = await adapter.count('/carts/main.p8');
105
+
106
+ expect(result.ok).toBe(true);
107
+ if (result.ok) {
108
+ expect(result.value.tokens).toBe(8053);
109
+ expect(result.value.chars).toBe(30320);
110
+ expect(result.value.compressed).toBe(12176);
111
+ }
112
+ // It spawned `shrinko8 /carts/main.p8 --count` first and stopped there.
113
+ expect(spawns[0]?.file).toBe('shrinko8');
114
+ expect(spawns[0]?.args).toEqual(['/carts/main.p8', '--count']);
115
+ });
116
+
117
+ it('parses --count even when shrinko prints to stderr', async () => {
118
+ const {run} = stubRunner({
119
+ presentFile: 'shrinko8',
120
+ stdout: '',
121
+ stderr: sample('count-under-budget.txt'),
122
+ });
123
+ const adapter = new ShellShrinkoAdapter({env: {PATH: '/fake/bin'}, run});
124
+ const result = await adapter.count('/carts/main.p8');
125
+ expect(result.ok).toBe(true);
126
+ if (result.ok) expect(result.value.tokens).toBe(512);
127
+ });
128
+
129
+ it('falls back to `python -m shrinko8` when shrinko8 is not on PATH', async () => {
130
+ const {run, spawns} = stubRunner({
131
+ presentFile: 'python',
132
+ stdout: sample('count-over-budget.txt'),
133
+ });
134
+ const adapter = new ShellShrinkoAdapter({env: {PATH: '/fake/bin'}, run});
135
+ const result = await adapter.count('/carts/main.p8');
136
+
137
+ expect(result.ok).toBe(true);
138
+ // First tried shrinko8 (ENOENT), then python -m shrinko8 (spawned).
139
+ expect(spawns.map((s) => s.file)).toEqual(['shrinko8', 'python']);
140
+ expect(spawns[1]?.args).toEqual([
141
+ '-m',
142
+ 'shrinko8',
143
+ '/carts/main.p8',
144
+ '--count',
145
+ ]);
146
+ });
147
+ });
148
+
149
+ describe('ShellShrinkoAdapter.count: absent (structured, never a crash)', () => {
150
+ it('returns shrinko-not-found with the EXACT remedy when nothing spawns', async () => {
151
+ // Every candidate ENOENTs (an empty PATH is the isolation lever).
152
+ const {run, spawns} = stubRunner({presentFile: undefined});
153
+ const adapter = new ShellShrinkoAdapter({env: {}, run});
154
+ const result = await adapter.count('/carts/main.p8');
155
+
156
+ expect(result.ok).toBe(false);
157
+ if (!result.ok) {
158
+ expect(result.reason).toBe('shrinko-not-found');
159
+ expect(result.remedy).toBe(SHRINKO_REMEDY);
160
+ expect(result.remedy).toBe('uv pip install shrinko');
161
+ expect(result.needs).toEqual(['python>=3.8']);
162
+ }
163
+ // It tried every invocation candidate before giving up.
164
+ expect(spawns.map((s) => s.file)).toEqual([
165
+ 'shrinko8',
166
+ 'python',
167
+ 'python3',
168
+ ]);
169
+ });
170
+
171
+ it('passes ONLY the given env to the child (the PATH isolation lever)', async () => {
172
+ const {run, spawns} = stubRunner({
173
+ presentFile: 'shrinko8',
174
+ stdout: sample('count-over-budget.txt'),
175
+ });
176
+ const env = {PATH: '/only/this/bin', SOME_OTHER: 'x'};
177
+ const adapter = new ShellShrinkoAdapter({env, run});
178
+ await adapter.count('/carts/main.p8');
179
+ // The child sees exactly the env we handed the adapter, nothing ambient.
180
+ expect(spawns[0]?.env).toEqual(env);
181
+ });
182
+ });