39563f6934
Wave 1: Plan 01 (scaffold + test infra)
Wave 2: Plans 02 (eslint firewall), 03 (save layer), 04 (content pipeline),
05 (asset provenance — autonomous:false human-curate checkpoint),
06 (doctrine docs)
Wave 3: Plan 07 (CI workflow)
All 16 Phase-1 REQ-IDs covered. Plan-checker found 4 blockers + 6 warnings
on first pass; revision iteration 1 landed all 10 fixes; iteration 2
returned VERIFICATION PASSED. Two orchestrator judgment calls during
revision: (1) implement CORE-04 localStorage fallback in Phase 1 (the
literal requirement and ROADMAP success criterion #2 both call for it),
(2) reclassify STRY-09 as vacuously satisfied in Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
511 lines
25 KiB
Markdown
511 lines
25 KiB
Markdown
---
|
||
phase: 01
|
||
plan: 04
|
||
type: execute
|
||
wave: 2
|
||
depends_on: [01-01]
|
||
files_modified:
|
||
- src/content/schemas/fragment.ts
|
||
- src/content/schemas/season.ts
|
||
- src/content/schemas/index.ts
|
||
- src/content/loader.ts
|
||
- src/content/loader.test.ts
|
||
- src/content/index.ts
|
||
- content/seasons/00-demo/fragments.yaml
|
||
- content/seasons/00-demo/.gitkeep
|
||
- content/README.md
|
||
- content/dialogue/.gitkeep
|
||
autonomous: true
|
||
requirements: [PIPE-01, STRY-09]
|
||
must_haves:
|
||
truths:
|
||
- "A `/content/seasons/<slug>/fragments.yaml` file with frontmatter parses, validates against the Zod fragment schema, and is available as a typed `Fragment[]` import (PIPE-01)"
|
||
- "A deliberately-malformed content file (e.g., numeric ID instead of string) FAILS Vitest's loader.test.ts assertion (proving the build will fail on schema violation per PIPE-01)"
|
||
- "Fragment IDs match the regex `^season\\d+\\.[a-z0-9._-]+$` per CLAUDE.md stable-string-ID rule"
|
||
- "The `compile:ink` npm script exists, runs successfully against an empty `/content/dialogue/` directory, and is a no-op (per CONTEXT D-08 deferral and RESEARCH § Pattern 4 — Ink files in Phase 1)"
|
||
- "The `/content/` repo-root convention is established with one demo fragment, a README explaining the shape, and the dialogue directory ready for Phase 2 (STRY-09 convention prerequisite)"
|
||
artifacts:
|
||
- path: src/content/schemas/fragment.ts
|
||
provides: "FragmentSchema (Zod), Fragment type, ID regex `^season\\d+\\.[a-z0-9._-]+$`"
|
||
exports: ["FragmentSchema", "Fragment"]
|
||
- path: src/content/schemas/season.ts
|
||
provides: "SeasonContentSchema (Zod) — wraps fragments[]"
|
||
exports: ["SeasonContentSchema", "SeasonContent"]
|
||
- path: src/content/schemas/index.ts
|
||
provides: "Re-exports for schemas/"
|
||
- path: src/content/loader.ts
|
||
provides: "Vite-native glob loader using import.meta.glob for /content/seasons/*/fragments.yaml + /content/seasons/*/fragments/*.md; Zod-validated at module-eval time"
|
||
exports: ["fragments", "loadFragmentsFromGlob"]
|
||
- path: src/content/index.ts
|
||
provides: "Public surface for Phase 2 consumers"
|
||
- path: content/seasons/00-demo/fragments.yaml
|
||
provides: "One demo fragment proving the round-trip; removed in Phase 2 when real Season 1 content lands"
|
||
contains: "season0.demo"
|
||
- path: content/README.md
|
||
provides: "Explains the /content/ convention: directory shape, frontmatter rules, ID convention, where things go in Phase 2"
|
||
key_links:
|
||
- from: src/content/loader.ts
|
||
to: "/content/seasons/*/fragments.yaml"
|
||
via: "import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })"
|
||
pattern: "import.meta.glob\\('/content/seasons/\\*/fragments\\.yaml'"
|
||
- from: src/content/loader.ts
|
||
to: src/content/schemas/season.ts
|
||
via: "SeasonContentSchema.safeParse — throws on schema violation, failing the build"
|
||
pattern: "SeasonContentSchema.safeParse"
|
||
---
|
||
|
||
<objective>
|
||
Stand up the Vite-native content pipeline: Zod schemas for `Fragment` (with the `season<N>.<id>` stable-string-ID regex) and `SeasonContent` (wraps `fragments[]`); a `loader.ts` that uses `import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })` to import every YAML file at build time, parses each via the `yaml` package, and validates via Zod at module-evaluation time so any schema violation fails `npm run build`. Ship one demo fragment under `/content/seasons/00-demo/fragments.yaml` to prove the round-trip end-to-end. Verify the `compile:ink` no-op script runs cleanly. Author a `content/README.md` documenting the convention so Phase 2 has a contract to write Season 1 fragments against.
|
||
|
||
Purpose: Phase 2 will pour Season 1 fragments into `/content/seasons/01-soil/`. Without this loader the build doesn't see them; without the schemas the writer can ship typos that compile and explode at runtime. CONTEXT D-11 and CLAUDE.md "Code Style" lock the `/content/` repo-root location and stable-string-ID convention. RESEARCH § Pattern 4 + Pitfall 1 (literal-glob requirement) provide concrete code.
|
||
|
||
Output: A complete content pipeline: schemas, loader, demo fragment, README, all under `src/content/` and `/content/`. One Vitest test that validates against a mocked schema-violating fragment to prove the build will fail (PIPE-01).
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/PROJECT.md
|
||
@.planning/ROADMAP.md
|
||
@.planning/STATE.md
|
||
@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
|
||
@.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
|
||
@.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md
|
||
@CLAUDE.md
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 1: Zod schemas + loader + demo fragment + content README</name>
|
||
<files>
|
||
src/content/schemas/fragment.ts,
|
||
src/content/schemas/season.ts,
|
||
src/content/schemas/index.ts,
|
||
src/content/loader.ts,
|
||
src/content/index.ts,
|
||
content/seasons/00-demo/fragments.yaml,
|
||
content/seasons/00-demo/.gitkeep,
|
||
content/dialogue/.gitkeep,
|
||
content/README.md
|
||
</files>
|
||
<read_first>
|
||
- .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 4: Vite-Native Content Pipeline" (verbatim code for FragmentSchema, SeasonContentSchema, loader.ts) and § "Common Pitfalls — Pitfall 1: import.meta.glob requires literal patterns"
|
||
- .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-11 — `/content/` at repo root; D-08 — Ink deferred to Phase 2)
|
||
- CLAUDE.md "Code Style" — stable string fragment IDs (`season3.canopy.lura_07.vignette`, never numeric)
|
||
- REQUIREMENTS.md MEMR-03 — fragment ID regex requirement (so the schema regex is correct for Phase 2)
|
||
</read_first>
|
||
<behavior>
|
||
- **fragment.ts:**
|
||
- The `id` field must match `^season\d+\.[a-z0-9._-]+$` (a Phase-1 demo can use `season0.demo`).
|
||
- The `season` field must be an integer in [0, 7] (allowing 0 for the Phase-1 demo; Phase 2 will narrow to [1, 7] when Season 1 lands).
|
||
- The `body` field must be a non-empty string.
|
||
- **loader.ts:**
|
||
- At module evaluation time, calls `import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })`.
|
||
- Parses each YAML string and validates against `SeasonContentSchema`.
|
||
- Throws on any validation failure (the throw fails `npm run build` — that's PIPE-01).
|
||
- Exports a flat `fragments: Fragment[]` containing all loaded fragments.
|
||
- **content/seasons/00-demo/fragments.yaml:** one demo fragment with valid shape.
|
||
</behavior>
|
||
<action>
|
||
**Step 1 — `src/content/schemas/fragment.ts`** (per RESEARCH Pattern 4 verbatim, with the regex from CLAUDE.md and the season range expanded to allow the Phase-1 demo):
|
||
```typescript
|
||
import { z } from 'zod';
|
||
|
||
/**
|
||
* Fragment ID convention (CLAUDE.md "Code Style"): stable string,
|
||
* `season<N>.<id>` where <id> uses lowercase + digits + dot/underscore/hyphen.
|
||
* Example: `season3.canopy.lura_07.vignette`.
|
||
* Never numeric. Renames are forbidden.
|
||
*
|
||
* Phase 1 allows season 0 for the demo fragment under /content/seasons/00-demo/;
|
||
* Phase 2 will narrow the range when real Season 1 content arrives.
|
||
*/
|
||
export const FragmentSchema = z.object({
|
||
id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/),
|
||
season: z.number().int().min(0).max(7),
|
||
body: z.string().min(1),
|
||
});
|
||
export type Fragment = z.infer<typeof FragmentSchema>;
|
||
```
|
||
|
||
**Step 2 — `src/content/schemas/season.ts`:**
|
||
```typescript
|
||
import { z } from 'zod';
|
||
import { FragmentSchema } from './fragment';
|
||
|
||
/** Shape of one /content/seasons/<slug>/fragments.yaml file. */
|
||
export const SeasonContentSchema = z.object({
|
||
fragments: z.array(FragmentSchema),
|
||
});
|
||
export type SeasonContent = z.infer<typeof SeasonContentSchema>;
|
||
```
|
||
|
||
**Step 3 — `src/content/schemas/index.ts`:**
|
||
```typescript
|
||
export { FragmentSchema, type Fragment } from './fragment';
|
||
export { SeasonContentSchema, type SeasonContent } from './season';
|
||
```
|
||
|
||
**Step 4 — `src/content/loader.ts`** (per RESEARCH Pattern 4 verbatim, simplified — Phase 1 only loads YAML; Markdown handling is wired here but won't fire until Phase 2 since /content/seasons/*/fragments/*.md doesn't exist yet):
|
||
```typescript
|
||
import grayMatter from 'gray-matter';
|
||
import { parse as parseYAML } from 'yaml';
|
||
import { SeasonContentSchema, FragmentSchema, type Fragment } from './schemas';
|
||
|
||
/**
|
||
* Vite-native content pipeline (PIPE-01). The glob patterns MUST be
|
||
* string literals at the call site — Vite's plugin walks the AST at build
|
||
* time and cannot resolve runtime expressions
|
||
* (.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 1).
|
||
*
|
||
* On any schema violation, the throw at module-evaluation time bubbles up
|
||
* through Vite into the build process — `npm run build` exits non-zero,
|
||
* which is the PIPE-01 contract.
|
||
*/
|
||
const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', {
|
||
eager: true,
|
||
query: '?raw',
|
||
import: 'default',
|
||
}) as Record<string, string>;
|
||
|
||
const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', {
|
||
eager: true,
|
||
query: '?raw',
|
||
import: 'default',
|
||
}) as Record<string, string>;
|
||
|
||
function loadYamlFragments(): Fragment[] {
|
||
return Object.entries(yamlFiles).flatMap(([path, raw]) => {
|
||
const data = parseYAML(raw);
|
||
const parsed = SeasonContentSchema.safeParse(data);
|
||
if (!parsed.success) {
|
||
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
|
||
}
|
||
return parsed.data.fragments;
|
||
});
|
||
}
|
||
|
||
function loadMdFragments(): Fragment[] {
|
||
return Object.entries(mdFiles).map(([path, raw]) => {
|
||
const { data, content } = grayMatter(raw);
|
||
const merged = { ...data, body: content.trim() };
|
||
const parsed = FragmentSchema.safeParse(merged);
|
||
if (!parsed.success) {
|
||
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
|
||
}
|
||
return parsed.data;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* All fragments discovered at build time. Phase 1 ships one demo fragment
|
||
* under /content/seasons/00-demo/fragments.yaml; Phase 2 fills /content/seasons/01-soil/.
|
||
*/
|
||
export const fragments: Fragment[] = [...loadYamlFragments(), ...loadMdFragments()];
|
||
|
||
/**
|
||
* Test-only helper that lets loader.test.ts validate a mocked SeasonContent
|
||
* shape against the schema without touching the filesystem. PIPE-01 is
|
||
* enforced at build by the throws above; this helper exists so the unit
|
||
* test can prove the schema rejects bad input.
|
||
*/
|
||
export function loadFragmentsFromGlob(
|
||
yamlGlob: Record<string, string>,
|
||
mdGlob: Record<string, string> = {},
|
||
): Fragment[] {
|
||
const yaml = Object.entries(yamlGlob).flatMap(([path, raw]) => {
|
||
const parsed = SeasonContentSchema.safeParse(parseYAML(raw));
|
||
if (!parsed.success) throw new Error(`[content] schema violation in ${path}: ${parsed.error.message}`);
|
||
return parsed.data.fragments;
|
||
});
|
||
const md = Object.entries(mdGlob).map(([path, raw]) => {
|
||
const { data, content } = grayMatter(raw);
|
||
const parsed = FragmentSchema.safeParse({ ...data, body: content.trim() });
|
||
if (!parsed.success) throw new Error(`[content] schema violation in ${path}: ${parsed.error.message}`);
|
||
return parsed.data;
|
||
});
|
||
return [...yaml, ...md];
|
||
}
|
||
```
|
||
|
||
**Step 5 — `src/content/index.ts`:**
|
||
```typescript
|
||
export { fragments, loadFragmentsFromGlob } from './loader';
|
||
export { FragmentSchema, SeasonContentSchema, type Fragment, type SeasonContent } from './schemas';
|
||
```
|
||
|
||
**Step 6 — `content/seasons/00-demo/fragments.yaml`** (one valid demo fragment):
|
||
```yaml
|
||
# /content/seasons/00-demo/fragments.yaml
|
||
# Phase 1 demo fragment — proves the loader round-trips end-to-end.
|
||
# Removed in Phase 2 when real Season 1 content lands under /content/seasons/01-soil/.
|
||
fragments:
|
||
- id: season0.demo.first-light
|
||
season: 0
|
||
body: |
|
||
The garden remembers the first time it was tended,
|
||
though it cannot say in whose voice.
|
||
```
|
||
Also create `content/seasons/00-demo/.gitkeep` (in case the YAML is removed in Phase 2 and we want the dir to persist) — actually skip the .gitkeep since Phase 2 will replace the whole directory anyway. Just the YAML file is enough.
|
||
|
||
**Step 7 — `content/dialogue/.gitkeep`** (already created by Plan 01; verify it exists so the no-op `compile:ink` script has a target).
|
||
|
||
**Step 8 — `content/README.md`:**
|
||
```markdown
|
||
# /content/ — authored content tree
|
||
|
||
All player-visible strings, memory fragments, and dialogue live here, never in
|
||
`src/`. The build pipeline (`src/content/loader.ts`) reads this tree at build
|
||
time, validates against Zod schemas, and emits typed values into the runtime
|
||
bundle.
|
||
|
||
## Directory shape
|
||
|
||
```
|
||
/content/
|
||
├── seasons/
|
||
│ ├── 00-demo/ # Phase 1 only; removed in Phase 2
|
||
│ │ └── fragments.yaml
|
||
│ ├── 01-soil/ # Phase 2 fills this
|
||
│ │ ├── fragments.yaml # bulk-authored fragments
|
||
│ │ └── fragments/ # one-per-file long-form fragments (.md with frontmatter)
|
||
│ │ └── lura-first-letter.md
|
||
│ ├── 02-roots/ # Phase 4
|
||
│ └── ... # Seasons 3–7 added in Phase 5+
|
||
├── dialogue/ # Phase 2+ Ink (.ink) files
|
||
│ └── (empty in Phase 1)
|
||
└── README.md (this file)
|
||
```
|
||
|
||
## Fragment ID convention (locked — see CLAUDE.md)
|
||
|
||
Fragment IDs are stable strings of the shape:
|
||
|
||
```
|
||
season<N>.<id>
|
||
```
|
||
|
||
where `<N>` is `0..7` and `<id>` matches `[a-z0-9._-]+`. Examples:
|
||
|
||
- `season1.soil.first-bloom`
|
||
- `season3.canopy.lura_07.vignette`
|
||
|
||
**Never use numeric IDs.** Renames are forbidden once a fragment ships;
|
||
re-authoring an existing fragment changes its body, never its ID.
|
||
|
||
## Adding fragments
|
||
|
||
### Option A — bulk YAML (preferred for short fragments)
|
||
|
||
Add an entry to `/content/seasons/<slug>/fragments.yaml`:
|
||
|
||
```yaml
|
||
fragments:
|
||
- id: season1.soil.first-bloom
|
||
season: 1
|
||
body: |
|
||
Multi-line text here.
|
||
```
|
||
|
||
### Option B — one-per-file Markdown with frontmatter (for longer pieces)
|
||
|
||
Create `/content/seasons/<slug>/fragments/<slug>.md`:
|
||
|
||
```markdown
|
||
---
|
||
id: season1.soil.lura-first-letter
|
||
season: 1
|
||
---
|
||
|
||
The body of the fragment goes here as Markdown. Frontmatter holds the
|
||
structured fields; the body is everything after the closing `---`.
|
||
```
|
||
|
||
## Validation (PIPE-01)
|
||
|
||
Every fragment is validated by the Zod schema in
|
||
`src/content/schemas/fragment.ts`. A schema violation fails `npm run build`.
|
||
|
||
## Ink dialogue
|
||
|
||
Phase 1 installs `inkjs` + `inklecate` and ships a no-op `npm run compile:ink`
|
||
script. Phase 2 begins authoring `.ink` files under `/content/dialogue/` and
|
||
replaces the no-op with `inklecate -o src/content/compiled-ink/ content/dialogue/*.ink`.
|
||
|
||
## Deferred
|
||
|
||
- Per-Season lazy loading: Phase 2 switches to `{ eager: false }` for
|
||
Seasons 2–7 so the initial bundle contains only Season 1 (PIPE-02).
|
||
```
|
||
|
||
**Step 9 — Verify `npm run build` succeeds** (the loader runs at build time and the demo fragment passes the schema, so the build is clean).
|
||
|
||
**Step 10 — Verify `npm run compile:ink` succeeds with the no-op stub** (set up in Plan 01; this should print the placeholder message and exit 0).
|
||
|
||
**Step 11 — Commit `feat(01-04): Vite-native content pipeline + Zod schemas + demo fragment + /content/ README`.**
|
||
</action>
|
||
<verify>
|
||
<automated>npm run build && npm run compile:ink</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- All 4 schema/loader files exist: `test -f src/content/schemas/fragment.ts && test -f src/content/schemas/season.ts && test -f src/content/schemas/index.ts && test -f src/content/loader.ts && test -f src/content/index.ts`.
|
||
- `FragmentSchema` enforces the stable-string ID regex `^season\\d+\\.[a-z0-9._-]+$` — verify with `grep -F "^season\\d+\\." src/content/schemas/fragment.ts` (the regex appears in the file).
|
||
- `loader.ts` calls `import.meta.glob` with literal patterns (per Pitfall 1) — verify with `grep -E "import\\.meta\\.glob\\('/content/seasons/\\*/" src/content/loader.ts | wc -l` returns 2 (one for yaml, one for md).
|
||
- `loader.ts` throws on schema violation — verify with `grep -q "throw new Error" src/content/loader.ts`.
|
||
- `content/seasons/00-demo/fragments.yaml` exists with a valid `season0.demo` fragment — verify with `grep -q "season0.demo" content/seasons/00-demo/fragments.yaml`.
|
||
- `content/README.md` exists and documents the ID convention — verify with `grep -q "season<N>" content/README.md && grep -q "Never use numeric IDs" content/README.md`.
|
||
- `content/dialogue/.gitkeep` exists (Plan 01 created it; Plan 04 confirms it survives) — verify with `test -f content/dialogue/.gitkeep`.
|
||
- `npm run build` exits 0 (the loader runs and the demo fragment passes).
|
||
- `npm run compile:ink` exits 0 (no-op stub from Plan 01) — verify exit code 0.
|
||
- `src/content/index.ts` exports the public surface — verify with `grep -cE "^export " src/content/index.ts` returns at least 4.
|
||
</acceptance_criteria>
|
||
<done>
|
||
Zod schemas for Fragment + SeasonContent with the stable-string-ID regex; Vite-native loader using `import.meta.glob` with literal patterns; one demo fragment proving end-to-end round-trip; `content/README.md` documenting the convention for Phase 2 writers; `npm run build` and `npm run compile:ink` both green; commit landed.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 2: PIPE-01 enforcement test — schema violation must throw</name>
|
||
<files>
|
||
src/content/loader.test.ts
|
||
</files>
|
||
<read_first>
|
||
- src/content/loader.ts (this plan's Task 1 — the `loadFragmentsFromGlob` helper)
|
||
- src/content/schemas/fragment.ts (the FragmentSchema regex — pick a violator that's clearly invalid)
|
||
- .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § Validation Architecture (PIPE-01 row: "Demo content file with deliberate schema violation fails the build")
|
||
</read_first>
|
||
<behavior>
|
||
- Test 1: `loadFragmentsFromGlob({})` returns `[]` (empty glob, no fragments).
|
||
- Test 2: `loadFragmentsFromGlob` with a valid YAML mock returns the parsed fragments.
|
||
- Test 3 (the load-bearing PIPE-01 test): `loadFragmentsFromGlob` with a mocked YAML containing a numeric `id` (violates the stable-string-ID regex) THROWS, and the error message contains `[content] schema violation`.
|
||
- Test 4: `loadFragmentsFromGlob` with a mocked YAML containing a `season` value out of [0,7] range THROWS.
|
||
- Test 5: `loadFragmentsFromGlob` with a mocked Markdown frontmatter that omits required `id` THROWS.
|
||
</behavior>
|
||
<action>
|
||
Per RESEARCH § Validation Architecture, PIPE-01 is tested via "Vitest run with mocked import.meta.glob". The exported `loadFragmentsFromGlob(yamlGlob, mdGlob)` helper from Task 1 makes this trivial — no need to mock Vite or run the build; we pass mocked glob outputs directly.
|
||
|
||
Use the Write tool to create `src/content/loader.test.ts`:
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest';
|
||
import { loadFragmentsFromGlob } from './loader';
|
||
|
||
describe('PIPE-01: content schema validation', () => {
|
||
it('returns [] when both globs are empty', () => {
|
||
expect(loadFragmentsFromGlob({}, {})).toEqual([]);
|
||
});
|
||
|
||
it('parses valid YAML fragments', () => {
|
||
const yamlGlob = {
|
||
'/content/seasons/00-demo/fragments.yaml': `
|
||
fragments:
|
||
- id: season0.demo.test
|
||
season: 0
|
||
body: "demo body"
|
||
`,
|
||
};
|
||
const result = loadFragmentsFromGlob(yamlGlob);
|
||
expect(result).toHaveLength(1);
|
||
expect(result[0]).toMatchObject({
|
||
id: 'season0.demo.test',
|
||
season: 0,
|
||
body: 'demo body',
|
||
});
|
||
});
|
||
|
||
it('THROWS on a numeric-id violation (stable-string-ID rule)', () => {
|
||
const yamlGlob = {
|
||
'/content/seasons/01-soil/fragments.yaml': `
|
||
fragments:
|
||
- id: 42
|
||
season: 1
|
||
body: "this should fail because id must be a string matching the season<N>.<slug> regex"
|
||
`,
|
||
};
|
||
expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/\[content\] schema violation/);
|
||
});
|
||
|
||
it('THROWS when season is out of [0,7] range', () => {
|
||
const yamlGlob = {
|
||
'/content/seasons/99-bogus/fragments.yaml': `
|
||
fragments:
|
||
- id: season99.bogus.test
|
||
season: 99
|
||
body: "season 99 doesn't exist"
|
||
`,
|
||
};
|
||
expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/\[content\] schema violation/);
|
||
});
|
||
|
||
it('THROWS when Markdown frontmatter omits required id', () => {
|
||
const mdGlob = {
|
||
'/content/seasons/01-soil/fragments/no-id.md': `---
|
||
season: 1
|
||
---
|
||
|
||
Body text without an id frontmatter key.
|
||
`,
|
||
};
|
||
expect(() => loadFragmentsFromGlob({}, mdGlob)).toThrow(/\[content\] schema violation/);
|
||
});
|
||
});
|
||
```
|
||
|
||
**Step 2 — Run `npx vitest run src/content/loader.test.ts`** and confirm all 5 tests pass (the 3 throw assertions plus the 2 happy-path).
|
||
|
||
**Step 3 — Run `npm test`** and confirm the full Phase-1 suite (sentinel + lint-firewall + save layer + content loader) is green.
|
||
|
||
**Step 4 — Commit `test(01-04): PIPE-01 enforcement — schema violations throw at content load`.**
|
||
</action>
|
||
<verify>
|
||
<automated>npx vitest run src/content/loader.test.ts && npm test</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- `src/content/loader.test.ts` exists and imports `loadFragmentsFromGlob` from `./loader` — verify with `grep -q "loadFragmentsFromGlob" src/content/loader.test.ts`.
|
||
- The test contains at least 3 `expect(() => ...).toThrow` assertions — verify with `grep -c "toThrow" src/content/loader.test.ts` returns at least 3.
|
||
- One throw assertion matches the numeric-id case — verify with `grep -E "id:\\s*42" src/content/loader.test.ts`.
|
||
- One throw assertion matches the out-of-range season case — verify with `grep -E "season:\\s*99" src/content/loader.test.ts`.
|
||
- The throw error messages contain `[content] schema violation` — verify with `grep -q "\\[content\\] schema violation" src/content/loader.test.ts`.
|
||
- `npx vitest run src/content/loader.test.ts` passes 5 tests — verify exit 0.
|
||
- `npm test` passes for the entire suite — verify exit 0.
|
||
</acceptance_criteria>
|
||
<done>
|
||
Vitest test for PIPE-01 covering 5 cases (2 happy-path + 3 schema violations); the throws prove `npm run build` would fail on equivalent real-file violations; `npm test` passes the entire Phase-1 suite; commit landed.
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<threat_model>
|
||
Per RESEARCH § Security Domain, content pipeline threats are minimal. Path traversal via `import.meta.glob` (a malicious content file with `../../` in frontmatter) is not exploitable: Vite glob expansion is at build time; the validator step never resolves paths from frontmatter values. No security-relevant runtime code in this plan.
|
||
</threat_model>
|
||
|
||
<verification>
|
||
- `npm run build` exits 0 (loader runs at build time, demo fragment validates).
|
||
- `npm run compile:ink` exits 0 (no-op stub).
|
||
- `npx vitest run src/content/loader.test.ts` passes 5 tests including 3 schema-violation throws (PIPE-01).
|
||
- `npm test` runs the entire Phase-1 suite green.
|
||
- Plan 06 (doctrine docs) and Plan 07 (CI workflow) will both depend on `npm run build` succeeding — this plan unblocks them.
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
- Zod schemas for Fragment (with stable-string-ID regex) and SeasonContent.
|
||
- Vite-native loader using `import.meta.glob` with literal patterns (Pitfall 1 honored).
|
||
- Loader throws on schema violation at module-eval time, failing `npm run build`.
|
||
- One demo fragment under `/content/seasons/00-demo/` proves the round-trip.
|
||
- `content/README.md` documents the convention for Phase 2 writers (STRY-09 prerequisite).
|
||
- 5 Vitest assertions covering happy-path + 3 schema-violation cases.
|
||
- `compile:ink` no-op stub from Plan 01 confirmed runnable.
|
||
</success_criteria>
|
||
|
||
<output>
|
||
After completion, create `.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md` documenting:
|
||
- The fragment ID regex committed (so Phase 2's writer has the contract).
|
||
- The path of the demo fragment (`content/seasons/00-demo/fragments.yaml`) and a note that Phase 2 will remove this directory and replace it with `01-soil/`.
|
||
- Confirmation that `compile:ink` is a no-op in Phase 1 (per CONTEXT D-08) and is replaced in Phase 2.
|
||
- Note for Phase 2: when authoring real fragments, follow `content/README.md` Section "Adding fragments" — the test in `src/content/loader.test.ts` proves any deviation from the schema fails the build.
|
||
</output>
|