Files
przeprogramowani b0c789af70 feat: enable v4 course selection and protected first-week sync (#38)
* docs(10xdevs4-cli-access): record membership gates and bootstrap context (p1)

Add canonical access plan, accepted decisions and supporting context.
Record phase 1 verification, Toolkit revision and remaining evidence gaps.

* docs(10xdevs4-cli-access): record course access gates and scoped review (p2)

Update canonical Progress, change status, evidence and implementation review.
Record Toolkit revisions, inherited typecheck limitation and phase 3 prerequisites.

* docs(10xdevs4-cli-access): persist reviewed revisions and phase 3 boundary

Record final reviewed Toolkit and CLI context revisions.
Persist Progress attribution and unresolved W04/W05/W08 prerequisites.
Keep phases 3–6, phase 7 and Manual criteria open.

* docs(10xdevs4-cli-access): record squash-safe source prerequisite

Record PR #30, verified gates, permanent-pin lessons and the remaining merge dependency.
Keep phase 3 and all manual rollout criteria pending.

* docs(10xdevs4-cli-access): record source prerequisite review

Record independent review of PR #30 and verified CI evidence.
Keep the master pin and delivery phase completion pending.

* docs(10xdevs4-cli-access): distinguish candidate checks from final master pins

Record passing pre-merge v4 checks and defer workflow suspension.
Keep the final v3 maintenance pin dependent on the resulting master SHA.

* feat: prepare v4 course delivery and protected project sync

Capture the reviewed implementation and manual rehearsal for draft PR review. Master source prerequisites, full clean verification and coordinated Windows CI remain open; production rollout is separate.

* fix: keep paid CI evidence private and converge cumulative sync

* docs: record merged source prerequisite and passing clean gate

* fix: preserve generated API type line endings on Windows

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-13 09:24:16 +02:00

1132 lines
45 KiB
TypeScript

/**
* Writer tests — applies a LessonBundle to a tempdir and asserts the resulting
* filesystem layout, manifest contents, and re-apply semantics.
*
* No real network, no real .claude/ — every test owns a `mkdtemp` root.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { LessonBundle } from "../src/lib/api-content";
import { contentHash, MANIFEST_FILENAME, readManifest } from "../src/lib/manifest";
import { applyBundle, type ConflictInfo, type ConflictResolution } from "../src/lib/writer";
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "10x-cli-writer-"));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
function bundleA(): LessonBundle {
return {
lessonId: "m1l1",
module: 1,
lesson: 1,
title: "Intro",
summary: "First lesson",
skills: [
{
name: "code-review",
files: [{ path: "SKILL.md", content: "# Code Review\n\nContent A\n" }],
},
{ name: "tdd", files: [{ path: "SKILL.md", content: "# TDD v1\n" }] },
],
prompts: [{ name: "plan", content: "# plan prompt\n" }],
rules: [{ name: "style", content: "Always test.\n" }],
configs: [{ name: "settings.json", content: '{"a":1}\n' }],
};
}
function bundleB(): LessonBundle {
return {
lessonId: "m1l2",
module: 1,
lesson: 2,
title: "Deeper",
summary: "Second lesson",
skills: [
// `tdd` is shared with A; `refactor` is exclusive to B.
{ name: "tdd", files: [{ path: "SKILL.md", content: "# TDD v2\n" }] },
{ name: "refactor", files: [{ path: "SKILL.md", content: "# Refactor\n" }] },
],
// `plan` from A is gone; `implement` is new.
prompts: [{ name: "implement", content: "# implement prompt\n" }],
rules: [{ name: "style", content: "Always refactor.\n" }],
configs: [
// `settings.json` is shared with A (and must NOT be overwritten).
{ name: "settings.json", content: '{"b":2}\n' },
{ name: "hooks.json", content: '{"pre":true}\n' },
],
};
}
// ---------------------------------------------------------------------------
// Fresh install
// ---------------------------------------------------------------------------
describe("writer — fresh install", () => {
it("writes skills at .claude/skills/<name>/SKILL.md", async () => {
const result = await applyBundle(bundleA(), tmp);
expect(readFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "utf8")).toBe(
"# Code Review\n\nContent A\n",
);
expect(readFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "utf8")).toBe("# TDD v1\n");
expect(result.skills.map((s) => s.files[0]!.action)).toEqual(["created", "created"]);
expect(result.skills[0]!.files[0]!.absolutePath).toBe(
join(tmp, ".claude/skills/code-review/SKILL.md"),
);
});
it("writes prompts at .claude/prompts/<name>.md", async () => {
const result = await applyBundle(bundleA(), tmp);
expect(readFileSync(join(tmp, ".claude/prompts/plan.md"), "utf8")).toBe("# plan prompt\n");
expect(result.prompts[0]!.action).toBe("created");
expect(result.prompts[0]!.path).toBe(join(tmp, ".claude/prompts/plan.md"));
});
it("writes configs at .claude/config-templates/<name>", async () => {
const result = await applyBundle(bundleA(), tmp);
expect(readFileSync(join(tmp, ".claude/config-templates/settings.json"), "utf8")).toBe(
'{"a":1}\n',
);
expect(result.configs[0]!.action).toBe("created");
});
it("writes rules between sentinel markers in CLAUDE.md", async () => {
const result = await applyBundle(bundleA(), tmp);
const claudeMd = readFileSync(join(tmp, "CLAUDE.md"), "utf8");
expect(claudeMd).toContain("<!-- BEGIN @przeprogramowani/10x-cli -->");
expect(claudeMd).toContain("<!-- END @przeprogramowani/10x-cli -->");
expect(claudeMd).toContain("Always test.");
expect(result.rules.action).toBe("created");
});
it("creates a manifest describing what was written", async () => {
await applyBundle(bundleA(), tmp);
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest).not.toBeNull();
expect(manifest!.package).toBe("@przeprogramowani/10x-cli");
expect(manifest!.manifestVersion).toBe(3);
expect(manifest!.lessonId).toBe("m1l1");
expect(Object.keys(manifest!.files.skills).sort()).toEqual(["code-review", "tdd"]);
expect(manifest!.files.skills["code-review"]!.files).toEqual(["SKILL.md"]);
expect(manifest!.files.skills["tdd"]!.files).toEqual(["SKILL.md"]);
expect(manifest!.files.prompts).toEqual(["plan.md"]);
expect(manifest!.files.configs).toEqual(["settings.json"]);
// ISO timestamp round-trippable.
expect(new Date(manifest!.lastApplied).toISOString()).toBe(manifest!.lastApplied);
});
});
// ---------------------------------------------------------------------------
// Idempotent re-apply
// ---------------------------------------------------------------------------
describe("writer — idempotent re-apply", () => {
it("second apply reports unchanged/skipped actions", async () => {
await applyBundle(bundleA(), tmp);
const result = await applyBundle(bundleA(), tmp);
for (const s of result.skills) {
for (const f of s.files) expect(f.action).toBe("unchanged");
}
for (const p of result.prompts) expect(p.action).toBe("unchanged");
for (const c of result.configs) expect(c.action).toBe("skipped");
expect(result.rules.action).toBe("unchanged");
});
it("does not duplicate the sentinel block in CLAUDE.md", async () => {
await applyBundle(bundleA(), tmp);
const first = readFileSync(join(tmp, "CLAUDE.md"), "utf8");
await applyBundle(bundleA(), tmp);
const second = readFileSync(join(tmp, "CLAUDE.md"), "utf8");
expect(second).toBe(first);
const beginCount = second.split("<!-- BEGIN @przeprogramowani/10x-cli -->").length - 1;
const endCount = second.split("<!-- END @przeprogramowani/10x-cli -->").length - 1;
expect(beginCount).toBe(1);
expect(endCount).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Migration from internal-pkg sentinel markers
// ---------------------------------------------------------------------------
describe("writer — migration from internal-pkg markers", () => {
it("removes the toolkit block and writes the cli block", async () => {
writeFileSync(
join(tmp, "CLAUDE.md"),
[
"# Project",
"",
"<!-- BEGIN @przeprogramowani/10x-toolkit -->",
"",
"legacy rules",
"",
"<!-- END @przeprogramowani/10x-toolkit -->",
"",
].join("\n"),
);
await applyBundle(bundleA(), tmp, { onConflict: async () => "overwrite" });
const claudeMd = readFileSync(join(tmp, "CLAUDE.md"), "utf8");
expect(claudeMd).not.toContain("legacy rules");
expect(claudeMd).not.toContain("<!-- BEGIN @przeprogramowani/10x-toolkit -->");
expect(claudeMd).not.toContain("<!-- END @przeprogramowani/10x-toolkit -->");
expect(claudeMd).toContain("<!-- BEGIN @przeprogramowani/10x-cli -->");
expect(claudeMd).toContain("Always test.");
expect(claudeMd).toContain("# Project");
});
});
// ---------------------------------------------------------------------------
// Config collision
// ---------------------------------------------------------------------------
describe("writer — config collision", () => {
it("does not overwrite a pre-existing config template", async () => {
mkdirSync(join(tmp, ".claude/config-templates"), { recursive: true });
const preExisting = '{"edited_by_user":true}\n';
writeFileSync(join(tmp, ".claude/config-templates/settings.json"), preExisting);
const result = await applyBundle(bundleA(), tmp);
expect(readFileSync(join(tmp, ".claude/config-templates/settings.json"), "utf8")).toBe(
preExisting,
);
expect(result.configs[0]!.action).toBe("skipped");
});
});
// ---------------------------------------------------------------------------
// Cleanup on re-apply
// ---------------------------------------------------------------------------
describe("writer — cleanup on re-apply", () => {
it("preserves artifacts from the previous lesson (cumulative)", async () => {
await applyBundle(bundleA(), tmp);
expect(existsSync(join(tmp, ".claude/skills/code-review/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/prompts/plan.md"))).toBe(true);
await applyBundle(bundleB(), tmp);
// Exclusive to A → preserved (cumulative)
expect(existsSync(join(tmp, ".claude/skills/code-review/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/prompts/plan.md"))).toBe(true);
// Shared → still present and content updated
expect(readFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "utf8")).toBe("# TDD v2\n");
// New in B → created
expect(existsSync(join(tmp, ".claude/skills/refactor/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/prompts/implement.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/config-templates/hooks.json"))).toBe(true);
});
it("shared configs are preserved untouched (not overwritten)", async () => {
await applyBundle(bundleA(), tmp);
await applyBundle(bundleB(), tmp);
expect(readFileSync(join(tmp, ".claude/config-templates/settings.json"), "utf8")).toBe(
'{"a":1}\n',
);
});
it("manifest is the union of all applied lessons", async () => {
await applyBundle(bundleA(), tmp);
await applyBundle(bundleB(), tmp);
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest).not.toBeNull();
expect(manifest!.lessonId).toBe("m1l2");
expect(Object.keys(manifest!.files.skills).sort()).toEqual(["code-review", "refactor", "tdd"]);
expect(manifest!.files.prompts.sort()).toEqual(["implement.md", "plan.md"]);
expect(manifest!.files.configs.sort()).toEqual(["hooks.json", "settings.json"]);
expect(manifest!.lessons).toBeDefined();
expect(Object.keys(manifest!.lessons!).sort()).toEqual(["m1l1", "m1l2"]);
});
});
// ---------------------------------------------------------------------------
// Multi-file skills
// ---------------------------------------------------------------------------
describe("writer — multi-file skills", () => {
function multiFileBundle(): LessonBundle {
return {
lessonId: "m2l1",
module: 2,
lesson: 1,
title: "Multi",
summary: "",
skills: [
{
name: "10x-plan",
files: [
{ path: "SKILL.md", content: "# 10x-plan\n" },
{
path: "scripts/check-context.sh",
content: "#!/bin/bash\necho 'low|0'\n",
executable: true,
},
{ path: "references/format.md", content: "# format reference\n" },
],
},
],
prompts: [],
rules: [],
configs: [],
};
}
it("materializes every file at its relative path under the skill dir", async () => {
await applyBundle(multiFileBundle(), tmp);
expect(readFileSync(join(tmp, ".claude/skills/10x-plan/SKILL.md"), "utf8")).toBe(
"# 10x-plan\n",
);
expect(
readFileSync(join(tmp, ".claude/skills/10x-plan/scripts/check-context.sh"), "utf8"),
).toBe("#!/bin/bash\necho 'low|0'\n");
expect(
readFileSync(join(tmp, ".claude/skills/10x-plan/references/format.md"), "utf8"),
).toBe("# format reference\n");
});
it.skipIf(process.platform === "win32")("applies +x to files marked executable", async () => {
await applyBundle(multiFileBundle(), tmp);
const mode = statSync(join(tmp, ".claude/skills/10x-plan/scripts/check-context.sh")).mode;
expect((mode & 0o111) !== 0).toBe(true);
});
it.skipIf(process.platform === "win32")("non-executable files are not chmod-marked +x", async () => {
await applyBundle(multiFileBundle(), tmp);
const mode = statSync(join(tmp, ".claude/skills/10x-plan/SKILL.md")).mode;
expect((mode & 0o111) === 0).toBe(true);
});
it("manifest records every file path under the skill", async () => {
await applyBundle(multiFileBundle(), tmp);
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest!.files.skills["10x-plan"]!.files.sort()).toEqual([
"SKILL.md",
"references/format.md",
"scripts/check-context.sh",
]);
});
it("removes a file dropped from a retained skill on re-apply", async () => {
await applyBundle(multiFileBundle(), tmp);
expect(
existsSync(join(tmp, ".claude/skills/10x-plan/scripts/check-context.sh")),
).toBe(true);
// Same skill, but the script file is gone upstream.
const next: LessonBundle = {
...multiFileBundle(),
skills: [
{
name: "10x-plan",
files: [
{ path: "SKILL.md", content: "# 10x-plan\n" },
{ path: "references/format.md", content: "# format reference\n" },
],
},
],
};
await applyBundle(next, tmp);
expect(
existsSync(join(tmp, ".claude/skills/10x-plan/scripts/check-context.sh")),
).toBe(false);
// Empty parent dir should be cleaned up too.
expect(existsSync(join(tmp, ".claude/skills/10x-plan/scripts"))).toBe(false);
// SKILL.md and other retained file are still there.
expect(existsSync(join(tmp, ".claude/skills/10x-plan/SKILL.md"))).toBe(true);
expect(
existsSync(join(tmp, ".claude/skills/10x-plan/references/format.md")),
).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Unsupported v1 manifests must block writes
// ---------------------------------------------------------------------------
describe("writer — unsupported v1 manifest", () => {
it("rejects writing before mutation and preserves the legacy manifest and content bytes", async () => {
// Hand-craft a v1 manifest: skills as `string[]`, no manifestVersion.
mkdirSync(join(tmp, ".claude"), { recursive: true });
writeFileSync(
join(tmp, ".claude", MANIFEST_FILENAME),
JSON.stringify({
package: "@przeprogramowani/10x-cli",
version: "0.5.0",
lastApplied: "2026-04-30T00:00:00.000Z",
lessonId: "m1l1",
course: "10xdevs3",
files: { skills: ["legacy-skill"], prompts: [], configs: [] },
}),
);
// Pre-create the legacy skill on disk so we can prove cleanup didn't
// touch it.
mkdirSync(join(tmp, ".claude/skills/legacy-skill"), { recursive: true });
writeFileSync(join(tmp, ".claude/skills/legacy-skill/SKILL.md"), "old\n");
expect(readManifest(join(tmp, ".claude"))).toBeNull();
const manifestPath = join(tmp, ".claude", MANIFEST_FILENAME);
const legacyPath = join(tmp, ".claude/skills/legacy-skill/SKILL.md");
const manifestBefore = readFileSync(manifestPath);
const legacyBefore = readFileSync(legacyPath);
// A nullable low-level read is not permission to erase unsupported state.
await expect(applyBundle(bundleA(), tmp)).rejects.toMatchObject({
code: "course_binding_invalid",
});
expect(readFileSync(manifestPath)).toEqual(manifestBefore);
expect(readFileSync(legacyPath)).toEqual(legacyBefore);
expect(readManifest(join(tmp, ".claude"))).toBeNull();
expect(existsSync(join(tmp, ".10x-cli.json"))).toBe(false);
expect(existsSync(join(tmp, "CLAUDE.md"))).toBe(false);
expect(existsSync(join(tmp, ".claude/skills/code-review"))).toBe(false);
expect(existsSync(join(tmp, ".claude/skills/tdd"))).toBe(false);
expect(existsSync(join(tmp, ".claude/prompts"))).toBe(false);
expect(existsSync(join(tmp, ".claude/config-templates"))).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Dry run
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Safety — unsafe artifact names must be refused
// ---------------------------------------------------------------------------
describe("writer — unsafe artifact names", () => {
it("throws on a skill name containing path separators", async () => {
const bundle = bundleA();
bundle.skills[0]!.name = "../evil";
await expect(applyBundle(bundle, tmp)).rejects.toThrow(/unsafe skill name/);
// Confirm nothing was written before the throw.
expect(existsSync(join(tmp, ".claude"))).toBe(false);
});
it("throws on a prompt name starting with a dot", async () => {
const bundle = bundleA();
bundle.prompts[0]!.name = ".hidden";
await expect(applyBundle(bundle, tmp)).rejects.toThrow(/unsafe prompt name/);
});
it("throws on a config name containing a backslash", async () => {
const bundle = bundleA();
bundle.configs[0]!.name = "..\\evil.json";
await expect(applyBundle(bundle, tmp)).rejects.toThrow(/unsafe config name/);
});
it("rejects Windows-specific unsafe names (NTFS ADS, reserved devices, trailing dot/space)", async () => {
const cases: string[] = [
"foo:bar", // NTFS Alternate Data Stream
"CON", // Windows reserved device name
"nul.txt", // reserved device with extension
"com1", // reserved device, lowercase
"LPT9.log",
"trailing.", // NTFS strips trailing dot
"trailing ", // NTFS strips trailing space
'bad"name', // NTFS reserved char
"pipe|name",
"star*name",
"quest?name",
"lt<name",
"gt>name",
];
for (const unsafe of cases) {
const bundle = bundleA();
bundle.skills[0]!.name = unsafe;
await expect(applyBundle(bundle, tmp)).rejects.toThrow(/unsafe skill name/);
}
// Confirm nothing was written across all iterations.
expect(existsSync(join(tmp, ".claude"))).toBe(false);
});
it("throws on a skill file path containing '..' before any write", async () => {
const bundle = bundleA();
bundle.skills[0]!.files.push({ path: "../evil.sh", content: "rm -rf" });
await expect(applyBundle(bundle, tmp)).rejects.toThrow(/unsafe file path/);
expect(existsSync(join(tmp, ".claude"))).toBe(false);
});
it("throws on an absolute skill file path", async () => {
const bundle = bundleA();
bundle.skills[0]!.files.push({ path: "/etc/passwd", content: "x" });
await expect(applyBundle(bundle, tmp)).rejects.toThrow(/unsafe file path/);
});
it("throws on an empty skill file path", async () => {
const bundle = bundleA();
bundle.skills[0]!.files.push({ path: "", content: "x" });
await expect(applyBundle(bundle, tmp)).rejects.toThrow(/unsafe file path/);
});
it("throws on a backslash-separated path traversal", async () => {
const bundle = bundleA();
bundle.skills[0]!.files.push({ path: "..\\evil.sh", content: "x" });
await expect(applyBundle(bundle, tmp)).rejects.toThrow(/unsafe file path/);
});
it("cleanup silently skips tampered manifest entries instead of rm -rf escaping claudeDir", async () => {
// First apply a clean bundle so a manifest exists.
await applyBundle(bundleA(), tmp);
// Now tamper with the manifest on disk to sneak in an unsafe name.
const manifestPath = join(tmp, ".claude", MANIFEST_FILENAME);
const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
raw.files.skills["../../../should-not-be-removed"] = { files: ["SKILL.md"] };
writeFileSync(manifestPath, JSON.stringify(raw));
// Second apply should not throw and should not rmSync outside claudeDir.
// Use bundleB which drops "code-review" so cleanup is exercised.
await expect(applyBundle(bundleB(), tmp)).resolves.toBeDefined();
// tmp itself must still exist — the tampered entry was ignored.
expect(existsSync(tmp)).toBe(true);
});
});
describe("writer — dry run", () => {
it("returns WriteResult shape without filesystem side effects on fresh install", async () => {
const result = await applyBundle(bundleA(), tmp, { dryRun: true });
expect(existsSync(join(tmp, ".claude"))).toBe(false);
expect(existsSync(join(tmp, "CLAUDE.md"))).toBe(false);
expect(result.skills.map((s) => s.files[0]!.action)).toEqual(["created", "created"]);
expect(result.prompts[0]!.action).toBe("created");
expect(result.configs[0]!.action).toBe("created");
expect(result.rules.action).toBe("created");
});
it("dry-run on re-apply reports unchanged/skipped without touching files", async () => {
await applyBundle(bundleA(), tmp);
const manifestBefore = readFileSync(join(tmp, ".claude", MANIFEST_FILENAME), "utf8");
const claudeMdBefore = readFileSync(join(tmp, "CLAUDE.md"), "utf8");
const result = await applyBundle(bundleA(), tmp, { dryRun: true });
expect(result.rules.action).toBe("unchanged");
for (const c of result.configs) expect(c.action).toBe("skipped");
expect(readFileSync(join(tmp, ".claude", MANIFEST_FILENAME), "utf8")).toBe(manifestBefore);
expect(readFileSync(join(tmp, "CLAUDE.md"), "utf8")).toBe(claudeMdBefore);
});
it("dry-run does not delete stale artifacts from a previous lesson", async () => {
await applyBundle(bundleA(), tmp);
await applyBundle(bundleB(), tmp, { dryRun: true });
// Files from A must still exist on disk — dry-run must not remove them.
expect(existsSync(join(tmp, ".claude/skills/code-review/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/prompts/plan.md"))).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Conflict detection — three-way hash comparison
// ---------------------------------------------------------------------------
describe("writer — conflict detection", () => {
it("does not trigger conflict when file matches stored hash (clean upstream update)", async () => {
await applyBundle(bundleA(), tmp);
// Re-apply with updated content — no local edits, so no conflict
const conflicts: ConflictInfo[] = [];
const updated = bundleA();
updated.skills[0]!.files[0]!.content = "# Code Review\n\nContent A v2\n";
const result = await applyBundle(updated, tmp, {
onConflict: async (info) => { conflicts.push(info); return "overwrite"; },
});
expect(conflicts).toHaveLength(0);
expect(result.skills[0]!.files[0]!.action).toBe("updated");
expect(readFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "utf8")).toBe(
"# Code Review\n\nContent A v2\n",
);
});
it("triggers conflict when user edits a skill file", async () => {
await applyBundle(bundleA(), tmp);
// User edits the file locally
writeFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "# My custom review\n");
const conflicts: ConflictInfo[] = [];
const result = await applyBundle(bundleA(), tmp, {
onConflict: async (info) => { conflicts.push(info); return "overwrite"; },
});
expect(conflicts).toHaveLength(1);
expect(conflicts[0]!.artifactType).toBe("skill");
expect(conflicts[0]!.artifactName).toBe("code-review/SKILL.md");
expect(result.skills[0]!.files[0]!.action).toBe("conflict_overwritten");
});
it("triggers conflict when user edits a prompt file", async () => {
await applyBundle(bundleA(), tmp);
writeFileSync(join(tmp, ".claude/prompts/plan.md"), "# My custom plan\n");
const conflicts: ConflictInfo[] = [];
const result = await applyBundle(bundleA(), tmp, {
onConflict: async (info) => { conflicts.push(info); return "skip"; },
});
expect(conflicts).toHaveLength(1);
expect(conflicts[0]!.artifactType).toBe("prompt");
expect(conflicts[0]!.artifactName).toBe("plan");
expect(result.prompts[0]!.action).toBe("conflict_skipped");
});
it("does not trigger conflict when user edits match the new content", async () => {
await applyBundle(bundleA(), tmp);
// User edits to exactly what the new bundle will write — no conflict
writeFileSync(
join(tmp, ".claude/skills/code-review/SKILL.md"),
"# Code Review\n\nContent A\n",
);
const conflicts: ConflictInfo[] = [];
const result = await applyBundle(bundleA(), tmp, {
onConflict: async (info) => { conflicts.push(info); return "overwrite"; },
});
expect(conflicts).toHaveLength(0);
expect(result.skills[0]!.files[0]!.action).toBe("unchanged");
});
it("resolves conflict as overwrite — writes new content and updates hash", async () => {
await applyBundle(bundleA(), tmp);
writeFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "# My TDD notes\n");
const result = await applyBundle(bundleA(), tmp, {
onConflict: async () => "overwrite",
});
expect(result.skills[1]!.files[0]!.action).toBe("conflict_overwritten");
expect(readFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "utf8")).toBe("# TDD v1\n");
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest!.files.skills["tdd"]!.contentHashes!["SKILL.md"]).toBe(
contentHash("# TDD v1\n"),
);
});
it("resolves conflict as save_user — backs up local file and writes new content", async () => {
await applyBundle(bundleA(), tmp);
writeFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "# My TDD notes\n");
const result = await applyBundle(bundleA(), tmp, {
onConflict: async () => "save_user",
});
expect(result.skills[1]!.files[0]!.action).toBe("conflict_saved_user");
expect(readFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "utf8")).toBe("# TDD v1\n");
expect(readFileSync(join(tmp, ".claude/skills/tdd/SKILL.user.md"), "utf8")).toBe(
"# My TDD notes\n",
);
});
it("resolves conflict as skip — preserves local file and does not update hash", async () => {
await applyBundle(bundleA(), tmp);
const manifestBefore = readManifest(join(tmp, ".claude"));
const originalHash = manifestBefore!.files.skills["tdd"]!.contentHashes!["SKILL.md"];
writeFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "# My TDD notes\n");
const result = await applyBundle(bundleA(), tmp, {
onConflict: async () => "skip",
});
expect(result.skills[1]!.files[0]!.action).toBe("conflict_skipped");
expect(readFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "utf8")).toBe(
"# My TDD notes\n",
);
const manifestAfter = readManifest(join(tmp, ".claude"));
expect(manifestAfter!.files.skills["tdd"]!.contentHashes!["SKILL.md"]).toBe(originalHash);
});
it("defaults to skip when no onConflict callback is provided", async () => {
await applyBundle(bundleA(), tmp);
writeFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "# My TDD notes\n");
const result = await applyBundle(bundleA(), tmp);
expect(result.skills[1]!.files[0]!.action).toBe("conflict_skipped");
expect(readFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "utf8")).toBe(
"# My TDD notes\n",
);
});
it("handles multiple conflicts with different resolutions per file", async () => {
await applyBundle(bundleA(), tmp);
writeFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "# Edited CR\n");
writeFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "# Edited TDD\n");
let callIndex = 0;
const resolutions: ConflictResolution[] = ["overwrite", "skip"];
const result = await applyBundle(bundleA(), tmp, {
onConflict: async () => resolutions[callIndex++]!,
});
expect(result.skills[0]!.files[0]!.action).toBe("conflict_overwritten");
expect(result.skills[1]!.files[0]!.action).toBe("conflict_skipped");
expect(readFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "utf8")).toBe(
"# Code Review\n\nContent A\n",
);
expect(readFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "utf8")).toBe(
"# Edited TDD\n",
);
});
});
// ---------------------------------------------------------------------------
// v2 manifest upgrade path
// ---------------------------------------------------------------------------
describe("writer — v2 manifest upgrade", () => {
function writeV2Manifest(dir: string, skills: Record<string, { files: string[] }>, prompts: string[]): void {
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, MANIFEST_FILENAME),
JSON.stringify({
package: "@przeprogramowani/10x-cli",
version: "1.0.0",
manifestVersion: 2,
lastApplied: "2026-05-01T00:00:00.000Z",
lessonId: "m1l1",
course: "10xdevs3",
tool: "claude-code",
files: { skills, prompts, configs: ["settings.json"] },
}),
);
}
it("treats any content difference as a potential conflict on v2 upgrade", async () => {
// Write a v2 manifest (no hashes) and pre-create a modified file
writeV2Manifest(join(tmp, ".claude"), { "code-review": { files: ["SKILL.md"] } }, ["plan.md"]);
mkdirSync(join(tmp, ".claude/skills/code-review"), { recursive: true });
writeFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "# User modified\n");
const conflicts: ConflictInfo[] = [];
await applyBundle(bundleA(), tmp, {
onConflict: async (info) => { conflicts.push(info); return "overwrite"; },
});
expect(conflicts.length).toBeGreaterThan(0);
expect(conflicts[0]!.artifactName).toBe("code-review/SKILL.md");
});
it("does not trigger conflict when v2 file matches new bundle content", async () => {
writeV2Manifest(join(tmp, ".claude"), { "code-review": { files: ["SKILL.md"] } }, ["plan.md"]);
mkdirSync(join(tmp, ".claude/skills/code-review"), { recursive: true });
writeFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "# Code Review\n\nContent A\n");
const conflicts: ConflictInfo[] = [];
await applyBundle(bundleA(), tmp, {
onConflict: async (info) => { conflicts.push(info); return "overwrite"; },
});
expect(conflicts).toHaveLength(0);
});
it("produces a v3 manifest with hashes after upgrading from v2", async () => {
writeV2Manifest(join(tmp, ".claude"), { "code-review": { files: ["SKILL.md"] } }, ["plan.md"]);
mkdirSync(join(tmp, ".claude/skills/code-review"), { recursive: true });
writeFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "# Code Review\n\nContent A\n");
await applyBundle(bundleA(), tmp, {
onConflict: async () => "overwrite",
});
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest!.manifestVersion).toBe(3);
expect(manifest!.files.skills["code-review"]!.contentHashes).toBeDefined();
expect(manifest!.files.promptHashes).toBeDefined();
});
it("subsequent apply after v2 upgrade uses accurate three-way detection", async () => {
// First: v2 manifest + matching content → upgrade to v3
writeV2Manifest(join(tmp, ".claude"), { "code-review": { files: ["SKILL.md"] } }, ["plan.md"]);
mkdirSync(join(tmp, ".claude/skills/code-review"), { recursive: true });
writeFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "# Code Review\n\nContent A\n");
await applyBundle(bundleA(), tmp, { onConflict: async () => "overwrite" });
// Now v3 manifest exists. User edits a file.
writeFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "# Custom TDD\n");
// Second apply: should accurately detect the user edit
const conflicts: ConflictInfo[] = [];
await applyBundle(bundleA(), tmp, {
onConflict: async (info) => { conflicts.push(info); return "skip"; },
});
expect(conflicts).toHaveLength(1);
expect(conflicts[0]!.artifactName).toBe("tdd/SKILL.md");
});
});
// ---------------------------------------------------------------------------
// Removal tracking
// ---------------------------------------------------------------------------
describe("writer — removal tracking", () => {
it("no removals when switching between different lessons (cumulative)", async () => {
await applyBundle(bundleA(), tmp);
const result = await applyBundle(bundleB(), tmp);
expect(result.removals.skills).toHaveLength(0);
expect(result.removals.prompts).toHaveLength(0);
expect(result.removals.configs).toHaveLength(0);
});
it("reports empty removals when there is no previous manifest", async () => {
const result = await applyBundle(bundleA(), tmp);
expect(result.removals.skills).toHaveLength(0);
expect(result.removals.prompts).toHaveLength(0);
expect(result.removals.configs).toHaveLength(0);
});
it("dry-run produces no removals when switching lessons (cumulative)", async () => {
await applyBundle(bundleA(), tmp);
const result = await applyBundle(bundleB(), tmp, { dryRun: true });
expect(result.removals.skills).toHaveLength(0);
expect(result.removals.prompts).toHaveLength(0);
expect(result.removals.configs).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Hash persistence
// ---------------------------------------------------------------------------
describe("writer — hash persistence", () => {
it("stores content hashes for skills in the manifest after apply", async () => {
await applyBundle(bundleA(), tmp);
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest!.files.skills["code-review"]!.contentHashes).toBeDefined();
expect(manifest!.files.skills["code-review"]!.contentHashes!["SKILL.md"]).toBe(
contentHash("# Code Review\n\nContent A\n"),
);
expect(manifest!.files.skills["tdd"]!.contentHashes!["SKILL.md"]).toBe(
contentHash("# TDD v1\n"),
);
});
it("stores content hashes for prompts in the manifest after apply", async () => {
await applyBundle(bundleA(), tmp);
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest!.files.promptHashes).toBeDefined();
expect(manifest!.files.promptHashes!["plan.md"]).toBe(contentHash("# plan prompt\n"));
});
it("preserves old hash for conflict-skipped files", async () => {
await applyBundle(bundleA(), tmp);
const originalHash = readManifest(join(tmp, ".claude"))!.files.skills["tdd"]!.contentHashes!["SKILL.md"];
writeFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "# My TDD\n");
await applyBundle(bundleA(), tmp, { onConflict: async () => "skip" });
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest!.files.skills["tdd"]!.contentHashes!["SKILL.md"]).toBe(originalHash);
});
it("updates hash for conflict-overwritten files", async () => {
await applyBundle(bundleA(), tmp);
writeFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "# My TDD\n");
await applyBundle(bundleA(), tmp, { onConflict: async () => "overwrite" });
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest!.files.skills["tdd"]!.contentHashes!["SKILL.md"]).toBe(
contentHash("# TDD v1\n"),
);
});
it("updates hash for conflict-saved-user files (new content was written)", async () => {
await applyBundle(bundleA(), tmp);
writeFileSync(join(tmp, ".claude/skills/tdd/SKILL.md"), "# My TDD\n");
await applyBundle(bundleA(), tmp, { onConflict: async () => "save_user" });
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest!.files.skills["tdd"]!.contentHashes!["SKILL.md"]).toBe(
contentHash("# TDD v1\n"),
);
});
});
// ---------------------------------------------------------------------------
// Cumulative multi-lesson behavior
// ---------------------------------------------------------------------------
describe("writer — cumulative multi-lesson", () => {
it("accumulates artifacts from multiple lessons on disk", async () => {
await applyBundle(bundleA(), tmp);
await applyBundle(bundleB(), tmp);
// All artifacts from both lessons exist
expect(existsSync(join(tmp, ".claude/skills/code-review/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/skills/tdd/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/skills/refactor/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/prompts/plan.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/prompts/implement.md"))).toBe(true);
});
it("re-applying a lesson does not remove another lesson's artifacts", async () => {
await applyBundle(bundleA(), tmp);
await applyBundle(bundleB(), tmp);
await applyBundle(bundleA(), tmp);
// m1l2-exclusive artifacts survive re-apply of m1l1
expect(existsSync(join(tmp, ".claude/skills/refactor/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/prompts/implement.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/config-templates/hooks.json"))).toBe(true);
// m1l1 artifacts also present
expect(existsSync(join(tmp, ".claude/skills/code-review/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".claude/prompts/plan.md"))).toBe(true);
});
it("removes a skill dropped by a lesson when no other lesson claims it", async () => {
await applyBundle(bundleA(), tmp);
expect(existsSync(join(tmp, ".claude/skills/code-review/SKILL.md"))).toBe(true);
// Re-apply m1l1 without code-review
const trimmedA: LessonBundle = {
...bundleA(),
skills: [{ name: "tdd", files: [{ path: "SKILL.md", content: "# TDD v1\n" }] }],
};
await applyBundle(trimmedA, tmp);
// code-review was only in m1l1 and was dropped → removed
expect(existsSync(join(tmp, ".claude/skills/code-review"))).toBe(false);
});
it("does not remove a shared skill when one lesson drops it but another claims it", async () => {
await applyBundle(bundleA(), tmp);
await applyBundle(bundleB(), tmp);
// Both lessons have tdd. Re-apply m1l1 without tdd
const trimmedA: LessonBundle = {
...bundleA(),
skills: [
{ name: "code-review", files: [{ path: "SKILL.md", content: "# Code Review\n\nContent A\n" }] },
],
};
await applyBundle(trimmedA, tmp);
// tdd is protected by m1l2
expect(existsSync(join(tmp, ".claude/skills/tdd/SKILL.md"))).toBe(true);
});
it("manifest has lessons entries with appliedAt timestamps", async () => {
await applyBundle(bundleA(), tmp);
await applyBundle(bundleB(), tmp);
const manifest = readManifest(join(tmp, ".claude"));
expect(manifest!.lessons).toBeDefined();
expect(manifest!.lessons!["m1l1"]).toBeDefined();
expect(manifest!.lessons!["m1l2"]).toBeDefined();
expect(new Date(manifest!.lessons!["m1l1"]!.appliedAt).toISOString()).toBe(
manifest!.lessons!["m1l1"]!.appliedAt,
);
});
it("union files includes content hashes from both lessons", async () => {
await applyBundle(bundleA(), tmp);
await applyBundle(bundleB(), tmp);
const manifest = readManifest(join(tmp, ".claude"));
// code-review is m1l1-only — hash present from first apply
expect(manifest!.files.skills["code-review"]!.contentHashes!["SKILL.md"]).toBe(
contentHash("# Code Review\n\nContent A\n"),
);
// refactor is m1l2-only — hash from second apply
expect(manifest!.files.skills["refactor"]!.contentHashes!["SKILL.md"]).toBe(
contentHash("# Refactor\n"),
);
// tdd is shared — hash reflects the latest apply (m1l2)
expect(manifest!.files.skills["tdd"]!.contentHashes!["SKILL.md"]).toBe(
contentHash("# TDD v2\n"),
);
// Prompt hashes from both lessons
expect(manifest!.files.promptHashes!["plan.md"]).toBe(contentHash("# plan prompt\n"));
expect(manifest!.files.promptHashes!["implement.md"]).toBe(
contentHash("# implement prompt\n"),
);
});
});
// ---------------------------------------------------------------------------
// v2/v3 upgrade seeding
// ---------------------------------------------------------------------------
describe("writer — upgrade seeding for lessons field", () => {
it("seeds lessons from a v3-without-lessons manifest on first cumulative apply", async () => {
// First apply creates a v3 manifest without lessons (simulating pre-cumulative code)
await applyBundle(bundleA(), tmp);
// Manually strip the lessons field to simulate old v3 manifest
const manifestDir = join(tmp, ".claude");
const manifest = readManifest(manifestDir)!;
const stripped = { ...manifest };
delete (stripped as Record<string, unknown>)["lessons"];
writeFileSync(
join(manifestDir, ".10x-cli-manifest.json"),
`${JSON.stringify(stripped, null, 2)}\n`,
);
// Apply a different lesson — should seed m1l1 from the old manifest
await applyBundle(bundleB(), tmp);
const updated = readManifest(manifestDir);
expect(updated!.lessons).toBeDefined();
expect(updated!.lessons!["m1l1"]).toBeDefined();
expect(updated!.lessons!["m1l2"]).toBeDefined();
// Seeded entry has the old lesson's skills
expect(Object.keys(updated!.lessons!["m1l1"]!.skills).sort()).toEqual(["code-review", "tdd"]);
// Union files includes both
expect(Object.keys(updated!.files.skills).sort()).toEqual(["code-review", "refactor", "tdd"]);
});
it("seeds lessons from a v2 manifest on upgrade", async () => {
// Hand-craft a v2 manifest
const manifestDir = join(tmp, ".claude");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, ".10x-cli-manifest.json"),
JSON.stringify({
package: "@przeprogramowani/10x-cli",
version: "0.5.0",
manifestVersion: 2,
lastApplied: "2026-04-30T00:00:00.000Z",
lessonId: "m1l1",
course: "10xdevs3",
files: {
skills: { "code-review": { files: ["SKILL.md"] } },
prompts: ["plan.md"],
configs: ["settings.json"],
},
}),
);
// Write the actual files so they exist on disk
mkdirSync(join(tmp, ".claude/skills/code-review"), { recursive: true });
mkdirSync(join(tmp, ".claude/prompts"), { recursive: true });
writeFileSync(join(tmp, ".claude/skills/code-review/SKILL.md"), "# Code Review\n\nContent A\n");
writeFileSync(join(tmp, ".claude/prompts/plan.md"), "# plan prompt\n");
// Apply m1l2 — should seed m1l1 from v2 data
await applyBundle(bundleB(), tmp);
const manifest = readManifest(manifestDir);
expect(manifest!.manifestVersion).toBe(3);
expect(manifest!.lessons).toBeDefined();
expect(manifest!.lessons!["m1l1"]).toBeDefined();
expect(manifest!.lessons!["m1l2"]).toBeDefined();
expect(manifest!.lessons!["m1l1"]!.prompts).toEqual(["plan.md"]);
// Both lessons' artifacts in union
expect(Object.keys(manifest!.files.skills).sort()).toEqual(["code-review", "refactor", "tdd"]);
});
});
// ---------------------------------------------------------------------------
// Course-rules opt-out & strip (applyCourseRules:false)
// ---------------------------------------------------------------------------
describe("writer — course rules opt-out", () => {
const BEGIN = "<!-- BEGIN @przeprogramowani/10x-cli -->";
const END = "<!-- END @przeprogramowani/10x-cli -->";
it("strips an existing block and preserves surrounding content", async () => {
// Seed a CLAUDE.md with a course block sandwiched between user content.
await applyBundle(bundleA(), tmp);
const claudeMdPath = join(tmp, "CLAUDE.md");
const seeded = `# My own rules\n\nKeep me.\n\n${readFileSync(claudeMdPath, "utf8")}\n# Trailer\n`;
writeFileSync(claudeMdPath, seeded);
expect(readFileSync(claudeMdPath, "utf8")).toContain(BEGIN);
const result = await applyBundle(bundleA(), tmp, { applyCourseRules: false });
expect(result.rules.action).toBe("removed");
const after = readFileSync(claudeMdPath, "utf8");
expect(after).not.toContain(BEGIN);
expect(after).not.toContain(END);
expect(after).toContain("# My own rules");
expect(after).toContain("Keep me.");
expect(after).toContain("# Trailer");
});
it("leaves the rules file untouched when no block is present", async () => {
const claudeMdPath = join(tmp, "CLAUDE.md");
writeFileSync(claudeMdPath, "# Just my rules\n");
const result = await applyBundle(bundleA(), tmp, { applyCourseRules: false });
expect(result.rules.action).toBe("unchanged");
expect(readFileSync(claudeMdPath, "utf8")).toBe("# Just my rules\n");
});
it("reports removed under dryRun without modifying the file", async () => {
await applyBundle(bundleA(), tmp);
const claudeMdPath = join(tmp, "CLAUDE.md");
const before = readFileSync(claudeMdPath, "utf8");
expect(before).toContain(BEGIN);
const result = await applyBundle(bundleA(), tmp, {
applyCourseRules: false,
dryRun: true,
});
expect(result.rules.action).toBe("removed");
// File on disk is unchanged.
expect(readFileSync(claudeMdPath, "utf8")).toBe(before);
});
it("default (omitted flag) still applies the block and is idempotent", async () => {
await applyBundle(bundleA(), tmp);
const claudeMdPath = join(tmp, "CLAUDE.md");
const first = readFileSync(claudeMdPath, "utf8");
expect(first).toContain(BEGIN);
const result = await applyBundle(bundleA(), tmp);
expect(result.rules.action).toBe("unchanged");
// Byte-identical re-apply.
expect(readFileSync(claudeMdPath, "utf8")).toBe(first);
});
});