feat(02-04): ink compilation pipeline + 4 authored Season-1 Ink files + runtime loader

- scripts/compile-ink.mjs: build-time inklecate runner using bundled binary
  (BLOCKER 4 — uses node_modules/inklecate/bin, not stale -windows/-mac path strings).
  Assumption A6 verified first-try on Windows; the same binary path resolution
  works on macOS + Linux per the wrapper's own getInklecatePath convention.
- scripts/compile-ink.test.mjs: 3 Vitest cases proving the compiler runs +
  emits valid JSON with inkVersion. wipe=false for the test path so it can
  run in parallel with the ink-loader test without racing on the wipe step.
- 4 Season-1 .ink files authored in voice (Lura warmth-anchor, gardener-keeper
  for compost): lura-arrival.ink, lura-mid.ink, lura-farewell.ink,
  compost-acknowledgements.ink (rewrite of Plan 02-03 scaffolded version into
  VAR-driven branch shape consumable by the runtime).
- src/content/ink-loader.ts: loadInkStory + bindGardenStateToInk +
  INK_VARIABLE_MAP. Centralized snake_case slot mapping per Pitfall 4. UTF-8
  BOM stripped before Story instantiation.
- src/content/ink-loader.test.ts: 8 cases — Story instantiation for all 4
  beats, fragment_count binding, Pitfall 4 snake_case enforcement, silent
  skip for stories missing declared vars.
- package.json: build now runs compile:ink first; ci chain runs compile:ink
  before test so ink-loader.test.ts's precondition check passes.
- .gitignore: src/content/compiled-ink/ excluded (regenerated on every build).

npm run ci exits 0; 11 new tests green (228 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:24:40 -04:00
parent 348c76a537
commit c90f8f1e5c
11 changed files with 674 additions and 39 deletions
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { compileAllInk } from './compile-ink.mjs';
/**
* Phase 2 Plan 02-04 Task 1 sanity test for the build-time Ink compiler.
*
* Imports compile-ink.mjs (the CLI guard prevents the auto-run path from
* firing under Vitest) and exercises compileAllInk() against the real
* /content/dialogue tree exactly once via beforeAll. Subsequent test
* cases inspect the resulting artefacts.
*
* W9 invariant: compileAllInk() wipes src/content/compiled-ink/ at start,
* so we MUST call it from a single beforeAll. Calling it inside multiple
* test cases — or concurrently with src/content/ink-loader.test.ts —
* creates a filesystem race. The npm run ci chain runs `compile:ink`
* BEFORE `test` so under CI both this file and ink-loader.test.ts see
* a fully-populated compiled-ink/ directory at module-eval time. This
* file's beforeAll is defensive belt-and-suspenders.
*
* Determinism guarantee: inklecate is deterministic from .ink content,
* so same inputs ALWAYS produce the same JSON output.
*/
let compileResult = null;
beforeAll(async () => {
// wipe=false to avoid racing with src/content/ink-loader.test.ts when
// Vitest runs test files in parallel. Production CLI invocation
// (`npm run compile:ink`) keeps wipe=true to clear deleted .ink files.
compileResult = await compileAllInk({ wipe: false });
});
describe('scripts/compile-ink.mjs', () => {
it('exports compileAllInk', () => {
expect(typeof compileAllInk).toBe('function');
});
it('compiles all .ink files in content/dialogue/ and emits .ink.json under src/content/compiled-ink/', () => {
expect(compileResult).not.toBeNull();
// 3 Lura beats + 1 compost = 4 minimum. Phase 4+ will add more.
expect(compileResult.compiled).toBeGreaterThanOrEqual(4);
const expected = [
'src/content/compiled-ink/season1/lura-arrival.ink.json',
'src/content/compiled-ink/season1/lura-mid.ink.json',
'src/content/compiled-ink/season1/lura-farewell.ink.json',
'src/content/compiled-ink/season1/compost-acknowledgements.ink.json',
];
for (const rel of expected) {
expect(existsSync(resolve(process.cwd(), rel))).toBe(true);
}
});
it('produces valid JSON output (parses without error)', () => {
const arrival = readFileSync(
resolve(process.cwd(), 'src/content/compiled-ink/season1/lura-arrival.ink.json'),
'utf8',
);
// inklecate emits a UTF-8 BOM header byte on some platforms; strip it
// before JSON.parse just like the runtime loader will.
const stripped = arrival.charCodeAt(0) === 0xfeff ? arrival.slice(1) : arrival;
expect(() => JSON.parse(stripped)).not.toThrow();
const obj = JSON.parse(stripped);
expect(obj).toBeTypeOf('object');
// inklecate v1.x stories carry an `inkVersion` property at the root.
expect(obj.inkVersion).toBeTypeOf('number');
});
});