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.
- package/LICENSE +661 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/audio.d.ts +17 -0
- package/dist/commands/audio.d.ts.map +1 -0
- package/dist/commands/audio.js +315 -0
- package/dist/commands/audio.js.map +1 -0
- package/dist/commands/export.d.ts +36 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +257 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/gfx.d.ts +28 -0
- package/dist/commands/gfx.d.ts.map +1 -0
- package/dist/commands/gfx.js +521 -0
- package/dist/commands/gfx.js.map +1 -0
- package/dist/commands/init.d.ts +34 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +151 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/lint.d.ts +20 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +90 -0
- package/dist/commands/lint.js.map +1 -0
- package/dist/commands/minify.d.ts +20 -0
- package/dist/commands/minify.d.ts.map +1 -0
- package/dist/commands/minify.js +129 -0
- package/dist/commands/minify.js.map +1 -0
- package/dist/commands/music.d.ts +13 -0
- package/dist/commands/music.d.ts.map +1 -0
- package/dist/commands/music.js +241 -0
- package/dist/commands/music.js.map +1 -0
- package/dist/commands/playtest.d.ts +29 -0
- package/dist/commands/playtest.d.ts.map +1 -0
- package/dist/commands/playtest.js +631 -0
- package/dist/commands/playtest.js.map +1 -0
- package/dist/commands/run.d.ts +30 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +224 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/serve.d.ts +50 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +213 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/sfx.d.ts +13 -0
- package/dist/commands/sfx.d.ts.map +1 -0
- package/dist/commands/sfx.js +254 -0
- package/dist/commands/sfx.js.map +1 -0
- package/dist/commands/tokens.d.ts +36 -0
- package/dist/commands/tokens.d.ts.map +1 -0
- package/dist/commands/tokens.js +129 -0
- package/dist/commands/tokens.js.map +1 -0
- package/dist/commands/verify.d.ts +57 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +213 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +24 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/engine/audio/index.d.ts +14 -0
- package/dist/engine/audio/index.d.ts.map +1 -0
- package/dist/engine/audio/index.js +14 -0
- package/dist/engine/audio/index.js.map +1 -0
- package/dist/engine/audio/music.d.ts +116 -0
- package/dist/engine/audio/music.d.ts.map +1 -0
- package/dist/engine/audio/music.js +138 -0
- package/dist/engine/audio/music.js.map +1 -0
- package/dist/engine/audio/section.d.ts +34 -0
- package/dist/engine/audio/section.d.ts.map +1 -0
- package/dist/engine/audio/section.js +67 -0
- package/dist/engine/audio/section.js.map +1 -0
- package/dist/engine/audio/sfx.d.ts +141 -0
- package/dist/engine/audio/sfx.d.ts.map +1 -0
- package/dist/engine/audio/sfx.js +506 -0
- package/dist/engine/audio/sfx.js.map +1 -0
- package/dist/engine/cart/cart.d.ts +75 -0
- package/dist/engine/cart/cart.d.ts.map +1 -0
- package/dist/engine/cart/cart.js +153 -0
- package/dist/engine/cart/cart.js.map +1 -0
- package/dist/engine/cart/errors.d.ts +29 -0
- package/dist/engine/cart/errors.d.ts.map +1 -0
- package/dist/engine/cart/errors.js +26 -0
- package/dist/engine/cart/errors.js.map +1 -0
- package/dist/engine/cart/gfx.d.ts +79 -0
- package/dist/engine/cart/gfx.d.ts.map +1 -0
- package/dist/engine/cart/gfx.js +171 -0
- package/dist/engine/cart/gfx.js.map +1 -0
- package/dist/engine/cart/index.d.ts +15 -0
- package/dist/engine/cart/index.d.ts.map +1 -0
- package/dist/engine/cart/index.js +15 -0
- package/dist/engine/cart/index.js.map +1 -0
- package/dist/engine/cart/map.d.ts +46 -0
- package/dist/engine/cart/map.d.ts.map +1 -0
- package/dist/engine/cart/map.js +118 -0
- package/dist/engine/cart/map.js.map +1 -0
- package/dist/engine/config/config.d.ts +74 -0
- package/dist/engine/config/config.d.ts.map +1 -0
- package/dist/engine/config/config.js +103 -0
- package/dist/engine/config/config.js.map +1 -0
- package/dist/engine/config/index.d.ts +7 -0
- package/dist/engine/config/index.d.ts.map +1 -0
- package/dist/engine/config/index.js +7 -0
- package/dist/engine/config/index.js.map +1 -0
- package/dist/engine/gfx/grid.d.ts +51 -0
- package/dist/engine/gfx/grid.d.ts.map +1 -0
- package/dist/engine/gfx/grid.js +116 -0
- package/dist/engine/gfx/grid.js.map +1 -0
- package/dist/engine/gfx/index.d.ts +17 -0
- package/dist/engine/gfx/index.d.ts.map +1 -0
- package/dist/engine/gfx/index.js +17 -0
- package/dist/engine/gfx/index.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +34 -0
- package/dist/engine/gfx/label.d.ts.map +1 -0
- package/dist/engine/gfx/label.js +190 -0
- package/dist/engine/gfx/label.js.map +1 -0
- package/dist/engine/gfx/overlap.d.ts +42 -0
- package/dist/engine/gfx/overlap.d.ts.map +1 -0
- package/dist/engine/gfx/overlap.js +57 -0
- package/dist/engine/gfx/overlap.js.map +1 -0
- package/dist/engine/gfx/palette.d.ts +29 -0
- package/dist/engine/gfx/palette.d.ts.map +1 -0
- package/dist/engine/gfx/palette.js +46 -0
- package/dist/engine/gfx/palette.js.map +1 -0
- package/dist/engine/gfx/png.d.ts +31 -0
- package/dist/engine/gfx/png.d.ts.map +1 -0
- package/dist/engine/gfx/png.js +165 -0
- package/dist/engine/gfx/png.js.map +1 -0
- package/dist/engine/gfx/render.d.ts +42 -0
- package/dist/engine/gfx/render.d.ts.map +1 -0
- package/dist/engine/gfx/render.js +72 -0
- package/dist/engine/gfx/render.js.map +1 -0
- package/dist/engine/index.d.ts +22 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +22 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +267 -0
- package/dist/engine/pico8/adapter.d.ts.map +1 -0
- package/dist/engine/pico8/adapter.js +37 -0
- package/dist/engine/pico8/adapter.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts +308 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -0
- package/dist/engine/pico8/drive.js +581 -0
- package/dist/engine/pico8/drive.js.map +1 -0
- package/dist/engine/pico8/harness.d.ts +146 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -0
- package/dist/engine/pico8/harness.js +251 -0
- package/dist/engine/pico8/harness.js.map +1 -0
- package/dist/engine/pico8/index.d.ts +21 -0
- package/dist/engine/pico8/index.d.ts.map +1 -0
- package/dist/engine/pico8/index.js +21 -0
- package/dist/engine/pico8/index.js.map +1 -0
- package/dist/engine/pico8/sentinel.d.ts +63 -0
- package/dist/engine/pico8/sentinel.d.ts.map +1 -0
- package/dist/engine/pico8/sentinel.js +86 -0
- package/dist/engine/pico8/sentinel.js.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.js +11 -0
- package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
- package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
- package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-main.js +71 -0
- package/dist/engine/pico8/session-daemon-main.js.map +1 -0
- package/dist/engine/pico8/session-daemon.d.ts +68 -0
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon.js +264 -0
- package/dist/engine/pico8/session-daemon.js.map +1 -0
- package/dist/engine/pico8/session.d.ts +140 -0
- package/dist/engine/pico8/session.d.ts.map +1 -0
- package/dist/engine/pico8/session.js +250 -0
- package/dist/engine/pico8/session.js.map +1 -0
- package/dist/engine/pico8/shell.d.ts +103 -0
- package/dist/engine/pico8/shell.d.ts.map +1 -0
- package/dist/engine/pico8/shell.js +324 -0
- package/dist/engine/pico8/shell.js.map +1 -0
- package/dist/engine/pico8/supervisor.d.ts +137 -0
- package/dist/engine/pico8/supervisor.d.ts.map +1 -0
- package/dist/engine/pico8/supervisor.js +140 -0
- package/dist/engine/pico8/supervisor.js.map +1 -0
- package/dist/engine/scaffold/index.d.ts +8 -0
- package/dist/engine/scaffold/index.d.ts.map +1 -0
- package/dist/engine/scaffold/index.js +8 -0
- package/dist/engine/scaffold/index.js.map +1 -0
- package/dist/engine/scaffold/scaffold.d.ts +49 -0
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
- package/dist/engine/scaffold/scaffold.js +259 -0
- package/dist/engine/scaffold/scaffold.js.map +1 -0
- package/dist/engine/shrinko/adapter.d.ts +167 -0
- package/dist/engine/shrinko/adapter.d.ts.map +1 -0
- package/dist/engine/shrinko/adapter.js +107 -0
- package/dist/engine/shrinko/adapter.js.map +1 -0
- package/dist/engine/shrinko/index.d.ts +13 -0
- package/dist/engine/shrinko/index.d.ts.map +1 -0
- package/dist/engine/shrinko/index.js +13 -0
- package/dist/engine/shrinko/index.js.map +1 -0
- package/dist/engine/shrinko/shell.d.ts +69 -0
- package/dist/engine/shrinko/shell.d.ts.map +1 -0
- package/dist/engine/shrinko/shell.js +156 -0
- package/dist/engine/shrinko/shell.js.map +1 -0
- package/dist/engine/skills/index.d.ts +8 -0
- package/dist/engine/skills/index.d.ts.map +1 -0
- package/dist/engine/skills/index.js +8 -0
- package/dist/engine/skills/index.js.map +1 -0
- package/dist/engine/skills/skills.d.ts +104 -0
- package/dist/engine/skills/skills.d.ts.map +1 -0
- package/dist/engine/skills/skills.js +149 -0
- package/dist/engine/skills/skills.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +48 -2
- package/src/bin.ts +4 -0
- package/src/cli.ts +61 -0
- package/src/commands/audio.test.ts +280 -0
- package/src/commands/audio.ts +410 -0
- package/src/commands/export.test.ts +310 -0
- package/src/commands/export.ts +316 -0
- package/src/commands/gfx-io.test.ts +178 -0
- package/src/commands/gfx.test.ts +470 -0
- package/src/commands/gfx.ts +645 -0
- package/src/commands/init.test.ts +482 -0
- package/src/commands/init.ts +193 -0
- package/src/commands/lint.test.ts +137 -0
- package/src/commands/lint.ts +117 -0
- package/src/commands/minify.test.ts +166 -0
- package/src/commands/minify.ts +156 -0
- package/src/commands/music.test.ts +272 -0
- package/src/commands/music.ts +288 -0
- package/src/commands/playtest.test.ts +317 -0
- package/src/commands/playtest.ts +773 -0
- package/src/commands/run.test.ts +264 -0
- package/src/commands/run.ts +285 -0
- package/src/commands/serve.test.ts +227 -0
- package/src/commands/serve.ts +272 -0
- package/src/commands/sfx.test.ts +349 -0
- package/src/commands/sfx.ts +300 -0
- package/src/commands/tokens.test.ts +212 -0
- package/src/commands/tokens.ts +168 -0
- package/src/commands/verify.test.ts +238 -0
- package/src/commands/verify.ts +256 -0
- package/src/commands/version.test.ts +70 -0
- package/src/commands/version.ts +24 -0
- package/src/engine/audio/index.ts +47 -0
- package/src/engine/audio/music.test.ts +188 -0
- package/src/engine/audio/music.ts +218 -0
- package/src/engine/audio/section.test.ts +124 -0
- package/src/engine/audio/section.ts +79 -0
- package/src/engine/audio/sfx.test.ts +410 -0
- package/src/engine/audio/sfx.ts +694 -0
- package/src/engine/cart/cart.test.ts +304 -0
- package/src/engine/cart/cart.ts +211 -0
- package/src/engine/cart/errors.ts +39 -0
- package/src/engine/cart/fixtures/crlf.p8 +6 -0
- package/src/engine/cart/fixtures/full.p8 +174 -0
- package/src/engine/cart/fixtures/header-only.p8 +2 -0
- package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
- package/src/engine/cart/fixtures/minimal.p8 +10 -0
- package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
- package/src/engine/cart/gfx.ts +195 -0
- package/src/engine/cart/index.ts +27 -0
- package/src/engine/cart/map.ts +134 -0
- package/src/engine/config/config.ts +115 -0
- package/src/engine/config/index.ts +12 -0
- package/src/engine/gfx/grid.test.ts +128 -0
- package/src/engine/gfx/grid.ts +136 -0
- package/src/engine/gfx/index.ts +34 -0
- package/src/engine/gfx/label.test.ts +105 -0
- package/src/engine/gfx/label.ts +229 -0
- package/src/engine/gfx/overlap.test.ts +71 -0
- package/src/engine/gfx/overlap.ts +66 -0
- package/src/engine/gfx/palette.test.ts +35 -0
- package/src/engine/gfx/palette.ts +54 -0
- package/src/engine/gfx/png.test.ts +111 -0
- package/src/engine/gfx/png.ts +196 -0
- package/src/engine/gfx/render.test.ts +180 -0
- package/src/engine/gfx/render.ts +106 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pico8/adapter.ts +303 -0
- package/src/engine/pico8/drive.test.ts +415 -0
- package/src/engine/pico8/drive.ts +729 -0
- package/src/engine/pico8/harness.test.ts +181 -0
- package/src/engine/pico8/harness.ts +354 -0
- package/src/engine/pico8/index.ts +20 -0
- package/src/engine/pico8/sentinel.test.ts +80 -0
- package/src/engine/pico8/sentinel.ts +93 -0
- package/src/engine/pico8/session-daemon-entry.ts +12 -0
- package/src/engine/pico8/session-daemon-main.ts +98 -0
- package/src/engine/pico8/session-daemon.test.ts +212 -0
- package/src/engine/pico8/session-daemon.ts +306 -0
- package/src/engine/pico8/session.test.ts +239 -0
- package/src/engine/pico8/session.ts +321 -0
- package/src/engine/pico8/shell.test.ts +583 -0
- package/src/engine/pico8/shell.ts +412 -0
- package/src/engine/pico8/supervisor.test.ts +105 -0
- package/src/engine/pico8/supervisor.ts +197 -0
- package/src/engine/scaffold/index.ts +15 -0
- package/src/engine/scaffold/scaffold.ts +278 -0
- package/src/engine/shrinko/adapter.ts +241 -0
- package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
- package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
- package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
- package/src/engine/shrinko/index.ts +33 -0
- package/src/engine/shrinko/shell.ts +224 -0
- package/src/engine/shrinko/shrinko.test.ts +182 -0
- package/src/engine/skills/index.ts +14 -0
- package/src/engine/skills/skills.test.ts +376 -0
- package/src/engine/skills/skills.ts +205 -0
- package/src/index.ts +13 -0
- package/src/skills/game-design-reference/SKILL.md +70 -0
- package/src/skills/game-jam/SKILL.md +45 -0
- package/src/skills/picopilot-art/SKILL.md +62 -0
- package/src/skills/picopilot-audio/SKILL.md +194 -0
- package/src/skills/picopilot-code/SKILL.md +97 -0
- package/src/skills/picopilot-code/reference/README.md +42 -0
- package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
- package/src/skills/picopilot-code/reference/platformer.md +119 -0
- package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
- package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
- package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
- package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
- package/src/skills/picopilot-debug/SKILL.md +195 -0
- package/src/skills/picopilot-overview/SKILL.md +52 -0
- 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,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
|
+
});
|