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,212 @@
1
+ import {execFileSync} from 'node:child_process';
2
+ import {mkdtempSync, writeFileSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {Cli} from 'incur';
6
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
7
+
8
+ import type {
9
+ CountReport,
10
+ ShrinkoAdapter,
11
+ ShrinkoResult,
12
+ } from '../engine/shrinko/index.js';
13
+ import {shrinkoNotFound} from '../engine/shrinko/index.js';
14
+ import {type ShrinkoAdapterFactory, registerTokens} from './tokens.js';
15
+
16
+ /** A trivial but valid cart the command reads (existence + a path to hand shrinko). */
17
+ const CART_TEXT =
18
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n';
19
+
20
+ let dir: string;
21
+ let cartPath: string;
22
+
23
+ beforeEach(() => {
24
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-tokens-'));
25
+ cartPath = join(dir, 'main.p8');
26
+ writeFileSync(cartPath, CART_TEXT);
27
+ });
28
+
29
+ afterEach(() => {
30
+ try {
31
+ execFileSync('rm', ['-rf', dir]);
32
+ } catch {
33
+ // ignore
34
+ }
35
+ });
36
+
37
+ /**
38
+ * A stub {@link ShrinkoAdapter} standing in for BOTH a shrinko-present shell
39
+ * adapter and a hypothetical native-TS adapter. It never spawns anything, so no
40
+ * test depends on shrinko being installed. `count` returns whatever result the
41
+ * test wired.
42
+ */
43
+ function stubAdapter(result: ShrinkoResult<CountReport>): ShrinkoAdapter {
44
+ return {
45
+ async count() {
46
+ return result;
47
+ },
48
+ };
49
+ }
50
+
51
+ function present(over: boolean): ShrinkoResult<CountReport> {
52
+ return {
53
+ ok: true,
54
+ // Over: 9001 tokens > the 8192 budget. Under: a small cart.
55
+ value: over
56
+ ? {
57
+ tokens: 9001,
58
+ tokensPct: 109,
59
+ chars: 34000,
60
+ charsPct: 52,
61
+ compressed: 13000,
62
+ compressedPct: 82,
63
+ }
64
+ : {
65
+ tokens: 512,
66
+ tokensPct: 6,
67
+ chars: 1840,
68
+ charsPct: 2,
69
+ compressed: 980,
70
+ compressedPct: 6,
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Drives `picopilot tokens` through incur's `serve` DI with an INJECTED adapter
77
+ * factory (the seam), capturing stdout + exit without the real environment or
78
+ * the real binary. `env` defaults to a bare PATH the (stub) adapter ignores.
79
+ */
80
+ async function runTokens(
81
+ factory: ShrinkoAdapterFactory,
82
+ argv: string[] = ['tokens', cartPath, '--json'],
83
+ env: Record<string, string | undefined> = {PATH: '/does/not/matter'},
84
+ ) {
85
+ // Use a UNIQUE cli name (not the real 'picopilot'), so incur's skills-freshness
86
+ // check (`SyncSkills.readHash(name)` + `hasInstalledSkills(name)`) finds NO
87
+ // stored hash for it and never attaches a "Skills are out of date" CTA. Under
88
+ // the full parallel run the real `picopilot` skill hash (in $XDG_DATA_HOME) goes
89
+ // stale (the skills test installs/removes skills concurrently), which would
90
+ // otherwise inject that ambient CTA and flakily break `expect(cta).toBeUndefined()`.
91
+ // A unit test of `tokens` must not depend on the machine's installed-skills state.
92
+ const cli = Cli.create('picopilot-tokens-test', {version: '0.0.0'});
93
+ registerTokens(cli, factory);
94
+ let stdout = '';
95
+ let exitCode = 0;
96
+ await cli.serve(argv, {
97
+ stdout(s) {
98
+ stdout += s;
99
+ },
100
+ exit(code) {
101
+ exitCode = code;
102
+ },
103
+ env,
104
+ });
105
+ return {stdout, exitCode};
106
+ }
107
+
108
+ describe('picopilot tokens: present (parses --count into the budget struct)', () => {
109
+ it('reports {tokens, pct, chars, compressed} under budget with no minify CTA', async () => {
110
+ const {stdout, exitCode} = await runTokens(() =>
111
+ stubAdapter(present(false)),
112
+ );
113
+ expect(exitCode).toBe(0);
114
+ const out = JSON.parse(stdout);
115
+ expect(out.tokens).toBe(512);
116
+ expect(out.chars).toBe(1840);
117
+ expect(out.compressed).toBe(980);
118
+ expect(out.budget).toBe(8192);
119
+ expect(out.overBudget).toBe(false);
120
+ // 512 / 8192 ≈ 6%.
121
+ expect(out.pct).toBe(6);
122
+ // Under budget: no minify CTA.
123
+ expect(out.cta).toBeUndefined();
124
+ expect(JSON.stringify(out)).not.toContain('minify');
125
+ });
126
+
127
+ it('reports over budget and CTAs to minify', async () => {
128
+ const {stdout, exitCode} = await runTokens(() =>
129
+ stubAdapter(present(true)),
130
+ );
131
+ // Over budget is still a successful count (exit 0); the CTA drives the fix.
132
+ expect(exitCode).toBe(0);
133
+ const out = JSON.parse(stdout);
134
+ expect(out.tokens).toBe(9001);
135
+ expect(out.overBudget).toBe(true);
136
+ // 9001 / 8192 ≈ 110%.
137
+ expect(out.pct).toBe(110);
138
+ // The over-budget → minify CTA is present (incur prefixes the CLI name).
139
+ expect(out.cta.commands[0].command).toContain('minify');
140
+ });
141
+ });
142
+
143
+ describe('picopilot tokens: absent (structured shrinko-not-found + nonzero exit)', () => {
144
+ it('returns the exact structured failure and a nonzero exit, never a crash', async () => {
145
+ const {stdout, exitCode} = await runTokens(() =>
146
+ stubAdapter(shrinkoNotFound()),
147
+ );
148
+ expect(exitCode).not.toBe(0);
149
+ const out = JSON.parse(stdout);
150
+ // incur error envelope: the reason is the machine code, the exact remedy is
151
+ // carried in the message so an agent reading only the message learns the fix.
152
+ expect(out.code).toBe('shrinko-not-found');
153
+ expect(out.message).toContain('uv pip install shrinko');
154
+ expect(out.message).toContain('python>=3.8');
155
+ });
156
+ });
157
+
158
+ describe('picopilot tokens: seam-swap (a stub adapter leaves output/exit unchanged)', () => {
159
+ it('produces byte-identical public output whether the adapter is A or B', async () => {
160
+ // Two DIFFERENT adapter implementations returning the SAME count value must
161
+ // yield the SAME command output + exit: the command depends only on the
162
+ // interface, so a native-TS shrinko drops in without a visible change.
163
+ const value = present(true);
164
+ // A: returns the value synchronously-resolved.
165
+ const adapterA: ShrinkoAdapter = {
166
+ async count() {
167
+ return value;
168
+ },
169
+ };
170
+ // B: a DIFFERENT implementation (extra async work, freshly-built struct) that
171
+ // yields the same count. Stands in for a native-TS shrinko behind the seam.
172
+ const adapterB: ShrinkoAdapter = {
173
+ async count() {
174
+ await Promise.resolve();
175
+ const v = value.ok ? value.value : undefined;
176
+ if (v === undefined) throw new Error('unreachable');
177
+ return {ok: true, value: {...v}};
178
+ },
179
+ };
180
+
181
+ const a = await runTokens(() => adapterA);
182
+ const b = await runTokens(() => adapterB);
183
+ expect(a.stdout).toBe(b.stdout);
184
+ expect(a.exitCode).toBe(b.exitCode);
185
+ });
186
+
187
+ it('the injected factory receives the incur-resolved env (PATH isolation lever)', async () => {
188
+ let seenEnv: NodeJS.ProcessEnv | undefined;
189
+ const factory: ShrinkoAdapterFactory = (env) => {
190
+ seenEnv = env;
191
+ return stubAdapter(present(false));
192
+ };
193
+ await runTokens(factory, ['tokens', cartPath, '--json'], {
194
+ PATH: '/isolated/bin',
195
+ });
196
+ // The command handed the adapter exactly the overridden env, so the child's
197
+ // PATH is controllable from the test without touching the real environment.
198
+ expect(seenEnv?.PATH).toBe('/isolated/bin');
199
+ });
200
+ });
201
+
202
+ describe('picopilot tokens: a missing cart is a distinct error (not shrinko-not-found)', () => {
203
+ it('errors with cart-not-found + nonzero exit when the cart path does not exist', async () => {
204
+ const {stdout, exitCode} = await runTokens(
205
+ () => stubAdapter(present(false)),
206
+ ['tokens', join(dir, 'nope.p8'), '--json'],
207
+ );
208
+ expect(exitCode).not.toBe(0);
209
+ const out = JSON.parse(stdout);
210
+ expect(out.code).toBe('cart-not-found');
211
+ });
212
+ });
@@ -0,0 +1,168 @@
1
+ import {existsSync} from 'node:fs';
2
+ import {isAbsolute, resolve} from 'node:path';
3
+ import {type Cli, z} from 'incur';
4
+
5
+ import {
6
+ type ShrinkoAdapter,
7
+ ShrinkoParseError,
8
+ ShellShrinkoAdapter,
9
+ } from '../engine/shrinko/index.js';
10
+
11
+ /** PICO-8's Lua token budget. A cart over this will not load in PICO-8 (US #3). */
12
+ export const TOKEN_BUDGET = 8192;
13
+
14
+ /**
15
+ * Builds the {@link ShrinkoAdapter} a command uses, given the incur-resolved
16
+ * env. The default is the shell-out {@link ShellShrinkoAdapter}, whose child
17
+ * process inherits ONLY this `env` (its `PATH` is the test isolation lever).
18
+ *
19
+ * A test passes its own factory to {@link registerTokens} to SWAP a stub
20
+ * adapter (the seam-swap contract): the command's public output/exit must be
21
+ * unchanged whether the real shell adapter or a native-TS stub is behind the
22
+ * seam, because the command only ever talks to the {@link ShrinkoAdapter}
23
+ * interface.
24
+ */
25
+ export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
26
+
27
+ const defaultAdapterFactory: ShrinkoAdapterFactory = (env) =>
28
+ new ShellShrinkoAdapter({env});
29
+
30
+ /**
31
+ * Registers `picopilot tokens` (US #3), the first shrinko-backed command.
32
+ *
33
+ * It reports `{tokens, pct, chars, compressed}` for a cart against the 8,192
34
+ * token budget via shrinko8 `--count`, and CTAs to `minify` when over budget so
35
+ * the agent is led straight to the fix (the detect-then-fix loop, ADR CTAs).
36
+ *
37
+ * Two-tier contract (ADR-0002): `tokens` is shrinko-BACKED, so with shrinko
38
+ * ABSENT it returns the structured `shrinko-not-found` error (nonzero exit,
39
+ * exact remedy `uv pip install shrinko`) rather than crashing or reporting a
40
+ * hollow zero. The dependency is reached entirely through the {@link
41
+ * ShrinkoAdapter} seam, so a native-TS shrinko would satisfy this command
42
+ * unchanged.
43
+ *
44
+ * @param adapterFactory injects the adapter (defaults to the shell adapter);
45
+ * tests pass a stub to swap the seam and to drive present/absent without the
46
+ * real binary.
47
+ */
48
+ export function registerTokens(
49
+ cli: Cli.Cli,
50
+ adapterFactory: ShrinkoAdapterFactory = defaultAdapterFactory,
51
+ ): void {
52
+ cli.command('tokens', {
53
+ description:
54
+ 'Count a cart against the 8,192-token PICO-8 budget (via shrinko8 --count). Requires shrinko.',
55
+ args: z.object({
56
+ cart: z
57
+ .string()
58
+ .default('main.p8')
59
+ .describe(
60
+ 'The .p8 cart to count. Defaults to main.p8 in the current folder.',
61
+ ),
62
+ }),
63
+ // Declaring PATH here makes incur resolve it from the (test-overridable)
64
+ // env source and hand it to us, so we pass a controlled PATH to the child.
65
+ env: z.object({
66
+ PATH: z
67
+ .string()
68
+ .optional()
69
+ .describe('Used to locate shrinko8 in the child process.'),
70
+ }),
71
+ output: z.object({
72
+ tokens: z.number().describe('The token count shrinko reported.'),
73
+ pct: z
74
+ .number()
75
+ .describe(
76
+ 'tokens as a percentage of the 8,192 budget (rounded); >100 means over budget.',
77
+ ),
78
+ chars: z.number().describe('The character count shrinko reported.'),
79
+ compressed: z
80
+ .number()
81
+ .optional()
82
+ .describe('The compressed byte count (absent for some input formats).'),
83
+ budget: z
84
+ .number()
85
+ .describe('The token budget the percentage is against (8192).'),
86
+ overBudget: z.boolean().describe('True when tokens exceed the budget.'),
87
+ }),
88
+ examples: [
89
+ {description: 'Count main.p8'},
90
+ {description: 'Count a specific cart', args: {cart: 'game.p8'}},
91
+ ],
92
+ async run({args, env, error, ok}) {
93
+ const cartPath = isAbsolute(args.cart)
94
+ ? args.cart
95
+ : resolve(process.cwd(), args.cart);
96
+
97
+ // A missing cart is a picopilot-side error, distinct from shrinko absence:
98
+ // surface it clearly instead of letting shrinko fail opaquely on the path.
99
+ if (!existsSync(cartPath)) {
100
+ return error({
101
+ code: 'cart-not-found',
102
+ message: `no cart at ${cartPath}`,
103
+ exitCode: 1,
104
+ });
105
+ }
106
+
107
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv);
108
+
109
+ let result: Awaited<ReturnType<ShrinkoAdapter['count']>>;
110
+ try {
111
+ result = await adapter.count(cartPath);
112
+ } catch (e) {
113
+ // shrinko RAN but its output was unparseable (version/format drift):
114
+ // a distinct `shrinko-failed`, NOT `shrinko-not-found` (which is absence).
115
+ if (e instanceof ShrinkoParseError) {
116
+ return error({
117
+ code: 'shrinko-failed',
118
+ message: `shrinko ran but its --count output could not be parsed: ${e.message}`,
119
+ exitCode: 1,
120
+ });
121
+ }
122
+ throw e;
123
+ }
124
+
125
+ if (!result.ok) {
126
+ // The two-tier structured failure (ADR-0002 / US #17): shrinko absent.
127
+ // The message CARRIES the exact remedy + needs so an agent reading only
128
+ // the message still learns the fix; the code is the machine handle.
129
+ return error({
130
+ code: result.reason,
131
+ message: `shrinko is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
132
+ exitCode: 1,
133
+ });
134
+ }
135
+
136
+ const {tokens, chars, compressed} = result.value;
137
+ const pct = Math.round((tokens / TOKEN_BUDGET) * 100);
138
+ const overBudget = tokens > TOKEN_BUDGET;
139
+
140
+ // Over budget → CTA to `minify` (the detect-then-fix loop). Under budget
141
+ // needs no CTA: nothing to do.
142
+ const cta = overBudget
143
+ ? {
144
+ description: 'Over the 8,192-token budget. Reclaim tokens with:',
145
+ commands: [
146
+ {
147
+ command: 'minify',
148
+ description:
149
+ 'Safe-minify the cart to reduce tokens without changing behaviour.',
150
+ },
151
+ ],
152
+ }
153
+ : undefined;
154
+
155
+ return ok(
156
+ {
157
+ tokens,
158
+ pct,
159
+ chars,
160
+ compressed,
161
+ budget: TOKEN_BUDGET,
162
+ overBudget,
163
+ },
164
+ cta === undefined ? undefined : {cta},
165
+ );
166
+ },
167
+ });
168
+ }
@@ -0,0 +1,238 @@
1
+ import {execFileSync} from 'node:child_process';
2
+ import {mkdtempSync, writeFileSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {Cli} from 'incur';
6
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
7
+
8
+ import type {
9
+ CountReport,
10
+ ShrinkoAdapter,
11
+ ShrinkoResult,
12
+ } from '../engine/shrinko/index.js';
13
+ import {shrinkoNotFound} from '../engine/shrinko/index.js';
14
+ import {
15
+ GATE_INCAPABLE_EXIT,
16
+ type ShrinkoAdapterFactory,
17
+ registerVerify,
18
+ } from './verify.js';
19
+
20
+ /** A well-formed cart: parses AND round-trips cleanly (passes integrity). */
21
+ const GOOD_CART =
22
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n';
23
+
24
+ /**
25
+ * A MALFORMED cart: no `version N` line after the header, so `Cart.parse`
26
+ * throws a `missing-version` `CartParseError` and integrity fails.
27
+ */
28
+ const MALFORMED_CART =
29
+ 'pico-8 cartridge // http://www.pico-8.com\n__lua__\nprint("hi")\n';
30
+
31
+ let dir: string;
32
+ let cartPath: string;
33
+
34
+ beforeEach(() => {
35
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-verify-'));
36
+ cartPath = join(dir, 'main.p8');
37
+ writeFileSync(cartPath, GOOD_CART);
38
+ });
39
+
40
+ afterEach(() => {
41
+ try {
42
+ execFileSync('rm', ['-rf', dir]);
43
+ } catch {
44
+ // ignore
45
+ }
46
+ });
47
+
48
+ /**
49
+ * A stub {@link ShrinkoAdapter} standing in for a shrinko-present shell adapter
50
+ * (or a native-TS one). It never spawns anything, so no test depends on shrinko
51
+ * being installed. `count` returns whatever result the test wired.
52
+ */
53
+ function stubAdapter(result: ShrinkoResult<CountReport>): ShrinkoAdapter {
54
+ return {
55
+ async count() {
56
+ return result;
57
+ },
58
+ };
59
+ }
60
+
61
+ /** A present-shrinko count: `over` picks an over-budget vs under-budget value. */
62
+ function present(over: boolean): ShrinkoResult<CountReport> {
63
+ return {
64
+ ok: true,
65
+ value: over
66
+ ? {
67
+ tokens: 9001,
68
+ tokensPct: 109,
69
+ chars: 34000,
70
+ charsPct: 52,
71
+ compressed: 13000,
72
+ compressedPct: 82,
73
+ }
74
+ : {
75
+ tokens: 512,
76
+ tokensPct: 6,
77
+ chars: 1840,
78
+ charsPct: 2,
79
+ compressed: 980,
80
+ compressedPct: 6,
81
+ },
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Drives `picopilot verify` through incur's `serve` DI with an INJECTED adapter
87
+ * factory (the seam), capturing stdout + exit without the real environment or
88
+ * binary. `--json` so the structured envelope is asserted directly.
89
+ */
90
+ async function runVerify(
91
+ factory: ShrinkoAdapterFactory,
92
+ cart: string = cartPath,
93
+ env: Record<string, string | undefined> = {PATH: '/does/not/matter'},
94
+ ) {
95
+ // Unique cli name (not 'picopilot') so incur's skills-freshness check finds no
96
+ // stored hash and never injects an ambient "Skills are out of date" CTA. Under
97
+ // the full parallel run that CTA would land as cta.commands[0] and flakily break
98
+ // the verify->run CTA assertion. (Same isolation as tokens.test.ts.)
99
+ const cli = Cli.create('picopilot-verify-test', {version: '0.0.0'});
100
+ registerVerify(cli, factory);
101
+ let stdout = '';
102
+ let exitCode = 0;
103
+ await cli.serve(['verify', cart, '--json'], {
104
+ stdout(s) {
105
+ stdout += s;
106
+ },
107
+ exit(code) {
108
+ exitCode = code;
109
+ },
110
+ env,
111
+ });
112
+ return {stdout, exitCode};
113
+ }
114
+
115
+ describe('picopilot verify: pass (integrity + tokens both green)', () => {
116
+ it('returns one structured envelope with an overall pass and per-check results', async () => {
117
+ const {stdout, exitCode} = await runVerify(() =>
118
+ stubAdapter(present(false)),
119
+ );
120
+ expect(exitCode).toBe(0);
121
+ const out = JSON.parse(stdout);
122
+ expect(out.status).toBe('pass');
123
+ expect(out.checks.integrity).toBe(true);
124
+ expect(out.checks.tokens).toBe(true);
125
+ expect(out.tokens).toBe(512);
126
+ expect(out.budget).toBe(8192);
127
+ });
128
+
129
+ it('SELF-SCOPES: the envelope states it is static and passing does not mean the cart runs', async () => {
130
+ const {stdout} = await runVerify(() => stubAdapter(present(false)));
131
+ const out = JSON.parse(stdout);
132
+ expect(out.scope.toLowerCase()).toContain('static');
133
+ // The connotation guard: passing must NOT be claimed as "the cart runs".
134
+ expect(out.scope.toLowerCase()).toContain('does not mean the cart runs');
135
+ });
136
+
137
+ it('green verify CTAs to `run` (well-formed → now confirm it boots)', async () => {
138
+ const {stdout} = await runVerify(() => stubAdapter(present(false)));
139
+ const out = JSON.parse(stdout);
140
+ // incur prefixes the CLI name onto the CTA command.
141
+ expect(out.cta.commands[0].command).toContain('run');
142
+ });
143
+ });
144
+
145
+ describe('picopilot verify: fail (a static check flunked, nonzero exit)', () => {
146
+ it('fails when tokens are over budget', async () => {
147
+ const {stdout, exitCode} = await runVerify(() =>
148
+ stubAdapter(present(true)),
149
+ );
150
+ expect(exitCode).toBe(1);
151
+ const out = JSON.parse(stdout);
152
+ expect(out.code).toBe('verify-failed');
153
+ // NOT reported as pass, and NOT the gate-incapable code.
154
+ expect(out.code).not.toBe('gate-incapable');
155
+ expect(out.message).toContain('budget');
156
+ // Still self-scopes on failure.
157
+ expect(out.message.toLowerCase()).toContain('static');
158
+ });
159
+
160
+ it('fails when the cart is malformed (integrity), independent of PICO-8', async () => {
161
+ writeFileSync(cartPath, MALFORMED_CART);
162
+ const {stdout, exitCode} = await runVerify(() =>
163
+ stubAdapter(present(false)),
164
+ );
165
+ expect(exitCode).toBe(1);
166
+ const out = JSON.parse(stdout);
167
+ expect(out.code).toBe('verify-failed');
168
+ expect(out.message).toContain('integrity');
169
+ });
170
+ });
171
+
172
+ describe('picopilot verify: gate-incapable (shrinko absent, the regression guard)', () => {
173
+ it('shrinko absent → DISTINCT gate-incapable outcome with a nonzero exit, NEVER green', async () => {
174
+ const {stdout, exitCode} = await runVerify(() =>
175
+ stubAdapter(shrinkoNotFound()),
176
+ );
177
+ // Nonzero, and specifically the distinct gate-incapable exit code.
178
+ expect(exitCode).not.toBe(0);
179
+ expect(exitCode).toBe(GATE_INCAPABLE_EXIT);
180
+ const out = JSON.parse(stdout);
181
+ // A categorically DISTINCT outcome: not pass, not the ordinary fail.
182
+ expect(out.code).toBe('gate-incapable');
183
+ expect(out.code).not.toBe('verify-failed');
184
+ // It names the missing capability + the remedy so the agent can enable it.
185
+ expect(out.message).toContain('shrinko');
186
+ expect(out.message).toContain('uv pip install shrinko');
187
+ });
188
+
189
+ it('REGRESSION GUARD: shrinko absent NEVER reports green, even for a perfect cart', async () => {
190
+ // The cart is well-formed (integrity would pass). If verify ever let a clean
191
+ // cart go green without checking tokens, THIS is the gate-as-theatre bug.
192
+ const {stdout, exitCode} = await runVerify(() =>
193
+ stubAdapter(shrinkoNotFound()),
194
+ );
195
+ expect(exitCode).not.toBe(0);
196
+ const out = JSON.parse(stdout);
197
+ // Must NOT be a success envelope: no `status: 'pass'`.
198
+ expect(out.status).not.toBe('pass');
199
+ expect(out.code).toBe('gate-incapable');
200
+ });
201
+
202
+ it('gate-incapable is a distinct exit code from fail (an agent can branch on it)', async () => {
203
+ const incapable = await runVerify(() => stubAdapter(shrinkoNotFound()));
204
+ const failed = await runVerify(() => stubAdapter(present(true)));
205
+ expect(incapable.exitCode).toBe(GATE_INCAPABLE_EXIT);
206
+ expect(failed.exitCode).toBe(1);
207
+ expect(incapable.exitCode).not.toBe(failed.exitCode);
208
+ });
209
+ });
210
+
211
+ describe('picopilot verify: no PICO-8 dependency (it never runs the cart)', () => {
212
+ it('passes with no PICO8_PATH / pico8 anywhere in env (static gate)', async () => {
213
+ // A bare env with only a PATH that has no pico8: verify still passes, proving
214
+ // it never reaches for PICO-8.
215
+ const {stdout, exitCode} = await runVerify(
216
+ () => stubAdapter(present(false)),
217
+ cartPath,
218
+ {
219
+ PATH: '/empty',
220
+ },
221
+ );
222
+ expect(exitCode).toBe(0);
223
+ const out = JSON.parse(stdout);
224
+ expect(out.status).toBe('pass');
225
+ });
226
+ });
227
+
228
+ describe('picopilot verify: a missing cart is a distinct error (not fail, not gate-incapable)', () => {
229
+ it('errors with cart-not-found + nonzero exit when the cart path does not exist', async () => {
230
+ const {stdout, exitCode} = await runVerify(
231
+ () => stubAdapter(present(false)),
232
+ join(dir, 'nope.p8'),
233
+ );
234
+ expect(exitCode).not.toBe(0);
235
+ const out = JSON.parse(stdout);
236
+ expect(out.code).toBe('cart-not-found');
237
+ });
238
+ });