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,137 @@
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
+ import type {
8
+ LintReport,
9
+ ShrinkoAdapter,
10
+ ShrinkoResult,
11
+ } from '../engine/shrinko/index.js';
12
+ import {parseLint, shrinkoNotFound} from '../engine/shrinko/index.js';
13
+ import {type ShrinkoAdapterFactory, registerLint} from './lint.js';
14
+
15
+ const CART_TEXT =
16
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n';
17
+
18
+ let dir: string;
19
+ let cartPath: string;
20
+
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-lint-'));
23
+ cartPath = join(dir, 'main.p8');
24
+ writeFileSync(cartPath, CART_TEXT);
25
+ });
26
+ afterEach(() => {
27
+ try {
28
+ execFileSync('rm', ['-rf', dir]);
29
+ } catch {
30
+ // ignore
31
+ }
32
+ });
33
+
34
+ /** A stub adapter whose `lint` returns whatever the test wires; `count`/`minify` unused here. */
35
+ function stubAdapter(result: ShrinkoResult<LintReport>): ShrinkoAdapter {
36
+ return {
37
+ async count() {
38
+ throw new Error('not used');
39
+ },
40
+ async lint() {
41
+ return result;
42
+ },
43
+ async minify() {
44
+ throw new Error('not used');
45
+ },
46
+ };
47
+ }
48
+
49
+ async function runLint(
50
+ factory: ShrinkoAdapterFactory,
51
+ argv: string[] = ['lint', cartPath, '--json'],
52
+ env: Record<string, string | undefined> = {PATH: '/x'},
53
+ ) {
54
+ const cli = Cli.create('picopilot', {version: '0.0.0'});
55
+ registerLint(cli, factory);
56
+ let stdout = '';
57
+ let exitCode = 0;
58
+ await cli.serve(argv, {
59
+ stdout(s) {
60
+ stdout += s;
61
+ },
62
+ exit(code) {
63
+ exitCode = code;
64
+ },
65
+ env,
66
+ });
67
+ return {stdout, exitCode};
68
+ }
69
+
70
+ describe('parseLint: shrinko --lint output to structured findings', () => {
71
+ it('parses file:line:col: message lines, dropping the filename', () => {
72
+ const out = [
73
+ 'Lint warnings:',
74
+ "lintme.p8:5:8: Local 'unused' isn't used",
75
+ "lintme.p8:6:4: Identifier 'undefined_global' not found",
76
+ ].join('\n');
77
+ const {findings} = parseLint(out);
78
+ expect(findings).toHaveLength(2);
79
+ expect(findings[0]).toEqual({
80
+ line: 5,
81
+ col: 8,
82
+ message: "Local 'unused' isn't used",
83
+ });
84
+ expect(findings[1]?.message).toContain('undefined_global');
85
+ });
86
+
87
+ it('a clean cart (no warning lines) parses to zero findings, not an error', () => {
88
+ expect(parseLint('').findings).toEqual([]);
89
+ expect(parseLint('Lint warnings:\n').findings).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe('picopilot lint: present', () => {
94
+ it('reports findings as data (exit 0) and CTAs when there are warnings', async () => {
95
+ const {stdout, exitCode} = await runLint(() =>
96
+ stubAdapter({
97
+ ok: true,
98
+ value: {findings: [{line: 5, col: 8, message: "Local 'x' isn't used"}]},
99
+ }),
100
+ );
101
+ // Findings are DATA, not a failure exit.
102
+ expect(exitCode).toBe(0);
103
+ const out = JSON.parse(stdout);
104
+ expect(out.clean).toBe(false);
105
+ expect(out.count).toBe(1);
106
+ expect(out.findings[0].message).toContain('x');
107
+ });
108
+
109
+ it('a clean cart reports clean=true and no CTA', async () => {
110
+ const {stdout, exitCode} = await runLint(() =>
111
+ stubAdapter({ok: true, value: {findings: []}}),
112
+ );
113
+ expect(exitCode).toBe(0);
114
+ const out = JSON.parse(stdout);
115
+ expect(out.clean).toBe(true);
116
+ expect(out.count).toBe(0);
117
+ });
118
+ });
119
+
120
+ describe('picopilot lint: shrinko absent + cart missing', () => {
121
+ it('returns structured shrinko-not-found + nonzero exit', async () => {
122
+ const {stdout, exitCode} = await runLint(() =>
123
+ stubAdapter(shrinkoNotFound()),
124
+ );
125
+ expect(exitCode).not.toBe(0);
126
+ expect(stdout).toContain('uv pip install shrinko');
127
+ });
128
+
129
+ it('cart-not-found is a distinct picopilot-side error', async () => {
130
+ const {stdout, exitCode} = await runLint(
131
+ () => stubAdapter({ok: true, value: {findings: []}}),
132
+ ['lint', join(dir, 'nope.p8'), '--json'],
133
+ );
134
+ expect(exitCode).not.toBe(0);
135
+ expect(stdout).toContain('no cart at');
136
+ });
137
+ });
@@ -0,0 +1,117 @@
1
+ import {existsSync} from 'node:fs';
2
+ import {isAbsolute, resolve} from 'node:path';
3
+ import {type Cli, z} from 'incur';
4
+ import {
5
+ type ShrinkoAdapter,
6
+ ShellShrinkoAdapter,
7
+ } from '../engine/shrinko/index.js';
8
+
9
+ /** See {@link import('./tokens.js').ShrinkoAdapterFactory}: lint reuses the same seam. */
10
+ export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
11
+
12
+ const defaultAdapterFactory: ShrinkoAdapterFactory = (env) =>
13
+ new ShellShrinkoAdapter({env});
14
+
15
+ /**
16
+ * Registers `picopilot lint` (US #4): surface PICO-8-specific issues (undefined
17
+ * globals, unused/duplicate locals, and, via the parsed findings, the classic
18
+ * gotchas) as STRUCTURED findings via shrinko8 `--lint`, so an agent catches
19
+ * them before running. Findings are DATA: a cart WITH warnings is a successful
20
+ * lint that reports them (exit 0), not a failure exit; only a broken cart or a
21
+ * missing dependency is an error.
22
+ *
23
+ * shrinko-backed: absent shrinko returns the structured `shrinko-not-found`
24
+ * error + nonzero exit (ADR-0002), mirroring `tokens`.
25
+ *
26
+ * @param adapterFactory injects the adapter (defaults to the shell adapter);
27
+ * tests pass a stub to drive present/absent without the real binary.
28
+ */
29
+ export function registerLint(
30
+ cli: Cli.Cli,
31
+ adapterFactory: ShrinkoAdapterFactory = defaultAdapterFactory,
32
+ ): void {
33
+ cli.command('lint', {
34
+ description:
35
+ 'Surface PICO-8 lint findings (undefined globals, unused/duplicate locals) via shrinko8. Requires shrinko.',
36
+ args: z.object({
37
+ cart: z
38
+ .string()
39
+ .default('main.p8')
40
+ .describe(
41
+ 'The .p8 cart to lint. Defaults to main.p8 in the current folder.',
42
+ ),
43
+ }),
44
+ env: z.object({
45
+ PATH: z
46
+ .string()
47
+ .optional()
48
+ .describe('Used to locate shrinko8 in the child process.'),
49
+ }),
50
+ output: z.object({
51
+ findings: z
52
+ .array(
53
+ z.object({
54
+ line: z.number().describe('1-based line in the cart.'),
55
+ col: z.number().describe('1-based column.'),
56
+ message: z.string().describe('The lint warning text.'),
57
+ }),
58
+ )
59
+ .describe(
60
+ 'Every lint warning shrinko reported (empty when the cart is clean).',
61
+ ),
62
+ clean: z.boolean().describe('True when there are no findings.'),
63
+ count: z.number().describe('Number of findings.'),
64
+ }),
65
+ examples: [
66
+ {description: 'Lint main.p8'},
67
+ {description: 'Lint a specific cart', args: {cart: 'game.p8'}},
68
+ ],
69
+ async run({args, env, error, ok}) {
70
+ const cartPath = isAbsolute(args.cart)
71
+ ? args.cart
72
+ : resolve(process.cwd(), args.cart);
73
+
74
+ if (!existsSync(cartPath)) {
75
+ return error({
76
+ code: 'cart-not-found',
77
+ message: `no cart at ${cartPath}`,
78
+ exitCode: 1,
79
+ });
80
+ }
81
+
82
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv);
83
+ const result = await adapter.lint(cartPath);
84
+
85
+ if (!result.ok) {
86
+ return error({
87
+ code: result.reason,
88
+ message: `shrinko is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
89
+ exitCode: 1,
90
+ });
91
+ }
92
+
93
+ const {findings} = result.value;
94
+ const clean = findings.length === 0;
95
+
96
+ // Findings are DATA, not a failure exit (an agent reads them and fixes the
97
+ // code). When there ARE findings, CTA toward the run/verify loop.
98
+ const cta = clean
99
+ ? undefined
100
+ : {
101
+ description: `${findings.length} lint finding(s). Fix them, then re-check:`,
102
+ commands: [
103
+ {command: 'lint', description: 'Re-lint after fixing.'},
104
+ {
105
+ command: 'verify',
106
+ description: 'Run the static gate before running.',
107
+ },
108
+ ],
109
+ };
110
+
111
+ return ok(
112
+ {findings: [...findings], clean, count: findings.length},
113
+ cta === undefined ? undefined : {cta},
114
+ );
115
+ },
116
+ });
117
+ }
@@ -0,0 +1,166 @@
1
+ import {execFileSync} from 'node:child_process';
2
+ import {existsSync, mkdtempSync, readFileSync, 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
+ import type {
8
+ MinifyReport,
9
+ ShrinkoAdapter,
10
+ ShrinkoResult,
11
+ } from '../engine/shrinko/index.js';
12
+ import {shrinkoNotFound} from '../engine/shrinko/index.js';
13
+ import {type ShrinkoAdapterFactory, registerMinify} from './minify.js';
14
+
15
+ const CART_TEXT =
16
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n';
17
+
18
+ let dir: string;
19
+ let cartPath: string;
20
+
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-minify-'));
23
+ cartPath = join(dir, 'main.p8');
24
+ writeFileSync(cartPath, CART_TEXT);
25
+ });
26
+ afterEach(() => {
27
+ try {
28
+ execFileSync('rm', ['-rf', dir]);
29
+ } catch {
30
+ // ignore
31
+ }
32
+ });
33
+
34
+ /**
35
+ * A stub adapter whose `minify` records the outPath it was handed, WRITES a fake
36
+ * minified cart there (so the command's on-disk assertions are real), and
37
+ * returns a wired token delta. `count`/`lint` unused.
38
+ */
39
+ function stubAdapter(
40
+ result: ShrinkoResult<MinifyReport> | 'write',
41
+ seen?: {outPath?: string},
42
+ ): ShrinkoAdapter {
43
+ return {
44
+ async count() {
45
+ throw new Error('not used');
46
+ },
47
+ async lint() {
48
+ throw new Error('not used');
49
+ },
50
+ async minify(_cart, outPath) {
51
+ if (seen !== undefined) seen.outPath = outPath;
52
+ if (result === 'write') {
53
+ writeFileSync(outPath, '-- minified\n');
54
+ return {
55
+ ok: true,
56
+ value: {beforeTokens: 100, afterTokens: 60, outPath},
57
+ };
58
+ }
59
+ return result;
60
+ },
61
+ };
62
+ }
63
+
64
+ async function runMinify(
65
+ factory: ShrinkoAdapterFactory,
66
+ argv: string[] = ['minify', cartPath, '--json'],
67
+ env: Record<string, string | undefined> = {PATH: '/x'},
68
+ ) {
69
+ const cli = Cli.create('picopilot', {version: '0.0.0'});
70
+ registerMinify(cli, factory);
71
+ let stdout = '';
72
+ let exitCode = 0;
73
+ await cli.serve(argv, {
74
+ stdout(s) {
75
+ stdout += s;
76
+ },
77
+ exit(code) {
78
+ exitCode = code;
79
+ },
80
+ env,
81
+ });
82
+ return {stdout, exitCode};
83
+ }
84
+
85
+ describe('picopilot minify: compiler-style (ADR-0007)', () => {
86
+ it('writes <name>.min.p8 by default and reports the token delta', async () => {
87
+ const {stdout, exitCode} = await runMinify(() => stubAdapter('write'));
88
+ expect(exitCode).toBe(0);
89
+ const out = JSON.parse(stdout);
90
+ expect(out.outPath).toBe(join(dir, 'main.min.p8'));
91
+ expect(out.beforeTokens).toBe(100);
92
+ expect(out.afterTokens).toBe(60);
93
+ expect(out.saved).toBe(40);
94
+ expect(existsSync(join(dir, 'main.min.p8'))).toBe(true);
95
+ });
96
+
97
+ it('NEVER mutates the source cart (the compiler invariant)', async () => {
98
+ const before = readFileSync(cartPath, 'utf8');
99
+ await runMinify(() => stubAdapter('write'));
100
+ expect(readFileSync(cartPath, 'utf8')).toBe(before); // source byte-identical
101
+ });
102
+
103
+ it('honours an explicit --out path', async () => {
104
+ const seen: {outPath?: string} = {};
105
+ const chosen = join(dir, 'dist', 'game.p8');
106
+ await runMinify(
107
+ () => stubAdapter('write', seen),
108
+ ['minify', cartPath, '--out', chosen, '--json'],
109
+ );
110
+ expect(seen.outPath).toBe(chosen);
111
+ });
112
+ });
113
+
114
+ describe('picopilot minify: no-clobber + source guard', () => {
115
+ it('refuses to overwrite an existing output without --force', async () => {
116
+ writeFileSync(join(dir, 'main.min.p8'), 'existing artifact');
117
+ const {stdout, exitCode} = await runMinify(() => stubAdapter('write'));
118
+ expect(exitCode).not.toBe(0);
119
+ expect(stdout.toLowerCase()).toContain('refusing to overwrite');
120
+ // The existing artifact is untouched (the adapter never ran).
121
+ expect(readFileSync(join(dir, 'main.min.p8'), 'utf8')).toBe(
122
+ 'existing artifact',
123
+ );
124
+ });
125
+
126
+ it('--force overwrites an existing output', async () => {
127
+ writeFileSync(join(dir, 'main.min.p8'), 'old');
128
+ const {exitCode} = await runMinify(
129
+ () => stubAdapter('write'),
130
+ ['minify', cartPath, '--force', '--json'],
131
+ );
132
+ expect(exitCode).toBe(0);
133
+ expect(readFileSync(join(dir, 'main.min.p8'), 'utf8')).toContain(
134
+ 'minified',
135
+ );
136
+ });
137
+
138
+ it('refuses --out pointing AT the source (no in-place mutation)', async () => {
139
+ const {stdout, exitCode} = await runMinify(
140
+ () => stubAdapter('write'),
141
+ ['minify', cartPath, '--out', cartPath, '--json'],
142
+ );
143
+ expect(exitCode).not.toBe(0);
144
+ expect(stdout.toLowerCase()).toContain('must differ from the source');
145
+ });
146
+ });
147
+
148
+ describe('picopilot minify: shrinko absent + cart missing', () => {
149
+ it('returns structured shrinko-not-found (and writes nothing)', async () => {
150
+ const {stdout, exitCode} = await runMinify(() =>
151
+ stubAdapter(shrinkoNotFound()),
152
+ );
153
+ expect(exitCode).not.toBe(0);
154
+ expect(stdout).toContain('uv pip install shrinko');
155
+ expect(existsSync(join(dir, 'main.min.p8'))).toBe(false);
156
+ });
157
+
158
+ it('cart-not-found is a distinct picopilot-side error', async () => {
159
+ const {stdout, exitCode} = await runMinify(
160
+ () => stubAdapter('write'),
161
+ ['minify', join(dir, 'nope.p8'), '--json'],
162
+ );
163
+ expect(exitCode).not.toBe(0);
164
+ expect(stdout).toContain('no cart at');
165
+ });
166
+ });
@@ -0,0 +1,156 @@
1
+ import {existsSync} from 'node:fs';
2
+ import {dirname, isAbsolute, join, resolve} from 'node:path';
3
+ import {type Cli, z} from 'incur';
4
+ import {
5
+ type ShrinkoAdapter,
6
+ ShellShrinkoAdapter,
7
+ } from '../engine/shrinko/index.js';
8
+
9
+ /** See {@link import('./tokens.js').ShrinkoAdapterFactory}: minify reuses the same seam. */
10
+ export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
11
+
12
+ const defaultAdapterFactory: ShrinkoAdapterFactory = (env) =>
13
+ new ShellShrinkoAdapter({env});
14
+
15
+ /**
16
+ * Derives the default output path `<name>.min.p8` next to the source cart:
17
+ * `main.p8` -> `main.min.p8`, `game.p8.png` -> `game.min.p8.png` is NOT the
18
+ * case (we only special-case the final `.p8`); a plain `<name>.p8` becomes
19
+ * `<name>.min.p8`. Anything without a `.p8` suffix just gets `.min.p8` appended.
20
+ */
21
+ function defaultOutPath(cartPath: string): string {
22
+ const dir = dirname(cartPath);
23
+ const base = cartPath.slice(dir.length + 1);
24
+ const out = base.endsWith('.p8')
25
+ ? `${base.slice(0, -'.p8'.length)}.min.p8`
26
+ : `${base}.min.p8`;
27
+ return join(dir, out);
28
+ }
29
+
30
+ /**
31
+ * Registers `picopilot minify` (US #5, ADR-0007): run shrinko8's SAFE
32
+ * minification and write the result to a SEPARATE artifact (`<name>.min.p8` by
33
+ * default), reporting the before/after token delta. It is COMPILER-STYLE: the
34
+ * user's authored source (`main.p8` / the `#include`d `main.lua`) is NEVER
35
+ * touched, and it refuses to clobber an existing output unless `--force`.
36
+ *
37
+ * Safe-only by default (never aggressive-minify silently). shrinko-backed:
38
+ * absent shrinko returns the structured `shrinko-not-found` error + nonzero
39
+ * exit, mirroring `tokens`/`lint`.
40
+ *
41
+ * @param adapterFactory injects the adapter (defaults to the shell adapter);
42
+ * tests pass a stub to drive present/absent without the real binary.
43
+ */
44
+ export function registerMinify(
45
+ cli: Cli.Cli,
46
+ adapterFactory: ShrinkoAdapterFactory = defaultAdapterFactory,
47
+ ): void {
48
+ cli.command('minify', {
49
+ description:
50
+ 'Safe-minify a cart to a SEPARATE artifact (<name>.min.p8), reporting the token delta. Source untouched. Requires shrinko.',
51
+ args: z.object({
52
+ cart: z
53
+ .string()
54
+ .default('main.p8')
55
+ .describe(
56
+ 'The .p8 cart to minify. Defaults to main.p8 in the current folder.',
57
+ ),
58
+ }),
59
+ options: z.object({
60
+ out: z
61
+ .string()
62
+ .optional()
63
+ .describe(
64
+ 'Output cart path. Defaults to <name>.min.p8 next to the source.',
65
+ ),
66
+ force: z
67
+ .boolean()
68
+ .default(false)
69
+ .describe(
70
+ 'Overwrite an existing output cart. Default off (no-clobber).',
71
+ ),
72
+ }),
73
+ env: z.object({
74
+ PATH: z
75
+ .string()
76
+ .optional()
77
+ .describe('Used to locate shrinko8 in the child process.'),
78
+ }),
79
+ output: z.object({
80
+ outPath: z
81
+ .string()
82
+ .describe('Absolute path of the minified cart written.'),
83
+ beforeTokens: z.number().describe('Tokens in the source cart.'),
84
+ afterTokens: z.number().describe('Tokens in the minified cart.'),
85
+ saved: z.number().describe('Tokens reclaimed (before - after).'),
86
+ }),
87
+ examples: [
88
+ {description: 'Minify main.p8 to main.min.p8'},
89
+ {
90
+ description: 'Minify to a chosen path',
91
+ args: {cart: 'game.p8'},
92
+ options: {out: 'dist/game.p8'},
93
+ },
94
+ ],
95
+ async run({args, options, env, error, ok}) {
96
+ const cartPath = isAbsolute(args.cart)
97
+ ? args.cart
98
+ : resolve(process.cwd(), args.cart);
99
+
100
+ if (!existsSync(cartPath)) {
101
+ return error({
102
+ code: 'cart-not-found',
103
+ message: `no cart at ${cartPath}`,
104
+ exitCode: 1,
105
+ });
106
+ }
107
+
108
+ const outPath =
109
+ options.out !== undefined
110
+ ? isAbsolute(options.out)
111
+ ? options.out
112
+ : resolve(process.cwd(), options.out)
113
+ : defaultOutPath(cartPath);
114
+
115
+ // Guard the source FIRST: never let --out point AT the source cart (that
116
+ // would be in-place mutation, which ADR-0007 forbids). Checked before the
117
+ // no-clobber guard so it wins even though the source obviously exists.
118
+ if (outPath === cartPath) {
119
+ return error({
120
+ code: 'output-is-source',
121
+ message: `--out must differ from the source cart (${cartPath}); minify never mutates the source`,
122
+ exitCode: 1,
123
+ });
124
+ }
125
+
126
+ // No-clobber (ADR-0007, mirrors init): refuse to overwrite an existing
127
+ // artifact unless --force, so a minify never silently destroys a prior one.
128
+ if (existsSync(outPath) && !options.force) {
129
+ return error({
130
+ code: 'output-exists',
131
+ message: `refusing to overwrite existing ${outPath}; pass --force to overwrite`,
132
+ exitCode: 1,
133
+ });
134
+ }
135
+
136
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv);
137
+ const result = await adapter.minify(cartPath, outPath);
138
+
139
+ if (!result.ok) {
140
+ return error({
141
+ code: result.reason,
142
+ message: `shrinko is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
143
+ exitCode: 1,
144
+ });
145
+ }
146
+
147
+ const {beforeTokens, afterTokens} = result.value;
148
+ return ok({
149
+ outPath,
150
+ beforeTokens,
151
+ afterTokens,
152
+ saved: beforeTokens - afterTokens,
153
+ });
154
+ },
155
+ });
156
+ }