feat(ai): house-style generator writing drafts through a DraftSink

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 03:22:38 +01:00
co-authored by Claude Opus 5.5
parent 49d1df0f13
commit 3cec42bba5
5 changed files with 517 additions and 0 deletions
@@ -0,0 +1,46 @@
import { mkdir } from "node:fs/promises";
import { dirname, join, resolve, sep } from "node:path";
export interface DraftFile { path: string; content: string }
/**
* Where generated files go. Same signature as the studio's `createDraftWriter(ctx).writeDraftFiles`, which
* commits onto the author's draft branch; Phase 2 passes that in. Drafts are never published by the AI package.
*/
export interface DraftSink {
writeDraftFiles(courseId: string, authorId: string, files: DraftFile[], message: string): Promise<{ location: string; paths: string[] }>;
}
export function safeRelPath(p: string): string | null {
const clean = p.trim().replace(/\\/g, "/").replace(/^\.\//, "");
if (!clean || clean.startsWith("/") || /^[a-zA-Z]:/.test(clean)) return null;
const parts = clean.split("/");
if (parts.some((s) => s === ".." || s === "" || s === "." || s === ".git")) return null;
return clean;
}
/** Filesystem fallback: `<dataDir>/drafts/<courseId>/<runId>/…` plus DRAFT.json. Outside every course folder. */
export function createFsDraftSink(dataDir: string): DraftSink {
return {
async writeDraftFiles(courseId, authorId, files, message) {
if (!safeRelPath(courseId) || courseId.includes("/")) throw new Error("Invalid course id");
const runId = `${new Date().toISOString().replace(/[:.]/g, "-")}-${Bun.randomUUIDv7().slice(-8)}`;
const location = resolve(dataDir, "drafts", courseId, runId);
const paths: string[] = [];
for (const f of files) {
const rel = safeRelPath(f.path);
if (!rel) throw new Error(`Unsafe draft path: ${f.path}`);
const abs = resolve(location, rel);
if (!abs.startsWith(location + sep)) throw new Error(`Unsafe draft path: ${f.path}`);
await mkdir(dirname(abs), { recursive: true });
await Bun.write(abs, f.content);
paths.push(rel);
}
await Bun.write(
join(location, "DRAFT.json"),
JSON.stringify({ courseId, authorId, message, createdAt: new Date().toISOString(), published: false, files: paths }, null, 2),
);
return { location, paths };
},
};
}
@@ -0,0 +1,169 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { AiProvider } from "@stc/contracts";
import { resolveInside, type CourseRepository, type LoadedCourse } from "@stc/course-format";
import { safeRelPath, type DraftFile, type DraftSink } from "./draft-sink";
import { OUTPUT_FORMAT, REQUIRED, VOICE, styleTemplate, type Kind, type Style } from "./templates";
export type GenerateSource =
| { kind: "lesson"; lessonId: string }
| { kind: "file"; path: string }
| { kind: "cache"; path: string }
| { kind: "text"; label: string; text: string };
export interface GenerateRequest {
courseId: string;
kind: Kind;
style: Style;
topic: string;
sources: GenerateSource[];
instructions?: string;
}
export interface GenerateResult { location: string; paths: string[]; warnings: string[] }
const MAX_MATERIAL = 400_000;
export function slugify(s: string): string {
return s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "draft";
}
export function targetPaths(req: GenerateRequest): string[] {
const slug = slugify(req.topic);
if (req.kind === "exercise") return [`exercises/${slug}/README.md`, `exercises/${slug}/starter.js`, `exercises/${slug}/tests.js`, `exercises/${slug}/solution.js`, `exercises/${slug}/fixtures/${slug}.json`, `exercises/${slug}/manifest-snippet.yaml`];
if (req.kind === "questions") return [`questions/${slug}.md`];
return req.style === "systemic-design" ? [`course/${slug}/lesson.md`, `course/${slug}/scenarios.md`] : [`course/${slug}.md`];
}
/** Reads the chosen inputs. Authors may use any course file, including solutions; cache text comes from <dataDir>/cache/<courseId>/. */
export async function gatherMaterial(repo: CourseRepository, course: LoadedCourse, sources: GenerateSource[], dataDir: string): Promise<{ text: string; warnings: string[] }> {
const parts: string[] = [];
const warnings: string[] = [];
let total = 0;
const push = (label: string, body: string) => {
if (total >= MAX_MATERIAL) { warnings.push(`Left out "${label}": the material is already ${MAX_MATERIAL} characters.`); return; }
let text = body;
if (total + text.length > MAX_MATERIAL) {
text = text.slice(0, MAX_MATERIAL - total);
warnings.push(`"${label}" was shortened to fit the material limit.`);
}
total += text.length;
parts.push(`<source label="${label.replace(/"/g, "'")}">\n${text}\n</source>`);
};
const readText = async (abs: string | null, label: string) => {
const info = abs ? await stat(abs).catch(() => null) : null;
if (!abs || !info?.isFile()) { warnings.push(`Source not found: ${label}`); return; }
const text = await Bun.file(abs).text();
if (text.includes("\u0000")) { warnings.push(`Skipped binary file: ${label}`); return; }
push(label, text);
};
for (const s of sources) {
if (s.kind === "text") push(s.label || "Pasted text", s.text);
else if (s.kind === "lesson") {
const e = course.lessonIndex.get(s.lessonId);
const md = e ? await repo.readLessonMarkdown(course.id, s.lessonId) : null;
if (!e || md === null) warnings.push(`Lesson has no readable text: ${s.lessonId}`);
else push(`${e.lesson.type === "video" ? e.lesson.transcript : e.lesson.file} (lesson ${s.lessonId}: ${e.lesson.title})`, md);
} else if (s.kind === "file") await readText(repo.resolvePath(course.id, s.path), s.path);
else await readText(resolveInside(join(dataDir, "cache", course.id), s.path), `cache/${s.path}`);
}
return { text: parts.join("\n\n"), warnings };
}
function outline(course: LoadedCourse): string {
return (course.manifest?.modules ?? []).map((m, i) => `${i + 1}. ${m.title} (${m.lessons.length} lessons)`).join("\n");
}
export function buildGeneratorPrompt(req: GenerateRequest, material: string, course: LoadedCourse): { system: string; user: string } {
const what = req.kind === "document" ? "an explanatory lesson document" : req.kind === "exercise" ? "a small exercise with tests and hint tiers" : "check-your-understanding questions";
const system = [
`You draft course material for STCourses authors. You write ${what} for the course "${course.manifest?.title ?? course.id}", in the house style below, from the source material the author selected.`,
VOICE,
styleTemplate(req.kind, req.style),
OUTPUT_FORMAT,
].join("\n\n");
const tracks = course.manifest?.tracks?.map((t) => `${t.label} (${t.id})`).join(", ") || "one track (JavaScript)";
const user = [
`Course: ${course.manifest?.title ?? course.id}`,
`Language tracks: ${tracks}`,
`Existing modules:\n${outline(course) || "(none yet)"}`,
`Topic: ${req.topic}`,
req.instructions?.trim() ? `Author's instructions:\n${req.instructions.trim()}` : "",
`Write these files:\n${targetPaths(req).map((p) => `- ${p}`).join("\n")}`,
`Source material (transcripts, text, slide text and code chosen by the author):\n\n${material || "(none: work from the topic and instructions, and mark facts TODO(author): check)"}`,
].filter(Boolean).join("\n\n");
return { system, user };
}
/** Reads `=== FILE: path ===` … `=== END FILE ===` blocks. Unsafe paths are dropped with a warning. */
export function parseFiles(text: string): { files: DraftFile[]; warnings: string[] } {
const files: DraftFile[] = [];
const warnings: string[] = [];
const lines = text.split(/\r?\n/);
let cur: { path: string; lines: string[] } | null = null;
const close = () => {
if (!cur) return;
const rel = safeRelPath(cur.path);
if (!rel) warnings.push(`Dropped a file with an unsafe path: ${cur.path}`);
else files.push({ path: rel, content: cur.lines.join("\n").replace(/\n*$/, "\n") });
cur = null;
};
for (const line of lines) {
const open = /^===\s*FILE:\s*(.+?)\s*===\s*$/.exec(line);
if (open) { close(); cur = { path: open[1]!.replace(/^`|`$/g, ""), lines: [] }; continue; }
if (/^===\s*END FILE\s*===\s*$/.test(line)) { close(); continue; }
cur?.lines.push(line);
}
if (cur) { warnings.push(`The last file (${(cur as { path: string }).path}) was not closed; it may be cut off.`); close(); }
if (!files.length) warnings.push("The model returned no files.");
return { files, warnings };
}
export function checkHouseStyle(req: GenerateRequest, files: DraftFile[]): string[] {
const warnings: string[] = [];
const md = files.filter((f) => /\.(md|markdown)$/i.test(f.path)).map((f) => f.content).join("\n");
for (const s of REQUIRED[req.kind][req.style]) if (!md.includes(s)) warnings.push(`House style: missing "${s}".`);
if (req.kind === "exercise") {
for (const name of ["starter.js", "tests.js", "solution.js"]) if (!files.some((f) => f.path.endsWith(`/${name}`))) warnings.push(`Exercise: missing ${name}.`);
const tests = files.find((f) => f.path.endsWith("/tests.js"));
if (tests && !/export\s+const\s+tests\b/.test(tests.content)) warnings.push("Exercise: tests.js does not export `tests`.");
}
return warnings;
}
const marker = (req: GenerateRequest) =>
`<!-- Draft generated by STCourses AI on ${new Date().toISOString().slice(0, 10)} (${req.kind}, ${req.style} style) from author-selected sources. An author must review it before publishing. -->\n\n`;
/** Runs the model once and writes the files it returns through the DraftSink. Never publishes. */
export async function generateDraft(o: {
provider: AiProvider;
repo: CourseRepository;
req: GenerateRequest;
sink: DraftSink;
authorId: string;
dataDir: string;
signal?: AbortSignal;
onProgress?: (fraction: number, message: string) => Promise<void> | void;
}): Promise<GenerateResult> {
const course = o.repo.get(o.req.courseId);
if (!course || course.status !== "ok") throw new Error(`Course not available: ${o.req.courseId}`);
await o.onProgress?.(0.05, "Reading sources");
const material = await gatherMaterial(o.repo, course, o.req.sources, o.dataDir);
const prompt = buildGeneratorPrompt(o.req, material.text, course);
await o.onProgress?.(0.15, "Drafting");
let text = "";
let chars = 0;
for await (const ev of o.provider.chat([{ role: "system", content: prompt.system }, { role: "user", content: prompt.user }], { signal: o.signal })) {
if (ev.text) {
text += ev.text;
chars += ev.text.length;
if (chars > 4000) { chars = 0; await o.onProgress?.(Math.min(0.85, 0.15 + text.length / 60_000), "Drafting"); }
}
}
const parsed = parseFiles(text);
if (!parsed.files.length) throw new Error(parsed.warnings.join(" ") || "The model returned no files.");
const files = parsed.files.map((f) => (/\.(md|markdown)$/i.test(f.path) ? { ...f, content: marker(o.req) + f.content } : f));
await o.onProgress?.(0.9, "Writing drafts");
const written = await o.sink.writeDraftFiles(course.id, o.authorId, files, `AI draft: ${o.req.kind} "${o.req.topic}" (${o.req.style} style)`);
return { ...written, warnings: [...material.warnings, ...parsed.warnings, ...checkHouseStyle(o.req, parsed.files)] };
}
@@ -0,0 +1,209 @@
// House-style prompt templates for the studio generator, distilled from the two reference courses:
// - Pure Snake (functional-programming-course): module shape of course/03-pure-game-rules.md and MENTOR.md;
// - Systemic Design (systemic-design-course): the beats of course/01-handles/lesson.md and mentor/SKILL.md.
export type Style = "pure-snake" | "systemic-design";
export type Kind = "document" | "exercise" | "questions";
export const OUTPUT_FORMAT = `## Output format
Reply with one or more files and nothing that matters outside them. Write each file exactly like this:
=== FILE: <course-relative path> ===
<the whole file content>
=== END FILE ===
Use only relative paths inside the course (no leading slash, no ".."). Everything you write is a draft for an author to review; it is never published automatically.`;
export const VOICE = `## Voice and rules (both house styles)
- Write for a learner working alone at home with general programming experience. Plain, direct sentences; second person ("you"). No hype, no emoji, no headings in all caps.
- Teach by making the learner think first: a concrete situation or puzzle before the explanation, and the explanation folded so they can try first.
- Every fact about a tool, library or real system must come from the source material you are given. If the material does not say it, leave it out or mark it "TODO(author): check".
- Code in fenced blocks with a language tag. Keep examples small. Exercise solutions never appear in learner-facing text; near-solution hints are skeletons with \`___\` holes.
- When the course has several language tracks, give each track's signature and use one shared fixture file for test cases, so every track tests the same cases with the same names.`;
export const PURE_SNAKE_DOCUMENT = `## House style: Pure Snake module
Follow this module shape exactly (headings verbatim, in this order):
# Module N — <Title>
> **Time:** ~<estimate> · **You'll edit:** <files per track>
> **Tests:** <test command per track>
> **Prerequisites:** <earlier modules>
## Why this module
Two or three paragraphs: how this is usually done (stateful, the familiar way), what goes wrong, and what this module does instead. End by announcing the puzzles.
## Learning objectives
"After this module you can:" then 4-6 bullets, each starting with a bold verb (**implement**, **explain**, **reason**, **predict**, **read**).
## Concepts
### <one H3 per concept>
Explain with a tiny example on a *different* record than the exercise (say so: "(illustration on a different record)"), shown in each track.
Include at least one bridge:
> **From everyday programming:** <how the learner already meets this idea: loops, setters, callbacks, SQL, Git, unit tests, mocks>
### Puzzle 1: <a question the learner can answer on paper>
A concrete small case (a 5 x 5 board, a list of three items). Then **Before reading on, decide:** <the question>.
<details><summary>Think about it first, then open the explanation</summary>
The reasoning, the answer, and the rule it reveals. Name the fixture cases that check it.
</details>
(One or two puzzles. Each one targets a subtlety implementations commonly get wrong.)
## Exercises
### Exercise N.1 — \`functionName\`
The signature per track and a one-line example in a comment. "Fixture: \`fixtures/mNN-<function>.json\` (<case names>)."
(2-4 exercises, each small and testable.)
### Run
The test command per track.
## Hints
For EVERY exercise, three folds in this order, labelled exactly:
<details><summary>Exercise N.1 (functionName) — nudge</summary>
One question or pointer back to an earlier idea.
</details>
<details><summary>Exercise N.1 (functionName) — stronger hint</summary>
The approach in words, no code.
</details>
<details><summary>Exercise N.1 (functionName) — near-solution (try the others first)</summary>
A skeleton per track with \`___\` holes. Never the full answer.
</details>
## Check your understanding
6-8 numbered questions. Include: at least one "Predict:" question with concrete input, at least one "What would break if…", one "Explain to a friend, with a drawing…", and one design question ("Sketch a different model that…").
## In production
2-3 bullets linking the idea to real systems, each with a source from the material or marked TODO(author).
## Further reading
3-5 bullets: "[Title](url) — why it is worth reading." Only links present in the material; otherwise "TODO(author): add reading".`;
export const SYSTEMIC_DESIGN_DOCUMENT = `## House style: Systemic Design module
Every module follows the same beats, as H2 headings verbatim and in this order: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading.
# Module N: <Title>
One paragraph: what the engine learns in this module and why later modules stand on it.
## Observe
A concrete situation from a real game or system, with the exact rule text quoted and its source cited in a link. Distinguish what the source says from the course's own reading ("That example is the course's reading of the quoted rule"). End with the tension the module resolves.
## Predict
"Write your answers down before you open the discussion, for example in \`notes/NN.md\` in your workspace."
2-3 numbered questions that make the learner commit to a design choice.
<details><summary>Discussion</summary>
Answer each question by number. Compare the tempting options and say what goes wrong with each, then name the answer.
</details>
## Spec
The contract: harness functions with signatures in each track, data formats, and how tests are named. Point to \`scenarios.md\`: each scenario heading is a test name, identical across tracks, with Setup / Action / Assertions bullets.
## Build
Where code goes (generic machinery in the engine, named content in content files; the engine must not name specific items) and what not to build yet.
<details><summary>Hint 1 — nudge</summary>
A question about who owns the data or when something happens.
</details>
<details><summary>Hint 2 — concept</summary>
The design in bolded terms (**Id**, **Entity record**, …), still no code.
</details>
<details><summary>Hint 3 — pseudo-code</summary>
\`\`\`text
language-neutral pseudo-code, never one track's real code
\`\`\`
</details>
## Name it
Name the pattern, then show where it appears in real engines or systems with links from the material, and the one idea to keep.
## Expansion Drop
New content arrives that the design must absorb without touching the engine: numbered steps (commit, copy the expansion test, implement in harness/content only, run the expansion check). State the test name and what it checks.
<details><summary>Discussion: what this drop measures</summary>
What a good design makes trivial here, and what a coupled design would have had to change.
</details>
## Further reading
Bullets with links from the material, each with one line on why.
Also write \`scenarios.md\` next to the lesson: "# Module N scenarios: <Title>", one sentence on where the tests live, then one "### test_NN_<name>" heading per scenario with Setup / Action / Assertions bullets.`;
export const EXERCISE = (style: Style) => `## House style: exercise
Write one small exercise that runs in the STCourses browser runner, plus its hint tiers.
Files (all under the exercise folder you are given):
- \`README.md\`: the task in the course's voice: one paragraph of context, the signature, two worked examples (input → output), and what "done" means. Then the hints as three folds per exercise, labelled ${style === "systemic-design" ? '"Hint 1 — nudge", "Hint 2 — concept", "Hint 3 — pseudo-code"' : '"Exercise N.M (name) — nudge", "… — stronger hint", "… — near-solution (try the others first)"'}. The near-solution tier is a skeleton with \`___\` holes (stronger hint: the approach in words; nudge: one question). The tiers in plain words are always nudge, stronger hint, near-solution.
- \`starter.js\`: an ES module exporting the function(s) with the right signature and a body that returns a wrong but type-correct value (or throws "TODO"). A doc comment states the contract.
- \`tests.js\`: an ES module \`export const tests = [ { name: "…", run(mod, assert) { assert.equal(mod.fn(…), …) } }, … ]\`. Use only \`assert.equal\`, \`assert.deepEqual\` and \`assert.ok\`. 5-8 tests including edge cases; test names read like fixture case names (\`sum-of-empty-list-is-zero\`). No imports: the runner loads this one module.
- \`solution.js\`: the reference solution (authors only; learners never see it).
- \`fixtures/<slug>.json\`: the same test cases as data (\`[{ "name", "input", "expected" }]\`), shared across tracks so other language tracks test identical cases.
- \`manifest-snippet.yaml\`: the lesson entry an author would paste into the manifest (type: exercise, browser: { language: js, starter, tests, solution }, hints: { file, match }).`;
export const QUESTIONS = `## House style: knowledge check
Write a "## Check your understanding" section as a numbered list of 6-8 questions about the material, in the reference courses' style:
- at least one "Predict:" question with concrete input the learner works out on paper;
- at least one "What would break if…" question;
- one "Explain to a friend, with a drawing…" question;
- one design question ("Sketch a different model that…");
- the rest test the main idea, not trivia. No multiple choice.
After the list, add one fold per question for authors:
<details><summary>Answer notes for question N (authors)</summary>
What a good answer contains and the misconception it probes.
</details>`;
export function styleTemplate(kind: Kind, style: Style): string {
if (kind === "exercise") return EXERCISE(style);
if (kind === "questions") return QUESTIONS;
return style === "systemic-design" ? SYSTEMIC_DESIGN_DOCUMENT : PURE_SNAKE_DOCUMENT;
}
/** Headings and labels the house-style check looks for. */
export const REQUIRED: Record<Kind, Record<Style, string[]>> = {
document: {
"pure-snake": ["## Why this module", "## Learning objectives", "## Concepts", "Think about it first", "## Exercises", "## Hints", "— nudge", "— stronger hint", "— near-solution", "## Check your understanding", "## Further reading"],
"systemic-design": ["## Observe", "## Predict", "## Spec", "## Build", "Hint 1 — nudge", "Hint 2 — concept", "Hint 3 — pseudo-code", "## Name it", "## Expansion Drop", "## Further reading"],
},
exercise: {
"pure-snake": ["nudge", "stronger hint", "near-solution"],
"systemic-design": ["Hint 1 — nudge", "Hint 2 — concept", "Hint 3 — pseudo-code"],
},
questions: {
"pure-snake": ["## Check your understanding"],
"systemic-design": ["## Check your understanding"],
},
};
+3
View File
@@ -10,3 +10,6 @@ export * from "./mcp/client";
export * from "./assistant/prompt";
export * from "./assistant/tools";
export * from "./assistant/loop";
export * from "./generator/draft-sink";
export * from "./generator/generate";
export * from "./generator/templates";
@@ -0,0 +1,90 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCourseRepository, type CourseRepository } from "@stc/course-format";
import { buildGeneratorPrompt, gatherMaterial, generateDraft, parseFiles, checkHouseStyle, type GenerateRequest } from "../src/generator/generate";
import { createFsDraftSink } from "../src/generator/draft-sink";
import { fakeProvider } from "./helpers";
const FIXTURES = join(import.meta.dir, "../../../fixtures/courses");
let repo: CourseRepository;
let dataDir: string;
beforeAll(async () => {
repo = await createCourseRepository([FIXTURES]);
dataDir = mkdtempSync(join(tmpdir(), "stc-ai-gen-"));
});
afterAll(() => rmSync(dataDir, { recursive: true, force: true }));
const req = (over: Partial<GenerateRequest> = {}): GenerateRequest => ({
courseId: "sample", kind: "document", style: "pure-snake", topic: "Adding many numbers",
sources: [{ kind: "lesson", lessonId: "m2-text" }, { kind: "text", label: "Author notes", text: "Learners confuse sum and count." }], ...over,
});
test("Pure Snake style prompt carries the module shape and hint tiers", async () => {
const course = repo.get("sample")!;
const material = await gatherMaterial(repo, course, req().sources, dataDir);
const p = buildGeneratorPrompt(req(), material.text, course);
for (const s of ["## Why this module", "## Learning objectives", "## Concepts", "Think about it first, then open the explanation", "From everyday programming", "— nudge", "— stronger hint", "— near-solution", "## Check your understanding", "## In production", "## Further reading", "=== FILE:"]) {
expect(p.system).toContain(s);
}
expect(p.user).toContain("Functions take values and return values.");
expect(p.user).toContain("Learners confuse sum and count.");
expect(p.user).toContain("course/adding-many-numbers.md");
});
test("Systemic Design style prompt carries the beats", () => {
const course = repo.get("sample")!;
const p = buildGeneratorPrompt(req({ style: "systemic-design" }), "material", course);
const beats = ["## Observe", "## Predict", "## Spec", "## Build", "## Name it", "## Expansion Drop", "## Further reading"];
let last = -1;
for (const b of beats) { const i = p.system.indexOf(b); expect(i).toBeGreaterThan(last); last = i; }
for (const s of ["Hint 1 — nudge", "Hint 2 — concept", "Hint 3 — pseudo-code", "what this drop measures", "scenarios.md"]) expect(p.system).toContain(s);
});
test("exercise and question prompts", () => {
const course = repo.get("sample")!;
const ex = buildGeneratorPrompt(req({ kind: "exercise" }), "m", course);
for (const s of ["starter.js", "tests.js", "solution.js", "export const tests", "fixtures/", "nudge", "stronger hint", "near-solution"]) expect(ex.system + ex.user).toContain(s);
const q = buildGeneratorPrompt(req({ kind: "questions" }), "m", course);
expect(q.system).toContain("Check your understanding");
expect(q.system).toMatch(/predict/i);
expect(q.system).toMatch(/what would break if/i);
});
test("parseFiles reads file blocks and rejects unsafe paths", () => {
const out = parseFiles("intro\n=== FILE: course/a.md ===\n# A\n=== END FILE ===\n=== FILE: ../escape.md ===\nx\n=== END FILE ===\n=== FILE: /abs.md ===\ny\n=== END FILE ===\n=== FILE: exercises/b/starter.js ===\nexport const b = 1\n=== END FILE ===");
expect(out.files).toEqual([{ path: "course/a.md", content: "# A\n" }, { path: "exercises/b/starter.js", content: "export const b = 1\n" }]);
expect(out.warnings.join("\n")).toMatch(/escape\.md/);
expect(out.warnings.join("\n")).toMatch(/abs\.md/);
expect(parseFiles("no files here").warnings[0]).toMatch(/no files/i);
});
test("house style check flags missing tiers", () => {
const w = checkHouseStyle(req(), [{ path: "course/x.md", content: "# M\n## Why this module\n" }]);
expect(w.some((x) => x.includes("Hints"))).toBe(true);
expect(w.some((x) => x.includes("near-solution"))).toBe(true);
});
test("generation writes drafts through the sink and never touches the course", async () => {
const before = readdirSync(join(FIXTURES, "sample"), { recursive: true }).length;
const doc = [
"# Module 3 — Adding many numbers", "## Why this module", "x", "## Learning objectives", "## Concepts", "<details><summary>Think about it first, then open the explanation</summary>", "y", "</details>",
"## Exercises", "## Hints", "<details><summary>Exercise 3.1 (sum) — nudge</summary></details>", "<details><summary>Exercise 3.1 (sum) — stronger hint</summary></details>",
"<details><summary>Exercise 3.1 (sum) — near-solution (try the others first)</summary></details>", "## Check your understanding", "1. Predict…", "## In production", "## Further reading",
].join("\n");
const { provider, seen } = fakeProvider([() => ({ text: `Here you go.\n=== FILE: course/adding-many-numbers.md ===\n${doc}\n=== END FILE ===\n` })]);
const sink = createFsDraftSink(dataDir);
const r = await generateDraft({ provider, repo, req: req(), sink, authorId: "author-1", dataDir });
expect(seen[0]!.tools).toBeUndefined();
expect(r.paths).toEqual(["course/adding-many-numbers.md"]);
expect(r.location.startsWith(join(dataDir, "drafts", "sample"))).toBe(true);
expect(r.warnings).toEqual([]);
const written = readFileSync(join(r.location, "course/adding-many-numbers.md"), "utf8");
expect(written.startsWith("<!-- Draft generated by STCourses AI")).toBe(true);
expect(written).toContain("## Why this module");
const meta = JSON.parse(readFileSync(join(r.location, "DRAFT.json"), "utf8"));
expect(meta).toMatchObject({ courseId: "sample", authorId: "author-1", published: false });
expect(readdirSync(join(FIXTURES, "sample"), { recursive: true }).length).toBe(before);
expect(existsSync(join(FIXTURES, "sample", "course"))).toBe(false);
});