From d5e41adf9c088bb974951fc077a74504e3a17bd1 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sat, 3 Oct 2026 21:34:54 -0300 Subject: [PATCH 01/26] fix(core): read only the files a target emits as an ingredient's body Rulings by the user (2026-10-03): (1) a section marker or near miss in a file no target emits is ignored silently; (2) param citation scans use the same criterion as sections; (3) take: param or take: section on such a file is refused with its own message. Sites changed: - src/core/extract.ts: added emittedFile() and bodyFile() - src/core/sync.ts: sectionPass and ctx.text use bodyFile - src/core/template-import.ts: readBase uses bodyFile - src/importers/decide.ts: sourceKeys and listAdmitted use bodyFile - src/core/param-writes.ts: cites() uses bodyFile - src/importers/claude-code.ts: markerIn uses bodyFile - src/core/unify.ts: planFrom, S2, S12, P3, declaredElsewhere, checkMarkers --- src/core/extract.ts | 31 +++++++++++ src/core/param-writes.ts | 6 +-- src/core/sync.ts | 15 +++--- src/core/template-import.ts | 4 +- src/core/unify.ts | 31 +++++++---- src/importers/claude-code.ts | 4 +- src/importers/decide.ts | 8 +-- test/importer.test.ts | 12 +++++ test/param-writes.test.ts | 5 ++ test/plan.test.ts | 23 ++++++++ test/unify.test.ts | 101 ++++++++++++++++++++++++++++++++--- 11 files changed, 203 insertions(+), 37 deletions(-) diff --git a/src/core/extract.ts b/src/core/extract.ts index 2a97154..6afa652 100644 --- a/src/core/extract.ts +++ b/src/core/extract.ts @@ -49,6 +49,37 @@ export function substitutedFile(meta: Ingredient, file: string): boolean { } } +/** + * Whether any target emits `file` (0.8.2): the body file of a rule, agent, command or steering, `SKILL.md` of a + * file-layout skill, every file of a dir-layout skill, the listed `files` of a script or hook; nothing for MCP. + * A file no target emits is never read as the ingredient's body — not for sections, not for {{param}} scans. + */ +export function emittedFile(meta: Ingredient, file: string): boolean { + switch (meta.type) { + case "rule": + return file === (meta.file ?? "rule.md"); + case "agent": + return file === (meta.file ?? "agent.md"); + case "command": + return file === (meta.file ?? "command.md"); + case "steering": + return file === (meta.file ?? "steering.md"); + case "skill": + if (meta.layout === "file") return file === "SKILL.md"; + return file !== "ingredient.yaml"; // dir layout: every file except ingredient.yaml + case "script": + case "hook": + return meta.files.includes(file); + case "mcp": + return false; + } +} + +/** A file read as the ingredient's body: some target emits it and every target that emits it renders it as text. */ +export function bodyFile(meta: Ingredient, file: string): boolean { + return emittedFile(meta, file) && substitutedFile(meta, file); +} + /** `substitute` restricted to `keys`: every other placeholder is left as it is. */ export function substituteKeys(text: string, values: Map): string { return text.replace(PLACEHOLDER, (m, key: string) => (values.has(key) ? values.get(key)! : m)); diff --git a/src/core/param-writes.ts b/src/core/param-writes.ts index 928bfc8..e08198c 100644 --- a/src/core/param-writes.ts +++ b/src/core/param-writes.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; import { IngredientSchema, ProfileSchema, WorkspaceConfigSchema } from "../schema/index.js"; -import { placeholders, substitutedFile, type Extraction } from "./extract.js"; +import { placeholders, bodyFile, type Extraction } from "./extract.js"; import { exists, listFiles, readIngredientText, FORGE_MANIFEST, type Forge, type LoadedIngredient } from "./forge.js"; import { manifestWithSections } from "./manifest-edit.js"; import { resolve, sectionKey } from "./resolve.js"; @@ -67,10 +67,10 @@ export function resolvedBy(forge: Forge, profile: string): Set { return new Set(r.ingredients.map((i) => i.ref)); } -/** Whether any file an ingredient reads through `ctx.text` cites `{{key}}`. */ +/** Whether any body file an ingredient reads through `ctx.text` cites `{{key}}`. */ async function cites(ing: LoadedIngredient, key: string): Promise { for (const rel of await listFiles(ing.dir)) { - if (rel === "ingredient.yaml" || !substitutedFile(ing.meta, rel)) continue; + if (rel === "ingredient.yaml" || !bodyFile(ing.meta, rel)) continue; if (placeholders(await readIngredientText(ing, rel)).includes(key)) return true; } return false; diff --git a/src/core/sync.ts b/src/core/sync.ts index bf2aa8b..d373422 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -6,7 +6,7 @@ import { resolve, substitute, type Resolution, type ResolvedIngredient, paramsFo import { hashNormalized, stripBom, toLf } from "./text.js"; import { deepMerge } from "./merge.js"; import { canonicalValue, checkDeclaredOnce, expandSections, firstMarkerLine, markerLine, parseSections, type ParsedSections } from "./sections.js"; -import { placeholders, substitutedFile } from "./extract.js"; +import { placeholders, substitutedFile, bodyFile, emittedFile } from "./extract.js"; import { LockSchema, WorkspaceConfigSchema, type Lock, type LockEntry, type Target, type WorkspaceConfig } from "../schema/index.js"; import { claudeCode } from "../emitters/claude-code.js"; import { kiro } from "../emitters/kiro.js"; @@ -110,19 +110,20 @@ async function sectionPass(forge: Forge, resolution: Resolution, warnings: strin for (const rel of await listFiles(ing.dir)) { if (rel === "ingredient.yaml") continue; const abs = path.join(ing.dir, rel); - if (substitutedFile(ing.meta, rel)) { + if (bodyFile(ing.meta, rel)) { const p = parseSections(await fs.readFile(abs, "utf8"), forgeRel(forge, abs), ing.ref); parsed.set(abs, p); files.push({ rel, p }); if (p.sections.length && firstMarker === null) firstMarker = `${p.file}:${p.sections[0].line}`; - } else { - // A file not every target renders as text keeps its markers; one, whatever its extension, is warned, never dropped + } else if (emittedFile(ing.meta, rel)) { + // A file emitted but not every target renders as text keeps its markers; one, whatever its extension, is warned, never dropped // silently. Every marker form contains "craftar:section", so a file without it is not decoded. const bytes = await fs.readFile(abs); if (bytes.includes("craftar:section") && toLf(stripBom(bytes.toString("utf8"))).split("\n").some((l) => markerLine(l) !== null)) { warnings.push(`${ing.ref} ${rel}: section markers are read only in files every target renders as text — copied with them`); } } + // A file no target emits is skipped silently (0.8.2) — not read for sections, not warned } checkDeclaredOnce(ing.ref, files.map((f) => f.p)); const list: Array<{ file: string; name: string; layer: SectionLayer }> = []; @@ -206,9 +207,9 @@ export async function plan(ws: Workspace): Promise { const raw = await fs.readFile(abs, "utf8"); const missing = new Set(); const params = paramsFor(ing, resolution); - // Sections first, then params (spec 11 §6.4), in admitted files only (§6.5). - const parsed = substitutedFile(ing.meta, file) ? sections.parsed.get(abs) : null; - // Every admitted file of a resolved ingredient was parsed and gated in the section pass; a miss here would skip the schema gate. + // Sections first, then params (spec 11 §6.4), in body files only (§6.5, 0.8.2). + const parsed = bodyFile(ing.meta, file) ? sections.parsed.get(abs) : null; + // Every body file of a resolved ingredient was parsed and gated in the section pass; a miss here would skip the schema gate. if (parsed === undefined) throw new Error(`internal: ${forgeRel(ws.forge, abs)} was not parsed by the section pass`); const expanded = parsed ? expandSections(parsed, sectionsFor(ing, resolution)) : toLf(stripBom(raw)); const out = substitute(expanded, params, missing); diff --git a/src/core/template-import.ts b/src/core/template-import.ts index 047ac9f..3e92e16 100644 --- a/src/core/template-import.ts +++ b/src/core/template-import.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { IngredientSchema, type Ingredient } from "../schema/index.js"; -import { placeholders, substitutedFile } from "./extract.js"; +import { placeholders, bodyFile } from "./extract.js"; import { fingerprintOf, type DirReader } from "./fingerprint.js"; import { parseYaml } from "./forge.js"; import { substitute } from "./resolve.js"; @@ -50,7 +50,7 @@ export async function readBase(dir: string, io: DirReader, forgeRoot?: string): try { for (const rel of await io.list(dir)) { if (rel === "ingredient.yaml") continue; - if (substitutedFile(meta, rel)) { + if (bodyFile(meta, rel)) { const text = norm(await io.readText(path.join(dir, rel))); texts.set(rel, text); parsed.set(rel, parseSections(text, label(rel), `${meta.type}/${meta.name}`)); diff --git a/src/core/unify.ts b/src/core/unify.ts index f34c7c3..13e97b6 100644 --- a/src/core/unify.ts +++ b/src/core/unify.ts @@ -8,7 +8,7 @@ import { splitLines, type Hunk } from "./diff.js"; import { detectEol, withEol, type Eol } from "./text.js"; import type { IngredientDiff } from "./variants.js"; import type { HunkTake, Ingredient, IngredientRef, Recipe, UnifyPlan, PlanFile, PlanHunk } from "../schema/index.js"; -import { collect, deriveHunk, prove, substitutedFile, type Extraction } from "./extract.js"; +import { collect, deriveHunk, prove, substitutedFile, bodyFile, emittedFile, type Extraction } from "./extract.js"; import { firstMarkerLine, parseSections, SectionMarkerError } from "./sections.js"; import { sectionKey } from "./resolve.js"; import { deriveSections, prefillSections, proveSections, type MarkerInsertion, type SectionRun } from "./section-extract.js"; @@ -78,13 +78,13 @@ export async function planFrom( ): Promise { await assertTextMergeable(base, variant); - // Build `declared`: every section name the ingredient already declares, in any admitted file (spec 12 §4.2). + // Build `declared`: every section name the ingredient already declares, in any body file (spec 12 §4.2, 0.8.2). const declared = new Set(); const baseFiles = await listFiles(base.dir); const label = (rel: string) => ["ingredients", path.basename(path.dirname(base.dir)), path.basename(base.dir), rel].join("/"); for (const rel of baseFiles) { if (rel === "ingredient.yaml") continue; - if (!substitutedFile(base.meta, rel)) continue; + if (!bodyFile(base.meta, rel)) continue; try { const text = await readIngredientText(base, rel); const parsed = parseSections(text, label(rel), base.ref); @@ -97,9 +97,9 @@ export async function planFrom( const files: PlanFile[] = []; for (const f of diff.files) { - // Pre-fill section names only when the file is admitted (substitutedFile) (spec 12 §4.2) + // Pre-fill section names only when the file is a body file (spec 12 §4.2, 0.8.2) let sectionNames: Array = []; - if (substitutedFile(base.meta, f.file)) { + if (bodyFile(base.meta, f.file)) { try { const baseText = await readIngredientText(base, f.file); sectionNames = prefillSections({ @@ -365,14 +365,14 @@ export async function applyPlan( } } - // S12 first part: When the plan has at least one section hunk, every admitted file of the VARIANT + // S12 first part: When the plan has at least one section hunk, every body file of the VARIANT // (not only the files with runs) is checked for markers. const planHasSectionHunk = plan.files.some((pf) => pf.hunks?.some((h) => h.take === "section")); if (planHasSectionHunk) { const variantFiles = await listFiles(variant.dir); for (const rel of variantFiles) { if (rel === "ingredient.yaml") continue; - if (!substitutedFile(variant.meta, rel)) continue; + if (!bodyFile(variant.meta, rel)) continue; const variantText = await readIngredientText(variant, rel); const markerLine = firstMarkerLine(toLf(stripBom(variantText))); if (markerLine !== null) { @@ -383,7 +383,7 @@ export async function applyPlan( } } - // Build declaredElsewhere: section names declared in the base's OTHER admitted files. + // Build declaredElsewhere: section names declared in the base's OTHER body files. // This is needed for S6 (new section name already declared). const baseFiles = await listFiles(base.dir); const label = (rel: string) => ["ingredients", path.basename(path.dirname(base.dir)), path.basename(base.dir), rel].join("/"); @@ -426,7 +426,12 @@ export async function applyPlan( if (sectionOf.size > 0) { filesWithSectionHunks.add(pf.file); - // S2: the file must be substituted + // S2: the file must be emitted and substituted (0.8.2) + if (!emittedFile(base.meta, pf.file)) { + throw new Error( + `unify plan: "${pf.file}" is not emitted by any target — a section there would never render`, + ); + } if (!substitutedFile(base.meta, pf.file)) { throw new Error( `unify plan: "${pf.file}" is copied without expansion by a target that emits it — a section marker there would be emitted literally`, @@ -437,7 +442,7 @@ export async function applyPlan( const declaredElsewhere = new Map(); for (const otherFile of baseFiles) { if (otherFile === "ingredient.yaml" || otherFile === pf.file) continue; - if (!substitutedFile(base.meta, otherFile)) continue; + if (!bodyFile(base.meta, otherFile)) continue; const otherText = await readIngredientText(base, otherFile); const parsed = parseSections(otherText, label(otherFile), base.ref); for (const s of parsed.sections) { @@ -537,6 +542,10 @@ export async function applyPlan( // entirely rather than round-tripping the base through `splitLines`/`withEol` for nothing, // which would re-terminate a base with mixed line endings even though no decision moved it. if (paramOf.size) { + // P3: the file must be emitted and substituted (0.8.2) + if (!emittedFile(base.meta, pf.file)) { + throw new Error(`unify plan: "${pf.file}" is not emitted by any target — a {{param}} there would never render`); + } if (!substitutedFile(base.meta, pf.file)) { throw new Error(`unify plan: "${pf.file}" is copied without substitution by a target that emits it — a {{param}} there would be emitted literally`); } @@ -680,7 +689,7 @@ async function checkMarkers( newNames: Map = new Map(), ): Promise { const label = (rel: string) => ["ingredients", path.basename(path.dirname(base.dir)), path.basename(base.dir), rel].join("/"); - const touched = [...Object.keys(write), ...remove].filter((rel) => substitutedFile(base.meta, rel)).sort(); + const touched = [...Object.keys(write), ...remove].filter((rel) => bodyFile(base.meta, rel)).sort(); for (const rel of touched) { const abs = path.join(base.dir, rel); const before = (await exists(abs)) ? markerStructure(await fs.readFile(abs, "utf8"), label(rel)) : { names: [] }; diff --git a/src/importers/claude-code.ts b/src/importers/claude-code.ts index d39b8f8..f1b4166 100644 --- a/src/importers/claude-code.ts +++ b/src/importers/claude-code.ts @@ -15,7 +15,7 @@ import { resolvedBy } from "../core/param-writes.js"; import { decide, forgeBefore, pin, sourceKeys, workspaceParams, workspaceSections, type RunContext } from "./decide.js"; import { renderMap } from "../core/template-import.js"; import { firstMarkerLine } from "../core/sections.js"; -import { substitutedFile } from "../core/extract.js"; +import { bodyFile } from "../core/extract.js"; import { deepMerge } from "../core/merge.js"; export interface ImportOptions { @@ -863,7 +863,7 @@ function markerIn(meta: Ingredient, files: Record, sour const raw = "frontmatterRaw" in meta ? meta.frontmatterRaw : undefined; const offset = raw ? raw.split("\n").length + 2 : 0; for (const [rel, content] of Object.entries(files)) { - if (!substitutedFile(meta, rel)) continue; + if (!bodyFile(meta, rel)) continue; const line = firstMarkerLine(toLf(stripBom(typeof content === "string" ? content : content.toString("utf8")))); if (line !== null) return { where: meta.type === "skill" && meta.layout === "dir" ? `${source}${rel}` : source, line: line + offset }; } diff --git a/src/importers/decide.ts b/src/importers/decide.ts index d28764f..88dd516 100644 --- a/src/importers/decide.ts +++ b/src/importers/decide.ts @@ -1,7 +1,7 @@ import path from "node:path"; import YAML from "yaml"; import { z } from "zod"; -import { placeholders, reservedKey, substitutedFile } from "../core/extract.js"; +import { placeholders, reservedKey, bodyFile } from "../core/extract.js"; import { fingerprintOf } from "../core/fingerprint.js"; import { exists, listFiles, loadForge, readIngredientText, type Forge } from "../core/forge.js"; import { hashNormalized, stripBom, toLf } from "../core/text.js"; @@ -65,10 +65,10 @@ const norm = (s: string) => toLf(stripBom(s)); const textOf = (c: string | Buffer) => norm(typeof c === "string" ? c : c.toString("utf8")); const valueOf = (m: Record, k: string) => (Object.hasOwn(m, k) ? String(m[k]) : undefined); -/** The keys a source's admitted files cite literally. */ +/** The keys a source's body files cite literally. */ export function sourceKeys(meta: Ingredient, files: Record): Set { const out = new Set(); - for (const [rel, c] of Object.entries(files)) if (substitutedFile(meta, rel)) for (const k of placeholders(textOf(c))) out.add(k); + for (const [rel, c] of Object.entries(files)) if (bodyFile(meta, rel)) for (const k of placeholders(textOf(c))) out.add(k); return out; } @@ -273,7 +273,7 @@ function inferSectionValues( } async function listAdmitted(ing: { dir: string; meta: Ingredient }): Promise { - return (await listFiles(ing.dir)).filter((rel) => rel !== "ingredient.yaml" && substitutedFile(ing.meta, rel)); + return (await listFiles(ing.dir)).filter((rel) => rel !== "ingredient.yaml" && bodyFile(ing.meta, rel)); } const OverridesParams = z.record(z.unknown()); diff --git a/test/importer.test.ts b/test/importer.test.ts index 831d31b..fc7c0d2 100644 --- a/test/importer.test.ts +++ b/test/importer.test.ts @@ -1091,6 +1091,18 @@ describe("section-aware import — decisions (spec 11 §6.7–§6.10)", () => { expect(await snapshot(t.forge)).toEqual(before); }); + it("a malformed marker in a file no target emits does not refuse the import (0.8.2)", async () => { + const t = await setup(); + await marked(t); + // Add a notes.md with a malformed marker to the Forge ingredient + await fs.writeFile(path.join(t.forge, "ingredients/rules/review-posture/notes.md"), `${OPEN("x")}no closer\n`); + // The import should not fail due to the malformed marker in notes.md (a file no target emits) + // The source will become a variant since the file set differs, but that's fine + const r = await importInto(t.forge, t.ws("acme"), "acme"); + // Verify the import succeeded (created a variant because file sets differ) + expect(r.variants.map((v) => v.name)).toEqual(["rule/review-posture--acme"]); + }); + it("param inference is preferred for a {{k}} inside a default (Ruling 16); a source that differs in both is a variant", async () => { const t = await setup(); await marked(t, `Deploy {{deploy.api}}.\n${OPEN("rows")}| {{owner}} |\n${CLOSE}`, "Deploy globex-api.\n| ann |\n"); diff --git a/test/param-writes.test.ts b/test/param-writes.test.ts index f79742f..92734da 100644 --- a/test/param-writes.test.ts +++ b/test/param-writes.test.ts @@ -99,6 +99,11 @@ describe("checkParamWrites — Forge-level refusals (spec 09 §6.3)", () => { expect(await err(check(v, [ext("k", "globex-api", "acme-api")]))).toContain("rule/deploy--acme already uses {{k}}"); }); + it("P14 ignores a {{key}} in a file no target emits (0.8.2)", async () => { + const b = await forgeOf(spec({ ingredients: [] }), { "ingredients/rules/deploy/notes.md": "see {{k}}\n" }); + expect(await err(check(b, [ext("k", "globex-api", "acme-api")]))).not.toContain("already uses {{k}}"); + }); + it("P15, P16: a recipe or another profile sets the key to another value", async () => { const r = await forgeOf(spec({ recipes: [recipe("base", ["rule/deploy"], { params: { k: { default: "x" } } }), recipe("base--acme", ["rule/deploy--acme"])] })); expect(await err(check(r, [ext("k", "globex-api", "acme-api")]))).toContain("recipe base declares k"); diff --git a/test/plan.test.ts b/test/plan.test.ts index 3dbb4e6..1a5aca5 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -148,6 +148,29 @@ describe("sections — plan warnings (Ruling 12)", () => { }); }); +describe("0.8.2 — files no target emits are not read for sections", () => { + it("a near-miss marker in a file no target emits does not fail the plan", async () => { + const r: IngredientSpec = { meta: { type: "rule", name: "r" }, files: { "rule.md": "# R\n", "notes.md": "\n" } }; + const p = await sectionPlan({ ingredients: [r] }); + expect(out(p, ".claude/rules/r.md")).toBe("# R\n"); + expect(p.warnings.filter((w) => /section/.test(w))).toEqual([]); + }); + + it("a marker in a file no target emits neither trips the schema: 1 gate nor declares a section", async () => { + const r: IngredientSpec = { meta: { type: "rule", name: "r" }, files: { "rule.md": "# R\n", "notes.md": [OPEN("x"), "y", CLOSE, ""].join("\n") } }; + const p = await sectionPlan({ ingredients: [r], schema: 1, profileExtra: { sections: { "rule/r": { x: "z" } } } }); + expect(out(p, ".claude/rules/r.md")).toBe("# R\n"); + expect((p.sections.get("rule/r") ?? []).length).toBe(0); + expect(p.warnings).toContain("profile acme sets section x of rule/r, which has no such marker"); + }); + + it("a dir skill still expands sections in every text file it emits", async () => { + const s: IngredientSpec = { meta: { type: "skill", name: "tool" }, files: { "SKILL.md": "# Tool\n", "ref.md": [OPEN("x"), "default", CLOSE, ""].join("\n") } }; + const p = await sectionPlan({ ingredients: [s], profileExtra: { sections: { "skill/tool": { x: "mine" } } } }); + expect(out(p, ".claude/skills/tool/ref.md")).toBe("mine\n"); + }); +}); + describe("sections — parse errors and the output guard", () => { it("a malformed marker fails the plan naming the Forge file and line, whichever targets resolve (edge case 20)", async () => { const bad = rule("r", ["# R", OPEN("flavors"), "x", ""].join("\n"), { targets: ["kiro"] }); diff --git a/test/unify.test.ts b/test/unify.test.ts index 69b536e..24d766f 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -845,14 +845,51 @@ describe("U1 — a merge never changes section markers (spec 11 §6.12, Ruling 8 it("refuses a variant-only file that would bring markers in, and a base-only file removed with its sections", async () => { const e = (p: Promise) => p.then(() => "no error", (x: Error) => x.message); - const add = await scenario({ "rule.md": "a\n" }, { "rule.md": "a\n", "notes.md": "\nx\n\n" }); - const addPlan = await planFrom(add.base, add.variant, add.diff, "acme"); - addPlan.files[0].take = "variant"; - expect(await e(applyPlan(add.base, add.variant, add.diff, addPlan))).toContain("sections none would become n"); - const rm = await scenario({ "rule.md": "a\n", "notes.md": "\nx\n\n" }, { "rule.md": "a\n" }); - const rmPlan = await planFrom(rm.base, rm.variant, rm.diff, "acme"); - rmPlan.files[0].take = "variant"; - expect(await e(applyPlan(rm.base, rm.variant, rm.diff, rmPlan))).toContain("the file would be removed with sections n"); + // 0.8.2: notes.md beside rule.md in a rule is not emitted by any target, so markers are ignored. + // For a file that IS emitted (a skill dir's text file), markers still matter. + const skill = (name: string, body: Record, extra: Record = {}): IngredientSpec => ({ + meta: { type: "skill", name, layout: "dir", ...extra }, + files: body, + }); + const addRoot = await tmpDir("craftar-u1-add-"); + cleanups.push(() => fs.rm(addRoot, { recursive: true, force: true })); + await makeForge(addRoot, { + ingredients: [ + skill("tool", { "SKILL.md": "a\n" }), + skill("tool--acme", { "SKILL.md": "a\n", "notes.md": "\nx\n\n" }, { as: "tool" }), + ], + recipes: [recipe("base", ["skill/tool"]), recipe("base--acme", ["skill/tool--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const addForge = await loadForge(addRoot); + const addBase = addForge.ingredients.get("skill/tool")!; + const addVariant = addForge.ingredients.get("skill/tool--acme")!; + const addDiff = await diffIngredients(addBase, addVariant); + const addPlan = await planFrom(addBase, addVariant, addDiff, "acme"); + // notes.md is one-sided in variant + const notesPlan = addPlan.files.find((f) => f.file === "notes.md"); + if (notesPlan) notesPlan.take = "variant"; + expect(await e(applyPlan(addBase, addVariant, addDiff, addPlan))).toContain("sections none would become n"); + + const rmRoot = await tmpDir("craftar-u1-rm-"); + cleanups.push(() => fs.rm(rmRoot, { recursive: true, force: true })); + await makeForge(rmRoot, { + ingredients: [ + skill("tool", { "SKILL.md": "a\n", "notes.md": "\nx\n\n" }), + skill("tool--acme", { "SKILL.md": "a\n" }, { as: "tool" }), + ], + recipes: [recipe("base", ["skill/tool"]), recipe("base--acme", ["skill/tool--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const rmForge = await loadForge(rmRoot); + const rmBase = rmForge.ingredients.get("skill/tool")!; + const rmVariant = rmForge.ingredients.get("skill/tool--acme")!; + const rmDiff = await diffIngredients(rmBase, rmVariant); + const rmPlan = await planFrom(rmBase, rmVariant, rmDiff, "acme"); + // notes.md is one-sided in base + const rmNotesPlan = rmPlan.files.find((f) => f.file === "notes.md"); + if (rmNotesPlan) rmNotesPlan.take = "variant"; + expect(await e(applyPlan(rmBase, rmVariant, rmDiff, rmPlan))).toContain("the file would be removed with sections n"); }); }); @@ -1162,6 +1199,54 @@ Done. ); }); + it("a malformed marker in a file no target emits does not block take: section (0.8.2)", async () => { + const root = await tmpDir("craftar-section-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const notes = "\nno closer\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "review-posture" }, files: { "rule.md": "# R\n\n| a |\n\nEnd.\n", "notes.md": notes } }, + { meta: { type: "rule", name: "review-posture--acme", as: "review-posture" }, files: { "rule.md": "# R\n\n| a |\n| b |\n\nEnd.\n", "notes.md": notes } }, + ], + recipes: [recipe("base", ["rule/review-posture"]), recipe("base--acme", ["rule/review-posture--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/review-posture")!; + const variant = forge.ingredients.get("rule/review-posture--acme")!; + const diff = await diffIngredients(base, variant); + const planObj = await planFrom(base, variant, diff, "acme"); + planObj.files[0].hunks![0].take = "section"; + (planObj.files[0].hunks![0] as Record).section = { name: "flavors" }; + const result = await applyPlan(base, variant, diff, planObj); + expect(result.resolved).toBe(true); + expect(result.sections.map((s) => s.name)).toEqual(["flavors"]); + }); + + it("take: section or take: param on a file no target emits is refused with its own message (0.8.2)", async () => { + const root = await tmpDir("craftar-section-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "w" }, files: { "rule.md": "# W\n", "notes.md": "use globex-api\n" } }, + { meta: { type: "rule", name: "w--acme", as: "w" }, files: { "rule.md": "# W\n", "notes.md": "use acme-api\n" } }, + ], + recipes: [recipe("base", ["rule/w"]), recipe("base--acme", ["rule/w--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/w")!; + const variant = forge.ingredients.get("rule/w--acme")!; + const diff = await diffIngredients(base, variant); + const err = (p: Promise) => p.then(() => "no error", (e: Error) => e.message); + const asParam = await planFrom(base, variant, diff, "acme"); + Object.assign(asParam.files[0].hunks![0], { take: "param", params: [{ token: "globex-api", key: "k" }] }); + expect(await err(applyPlan(base, variant, diff, asParam))).toContain('unify plan: "notes.md" is not emitted by any target'); + const asSection = await planFrom(base, variant, diff, "acme"); + Object.assign(asSection.files[0].hunks![0], { take: "section", section: { name: "s" } }); + expect(await err(applyPlan(base, variant, diff, asSection))).toContain('unify plan: "notes.md" is not emitted by any target'); + }); + it("U1: --take variant over a marked base is still refused", async () => { // This test ensures that taking variant on a file with markers is still refused const baseBody = "# Review\n\n\n| a |\n\n\nDone.\n"; From 574e458480eb2086d9a2e619e6da14a510429a9a Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sat, 3 Oct 2026 21:36:45 -0300 Subject: [PATCH 02/26] docs(readme): say that only emitted files are read as an ingredient's body Updated the Forge layout section to clarify that sections are read only in files a target emits as text. Added a note to the forge unify row about refusing take: param/section on non-emitted files. Added to 0.8.2 upgrade section. --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0fa8e69..4e1fe43 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | `craftar ls` | Recipes and ingredients resolved for this workspace. | | `craftar forge variants [--forge \| --workspace ] [--json]` | Lists ingredients that have variants, nearest first, with the profile each came from, its distance to the base and its hunks counted by suggested class (`[1 evolution · 2 block]`), then any variant whose base is missing from the Forge. Read-only; exits 0 either way. `--json` prints `{groups, orphans}`; each variant carries `classes: {evolution, value, block}`, which add up to `distance.hunks`. | | `craftar forge diff [--against ] [--forge \| --workspace ] [--json]` | Shows the differences between a base ingredient and each of its variants: a header carrying the same distance `forge variants` reports, then hunk by hunk, each with a suggested class and reason — `evolution` (one side is newer text), `value` (an identifier-like token swapped in shared prose, with a suggested `param.`) or `block` (lines only one side has) — then the files that exist on only one side. A suggestion never decides anything. It compares the raw Forge text, section marker lines included, so a base with markers shows them as hunks against a variant that has none. Read-only. `--json` prints an array of `{ref, profile, distance, diff}`; each hunk carries `suggestion: {class, reason, tokens?}` next to its `kind`. | -| `craftar forge unify --profile

(--take base\|variant \| --plan \| --save-plan ) [--forge

\| --workspace ] [--json]` | Resolves one variant back into its base, hunk by hunk. `--save-plan` writes a reviewable plan with every decision set to `keep`, each hunk annotated with its suggested class (which `--plan` ignores) and, for a `value` hunk, a pre-filled `params` list (`token` → suggested `key`); on a `block` hunk, a pre-filled `section: { name }` from the Markdown heading above (or `section-`); and on a hunk touching a section the base already has, that section's name. It refuses a path that resolves inside the Forge (after `..` segments and symlinks) and a path that already exists, so it never overwrites a Forge file or a plan you already edited; it writes nothing else. A hunk set to `take: param` turns each listed token into `{{key}}` in the base, declares the base's text as the key's default in the base's `ingredient.yaml` and writes the variant's text into the variant profile's `profile.yaml` `params` — only after proving that both the base's and the variant's text render back exactly, and only when the same plan resolves the variant; it refuses (with the Forge untouched) a key another layer, profile or ingredient already uses, a variant another profile also uses, a whitespace-only difference, and a YAML file that would not round-trip unchanged. A hunk set to `take: section` (with `section: { name, lines? }`) wraps the base's lines in section markers as the default and writes the variant's lines into the variant profile's `sections` — consecutive hunks with one name form one section, `lines: "-"` widens it over equal base lines, and hunks inside a section the base already has only write the profile value; it is proved (both sides render back exactly) and needs the same plan to resolve the variant; `take: param` and `take: section` may share a plan, never a hunk. It refuses (Forge untouched) a variant that holds markers, a profile that already sets that section (another profile for a new section, or the variant's profile with other content), a span that cuts a hunk or crosses another section, a span that covers a hunk outside the run (including `take: base` on a hunk inside a section the plan fills), a hunk mixing lines inside and outside an existing section, and a span reaching a missing final newline; when it adds a section to a Forge still at `schema: 1` it sets `schema: 2` in `craftar.forge.yaml` in place (refused if the file does not round-trip). `--plan` applies an edited plan, refusing when it is not for this exact ingredient/profile or either side's fingerprint moved since it was saved. `--take base\|variant` resolves every decision to that side. Writes the merged base and, once every difference is resolved, removes the variant and rewrites every recipe reference to it (`ingredients`) to name the base. It never deletes a recipe and never edits an `extends`; it edits a profile only to write the values of a `take: param` or `take: section` extraction into the variant's own profile, and otherwise never: a `--` left identical to `` is reported as a warning, to be removed by hand after repointing the lists that name it — unify does not, because a workspace or an `extends` chain may also name `` and the recipe order or param precedence would change. Unify cannot reach workspaces, so a removed variant is reported as a warning for any `craftar.yaml` that disables it in `overrides.ingredients.disable`. Every recipe rewrite is checked before the first write, so one that cannot land (a reference behind a YAML alias) is refused with the Forge untouched; a failure after writing began (an I/O error, a locked file) names the paths already touched and the `git checkout` / `git clean` commands that undo them. A merge never adds, removes or changes a section marker other than the sections the plan declares; a plan or `--take variant` that would is refused before the first write — take `base` for the marker lines, or `take: section` to fill the section; the citation checks of `take: param` also read the profiles' section values, which can cite `{{key}}`. `ingredient.yaml` is never merged: when the two sides' metadata differs (beyond `name`, `as` and `origin`), the variant stays unresolved and the differing fields are named — edit it by hand, or `--take base` to discard the variant, metadata included. Requires a clean git checkout in the Forge with at least one commit (`--save-plan` excepted) — the Forge has no lock, so git is the undo. It also refuses when any path it would overwrite or delete (the base, the variant, the recipe files it rewrites, the profile a parameter or section extraction edits, `craftar.forge.yaml` when it is bumped) is ignored, untracked, modified, or flagged skip-worktree or assume-unchanged in the index, since git could not restore it. `--json` prints `{base, profile, resolved, written, removed, unresolved, variantRemoved, recipes: {rewritten, identicalToSibling}, metaDiffers, params, profileEdited, sections, manifestEdited, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. | +| `craftar forge unify --profile

(--take base\|variant \| --plan \| --save-plan ) [--forge

\| --workspace ] [--json]` | Resolves one variant back into its base, hunk by hunk. `--save-plan` writes a reviewable plan with every decision set to `keep`, each hunk annotated with its suggested class (which `--plan` ignores) and, for a `value` hunk, a pre-filled `params` list (`token` → suggested `key`); on a `block` hunk, a pre-filled `section: { name }` from the Markdown heading above (or `section-`); and on a hunk touching a section the base already has, that section's name. It refuses a path that resolves inside the Forge (after `..` segments and symlinks) and a path that already exists, so it never overwrites a Forge file or a plan you already edited; it writes nothing else. A hunk set to `take: param` turns each listed token into `{{key}}` in the base, declares the base's text as the key's default in the base's `ingredient.yaml` and writes the variant's text into the variant profile's `profile.yaml` `params` — only after proving that both the base's and the variant's text render back exactly, and only when the same plan resolves the variant; it refuses (with the Forge untouched) a key another layer, profile or ingredient already uses, a variant another profile also uses, a whitespace-only difference, and a YAML file that would not round-trip unchanged. A hunk set to `take: section` (with `section: { name, lines? }`) wraps the base's lines in section markers as the default and writes the variant's lines into the variant profile's `sections` — consecutive hunks with one name form one section, `lines: "-"` widens it over equal base lines, and hunks inside a section the base already has only write the profile value; it is proved (both sides render back exactly) and needs the same plan to resolve the variant; `take: param` and `take: section` may share a plan, never a hunk. It refuses (Forge untouched) a `take: param` or `take: section` on a file no target emits, a variant that holds markers, a profile that already sets that section (another profile for a new section, or the variant's profile with other content), a span that cuts a hunk or crosses another section, a span that covers a hunk outside the run (including `take: base` on a hunk inside a section the plan fills), a hunk mixing lines inside and outside an existing section, and a span reaching a missing final newline; when it adds a section to a Forge still at `schema: 1` it sets `schema: 2` in `craftar.forge.yaml` in place (refused if the file does not round-trip). `--plan` applies an edited plan, refusing when it is not for this exact ingredient/profile or either side's fingerprint moved since it was saved. `--take base\|variant` resolves every decision to that side. Writes the merged base and, once every difference is resolved, removes the variant and rewrites every recipe reference to it (`ingredients`) to name the base. It never deletes a recipe and never edits an `extends`; it edits a profile only to write the values of a `take: param` or `take: section` extraction into the variant's own profile, and otherwise never: a `--` left identical to `` is reported as a warning, to be removed by hand after repointing the lists that name it — unify does not, because a workspace or an `extends` chain may also name `` and the recipe order or param precedence would change. Unify cannot reach workspaces, so a removed variant is reported as a warning for any `craftar.yaml` that disables it in `overrides.ingredients.disable`. Every recipe rewrite is checked before the first write, so one that cannot land (a reference behind a YAML alias) is refused with the Forge untouched; a failure after writing began (an I/O error, a locked file) names the paths already touched and the `git checkout` / `git clean` commands that undo them. A merge never adds, removes or changes a section marker other than the sections the plan declares; a plan or `--take variant` that would is refused before the first write — take `base` for the marker lines, or `take: section` to fill the section; the citation checks of `take: param` also read the profiles' section values, which can cite `{{key}}`. `ingredient.yaml` is never merged: when the two sides' metadata differs (beyond `name`, `as` and `origin`), the variant stays unresolved and the differing fields are named — edit it by hand, or `--take base` to discard the variant, metadata included. Requires a clean git checkout in the Forge with at least one commit (`--save-plan` excepted) — the Forge has no lock, so git is the undo. It also refuses when any path it would overwrite or delete (the base, the variant, the recipe files it rewrites, the profile a parameter or section extraction edits, `craftar.forge.yaml` when it is bumped) is ignored, untracked, modified, or flagged skip-worktree or assume-unchanged in the index, since git could not restore it. `--json` prints `{base, profile, resolved, written, removed, unresolved, variantRemoved, recipes: {rewritten, identicalToSibling}, metaDiffers, params, profileEdited, sections, manifestEdited, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. | All commands take `--workspace ` (default: current directory); the `forge` commands also take `--forge ` as an alternative to it. @@ -120,7 +120,7 @@ server: timeout: 30 ``` -**Sections.** A body (`rule.md`, `agent.md`, `command.md`, `steering.md`, `SKILL.md`, a script or hook text file, a skill file every target renders as text) can hold blocks a profile or a workspace replaces. A block sits between two marker lines, and its content is the default: +**Sections.** A body (`rule.md`, `agent.md`, `command.md`, `steering.md`, `SKILL.md`, a script or hook text file, a skill file every target renders as text) can hold blocks a profile or a workspace replaces. Sections are read only in files a target emits as text; markers in any other file of the ingredient directory (a `notes.md` beside `rule.md`) are ignored. A block sits between two marker lines, and its content is the default: ```markdown Dispatch reviewers after every commit. @@ -241,6 +241,10 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ## Upgrading +### to 0.8.2 + +- **A file no target emits (e.g. notes beside `rule.md`) is no longer read for sections or `{{param}}` citations** — a malformed marker there no longer fails `sync`/`status`/`import`, and a `{{key}}` there no longer makes `unify`/`import` refuse; `take: param`/`take: section` on such a file is refused with its own message. No emitted byte changes. + ### to 0.8.1 - **No behaviour change.** This is a documentation and test-only release. From b38b51c88a923bcb2c4320fe8fcb4b074db1a3b6 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sat, 3 Oct 2026 21:37:47 -0300 Subject: [PATCH 03/26] chore: update version craftar-cli 0.8.2: files no target emits are not read as an ingredient's body. --- package-lock.json | 4 ++-- package.json | 2 +- src/cli.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 73a190e..5a5a650 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.1", + "version": "0.8.2", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 6b16438..c1ff576 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.8.1", + "version": "0.8.2", "description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.", "license": "MIT", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index b0e56da..d00b8cb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,7 @@ import { HUNK_CLASSES, UnifyPlanSchema, type HunkClass, type HunkSuggestion, typ process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE") process.exit(0); }); const program = new Command(); -program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.1"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.2"); /* ---------------------------------------------------------------- import */ program From 0daeb10121f2e169cfe9a653ae74c9143041cfc1 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 06:49:11 -0300 Subject: [PATCH 04/26] fix(core): compare emitted paths in normal form A Forge with `file: ./rule.md` or `files: ["./run.sh"]` failed every sync with an internal error because emittedFile compared paths as exact strings. This normalizes both the file from listFiles and the meta.file entry so that `./rule.md` equals `rule.md`. The test confirms a regression that broke 0.8.1 syncs for such Forges. --- src/core/extract.ts | 14 ++++++++------ test/plan.test.ts | 6 ++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/core/extract.ts b/src/core/extract.ts index 6afa652..7274012 100644 --- a/src/core/extract.ts +++ b/src/core/extract.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { Ingredient, PlanParam } from "../schema/index.js"; import { changedRegions } from "./classify.js"; import { TEXT_EXT } from "../emitters/claude-code.js"; @@ -55,21 +56,22 @@ export function substitutedFile(meta: Ingredient, file: string): boolean { * A file no target emits is never read as the ingredient's body — not for sections, not for {{param}} scans. */ export function emittedFile(meta: Ingredient, file: string): boolean { + const norm = (x: string) => path.posix.normalize(x.replace(/\\/g, "/")); switch (meta.type) { case "rule": - return file === (meta.file ?? "rule.md"); + return norm(file) === norm(meta.file ?? "rule.md"); case "agent": - return file === (meta.file ?? "agent.md"); + return norm(file) === norm(meta.file ?? "agent.md"); case "command": - return file === (meta.file ?? "command.md"); + return norm(file) === norm(meta.file ?? "command.md"); case "steering": - return file === (meta.file ?? "steering.md"); + return norm(file) === norm(meta.file ?? "steering.md"); case "skill": - if (meta.layout === "file") return file === "SKILL.md"; + if (meta.layout === "file") return norm(file) === "SKILL.md"; return file !== "ingredient.yaml"; // dir layout: every file except ingredient.yaml case "script": case "hook": - return meta.files.includes(file); + return meta.files.some((f) => norm(f) === norm(file)); case "mcp": return false; } diff --git a/test/plan.test.ts b/test/plan.test.ts index 1a5aca5..8736820 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -169,6 +169,12 @@ describe("0.8.2 — files no target emits are not read for sections", () => { const p = await sectionPlan({ ingredients: [s], profileExtra: { sections: { "skill/tool": { x: "mine" } } } }); expect(out(p, ".claude/skills/tool/ref.md")).toBe("mine\n"); }); + + it("an ingredient whose file is spelled ./rule.md still syncs (0.8.2 regression)", async () => { + const r: IngredientSpec = { meta: { type: "rule", name: "r", file: "./rule.md" }, files: { "rule.md": "# R v\n" } }; + const p = await sectionPlan({ ingredients: [r] }); + expect(out(p, ".claude/rules/r.md")).toBe("# R v\n"); + }); }); describe("sections — parse errors and the output guard", () => { From 153c71584ae455872b2c15af3b0069ea09f061ad Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 06:51:04 -0300 Subject: [PATCH 05/26] test(import): a {{key}} in a file no target emits does not hold a param change back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin ruling 2 (F9, 0.8.2): a placeholder in notes.md (a file no target emits) no longer blocks parameter inference on the parent rule/deploy. Proved by temporarily reverting listAdmitted to use substitutedFile — the test then failed with "setting deploy.api would change rule/deploy- notes"; restored by editing. --- test/importer.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/importer.test.ts b/test/importer.test.ts index fc7c0d2..d160047 100644 --- a/test/importer.test.ts +++ b/test/importer.test.ts @@ -622,6 +622,16 @@ describe("template-aware import — decisions (spec 10 §6.1–§6.5)", () => { expect(r.params).toEqual([]); }); + it("does not hold a profile change back for a {{key}} in a file no target emits (F9, 0.8.2)", async () => { + const t = await setup(); + await templated(t, { deploy }); + await writeFiles(path.join(t.forge, "ingredients/rules/deploy-notes"), { "ingredient.yaml": "type: rule\nname: deploy-notes\n", "rule.md": "see the docs\n", "notes.md": "see {{deploy.api}}\n" }); + await writeFiles(t.ws("b"), { ".claude/rules/deploy.md": "use initech-api here\n" }); + const r = await importInto(t.forge, t.ws("b"), "b"); + expect(r.variants).toEqual([]); + expect(r.params.length).toBeGreaterThan(0); + }); + it("keeps a Forge base in the F9 scan when its workspace source is rejected for a secret (§6.5 (b))", async () => { const t = await setup(); await templated(t, { deploy }); From ddcb02aab0ce1713dd8bced0f13adaab81419256 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 06:53:00 -0300 Subject: [PATCH 06/26] test(unify): a rule's notes.md with markers is carried over by take: variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the relaxed U1 for 0.8.2: a file no target emits (like notes.md beside rule.md) is not read for section markers by checkMarkers, so take: variant copies it as a raw diff, markers and all. Proved by temporarily reverting the touched filter to substitutedFile — the test then failed with U1 refusal "sections none would become n"; restored by editing. --- test/unify.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/unify.test.ts b/test/unify.test.ts index 24d766f..6eb009c 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -891,6 +891,30 @@ describe("U1 — a merge never changes section markers (spec 11 §6.12, Ruling 8 if (rmNotesPlan) rmNotesPlan.take = "variant"; expect(await e(applyPlan(rmBase, rmVariant, rmDiff, rmPlan))).toContain("the file would be removed with sections n"); }); + + it("carries a rule's notes.md with markers over by take: variant — no target emits it (0.8.2)", async () => { + const root = await tmpDir("craftar-u1-notes-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const notes = "\nx\n\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "w" }, files: { "rule.md": "a\n" } }, + { meta: { type: "rule", name: "w--acme", as: "w" }, files: { "rule.md": "a\n", "notes.md": notes } }, + ], + recipes: [recipe("base", ["rule/w"]), recipe("base--acme", ["rule/w--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/w")!; + const variant = forge.ingredients.get("rule/w--acme")!; + const diff = await diffIngredients(base, variant); + const plan = await planFrom(base, variant, diff, "acme"); + const entry = plan.files.find((f) => f.file === "notes.md"); + expect(entry).toBeDefined(); + entry!.take = "variant"; + const result = await applyPlan(base, variant, diff, plan); + expect(result.write["notes.md"]).toBe(notes); + }); }); describe("take: section (spec 12)", () => { From 2a20144caa5ece1aa587a5e1ffdad0d1f44cb9b2 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 06:55:00 -0300 Subject: [PATCH 07/26] test: make two 0.8.2 assertions strict - unify.test.ts: replace if (notesPlan) / if (rmNotesPlan) with expect().toBeDefined() and non-null assertions so a silently-empty plan fails loudly instead of passing on a no-op. - param-writes.test.ts: replace .not.toContain() with .toBe("no error") for P14 so any unexpected rejection names itself. --- test/param-writes.test.ts | 2 +- test/unify.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/param-writes.test.ts b/test/param-writes.test.ts index 92734da..50bac8c 100644 --- a/test/param-writes.test.ts +++ b/test/param-writes.test.ts @@ -101,7 +101,7 @@ describe("checkParamWrites — Forge-level refusals (spec 09 §6.3)", () => { it("P14 ignores a {{key}} in a file no target emits (0.8.2)", async () => { const b = await forgeOf(spec({ ingredients: [] }), { "ingredients/rules/deploy/notes.md": "see {{k}}\n" }); - expect(await err(check(b, [ext("k", "globex-api", "acme-api")]))).not.toContain("already uses {{k}}"); + expect(await err(check(b, [ext("k", "globex-api", "acme-api")]))).toBe("no error"); }); it("P15, P16: a recipe or another profile sets the key to another value", async () => { diff --git a/test/unify.test.ts b/test/unify.test.ts index 6eb009c..5ee9a16 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -868,7 +868,8 @@ describe("U1 — a merge never changes section markers (spec 11 §6.12, Ruling 8 const addPlan = await planFrom(addBase, addVariant, addDiff, "acme"); // notes.md is one-sided in variant const notesPlan = addPlan.files.find((f) => f.file === "notes.md"); - if (notesPlan) notesPlan.take = "variant"; + expect(notesPlan).toBeDefined(); + notesPlan!.take = "variant"; expect(await e(applyPlan(addBase, addVariant, addDiff, addPlan))).toContain("sections none would become n"); const rmRoot = await tmpDir("craftar-u1-rm-"); @@ -888,7 +889,8 @@ describe("U1 — a merge never changes section markers (spec 11 §6.12, Ruling 8 const rmPlan = await planFrom(rmBase, rmVariant, rmDiff, "acme"); // notes.md is one-sided in base const rmNotesPlan = rmPlan.files.find((f) => f.file === "notes.md"); - if (rmNotesPlan) rmNotesPlan.take = "variant"; + expect(rmNotesPlan).toBeDefined(); + rmNotesPlan!.take = "variant"; expect(await e(applyPlan(rmBase, rmVariant, rmDiff, rmPlan))).toContain("the file would be removed with sections n"); }); From af1968d6e70d0a6a4553f9c8bd8a3fdaef6648a8 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 06:57:11 -0300 Subject: [PATCH 08/26] docs(core): say body file, not admitted file - extract.ts: update the substitutedFile doc comment to say that whether a file is emitted at all is emittedFile's question and bodyFile combines both. - template-import.ts: all comments that said "admitted" now say "body file", matching the function name bodyFile. --- src/core/extract.ts | 5 +++-- src/core/template-import.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/core/extract.ts b/src/core/extract.ts index 7274012..9346e64 100644 --- a/src/core/extract.ts +++ b/src/core/extract.ts @@ -30,8 +30,9 @@ export function reservedKey(key: string): boolean { } /** - * Whether every target that emits `file` reads it through `ctx.text` (P3). A file no target - * emits is inert and allowed; one a target copies as raw bytes would carry `{{key}}` literally. + * Whether every target that emits `file` reads it through `ctx.text` (P3). Whether a file is + * emitted at all is `emittedFile`'s question; `bodyFile` combines both. A file a target copies + * as raw bytes would carry `{{key}}` literally. */ export function substitutedFile(meta: Ingredient, file: string): boolean { switch (meta.type) { diff --git a/src/core/template-import.ts b/src/core/template-import.ts index 3e92e16..59b8c6a 100644 --- a/src/core/template-import.ts +++ b/src/core/template-import.ts @@ -25,18 +25,18 @@ export function renderMap(meta: Ingredient, profileParams: Record; bytes: Map; metaFile: string; - /** Each admitted text parsed for section markers (spec 11 §6.7); a file with none is one outside segment. */ + /** Each body file parsed for section markers (spec 11 §6.7); a file with none is one outside segment. */ parsed: Map; } /** - * Read a base. Every admitted text is parsed for section markers; a malformed one, or a name + * Read a base. Every body file is parsed for section markers; a malformed one, or a name * declared twice across the ingredient's files, is I12 (spec 11 §4.5). `forgeRoot`, when given, * makes the file the error names Forge-relative. */ @@ -64,12 +64,12 @@ export async function readBase(dir: string, io: DirReader, forgeRoot?: string): return { meta, texts, bytes, metaFile, parsed }; } -/** The names of the sections a base declares, across its admitted files. */ +/** The names of the sections a base declares, across its body files. */ export function sectionNames(base: ImportBase): string[] { return [...base.parsed.values()].flatMap((p) => p.sections.map((s) => s.name)); } -/** The base's admitted texts with their sections expanded by `values` (spec 11 §6.2) — what sync substitutes into. */ +/** The base's body files with their sections expanded by `values` (spec 11 §6.2) — what sync substitutes into. */ export function expandedTexts(base: ImportBase, values: Record = {}): Map { const out = new Map(); for (const [rel, text] of base.texts) { @@ -79,7 +79,7 @@ export function expandedTexts(base: ImportBase, values: Record = return out; } -/** The keys every admitted file of a base cites (`C(X)`); pass `expandedTexts` for what the importing profile renders (spec 11 §6.7). */ +/** The keys every body file of a base cites (`C(X)`); pass `expandedTexts` for what the importing profile renders (spec 11 §6.7). */ export function citedKeys(texts: Map): Set { const out = new Set(); for (const t of texts.values()) for (const k of placeholders(t)) out.add(k); @@ -88,7 +88,7 @@ export function citedKeys(texts: Map): Set { /** * The fingerprint of a base rendered through `map` (spec 10 §6.2): its metadata without `params` - * (a workspace cannot express a declaration), admitted files expanded with the section values + * (a workspace cannot express a declaration), body files expanded with the section values * `sections` and then substituted, exactly as `ctx.text` does (spec 11 §6.4, §6.7), every other file * as bytes. With no declaration, no marker and nothing set, this is `fingerprintDir`. */ From b78b22c347e1329881d1dbcc6e0071554ac47bae Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 06:59:06 -0300 Subject: [PATCH 09/26] docs(readme): say which files are an ingredient's body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sections paragraph: define body files explicitly (the file of a rule, agent, command or steering; SKILL.md of a file-layout skill; every text file of a dir-layout skill; files listed in files of scripts/hooks). - Marker paragraph: markers are read only in body files; in a file a target emits but does not render as text they are copied; in a file no target emits they are ignored. - Params paragraph: only body files are read for {{param}} citations. - to 0.8.2: split the bullet into two — one for the relaxation, one for the refusal of take: param / take: section on non-emitted files. --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4e1fe43..59bda45 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ server: timeout: 30 ``` -**Sections.** A body (`rule.md`, `agent.md`, `command.md`, `steering.md`, `SKILL.md`, a script or hook text file, a skill file every target renders as text) can hold blocks a profile or a workspace replaces. Sections are read only in files a target emits as text; markers in any other file of the ingredient directory (a `notes.md` beside `rule.md`) are ignored. A block sits between two marker lines, and its content is the default: +**Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every text file of a dir-layout skill; the text files listed in `files` of a script or hook) can hold blocks a profile or a workspace replaces. Any other file in the ingredient directory (a `notes.md` beside `rule.md`) is not emitted by any target and is ignored — for sections and for `{{param}}` citations alike. A block sits between two marker lines, and its content is the default: ```markdown Dispatch reviewers after every commit. @@ -134,7 +134,7 @@ Dispatch reviewers after every commit. Never edit what a reviewer reads. ``` -A marker is a whole line starting at column 0, with single spaces (trailing spaces or tabs are tolerated). Sections do not nest, a name is declared once per ingredient, and a line that looks almost like a marker is an error. An indented marker is plain text — indent it to show the syntax in a rule. The parser is line-based and not Markdown-aware, so a column-0 marker inside a fenced block is still a marker. Markers are read only in files every target renders as text; elsewhere they are copied as they are, with a warning. Agent and command frontmatter lives in `ingredient.yaml` and is never expanded; a rule's or skill's frontmatter is part of its body file, so a column-0 marker there is read like any other. A Forge whose bodies hold a marker must declare `schema: 2` in `craftar.forge.yaml`, so that craftar 0.6.2 and older refuse it instead of emitting the markers; `import` sets it when it relies on markers. A section can also be extracted from a variant with `forge unify` (`take: section`), not only added by hand and re-imported. +A marker is a whole line starting at column 0, with single spaces (trailing spaces or tabs are tolerated). Sections do not nest, a name is declared once per ingredient, and a line that looks almost like a marker is an error. An indented marker is plain text — indent it to show the syntax in a rule. The parser is line-based and not Markdown-aware, so a column-0 marker inside a fenced block is still a marker. Markers are read only in body files; in a file a target emits but does not render as text (a skill's `.sh`, an image) they are copied as they are, with a warning; in a file no target emits they are ignored. Agent and command frontmatter lives in `ingredient.yaml` and is never expanded; a rule's or skill's frontmatter is part of its body file, so a column-0 marker there is read like any other. A Forge whose bodies hold a marker must declare `schema: 2` in `craftar.forge.yaml`, so that craftar 0.6.2 and older refuse it instead of emitting the markers; `import` sets it when it relies on markers. A section can also be extracted from a variant with `forge unify` (`take: section`), not only added by hand and re-imported. Profile `profile.yaml`, with section values keyed by `/` (the output name: a variant's values are its base's), then by section name: @@ -178,7 +178,7 @@ overrides: ingredients: { disable: [] } ``` -Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder with no value in any layer is left untouched and reported as a warning by `status` and `sync` (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. +Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; only body files are read for them, so a placeholder in a file no target emits is never rendered and never counted as a citation; a placeholder with no value in any layer is left untouched and reported as a warning by `status` and `sync` (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. Sections layer the same way, weakest → strongest: the body's default → the profile's `sections` → `craftar.yaml`'s `overrides.sections` → `craftar.local.yaml`'s, merged per section, so a workspace that sets one section of an ingredient keeps the profile's others. There is no recipe layer for sections. @@ -243,7 +243,8 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ### to 0.8.2 -- **A file no target emits (e.g. notes beside `rule.md`) is no longer read for sections or `{{param}}` citations** — a malformed marker there no longer fails `sync`/`status`/`import`, and a `{{key}}` there no longer makes `unify`/`import` refuse; `take: param`/`take: section` on such a file is refused with its own message. No emitted byte changes. +- **A file no target emits (e.g. notes beside `rule.md`) is no longer read for sections or `{{param}}` citations** — a malformed marker there no longer fails `sync`/`status`/`diff`/`explain`/`ls`/`import` or trips the `schema: 1` gate, and a `{{key}}` there no longer makes `unify`/`import` refuse. No emitted byte changes. +- **`take: param` and `take: section` on such a file are now refused** (`… is not emitted by any target`). 0.8.1 accepted `take: param` there, so a plan saved under 0.8.1 that extracts from such a file no longer applies. ### to 0.8.1 From 1e40309e4aea353917cf4dabb7668efba9f1e31a Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 07:30:21 -0300 Subject: [PATCH 10/26] fix(core): normalize emitted paths the way path.join resolves them emittedFile used path.posix.normalize which keeps leading slashes, so file: /rule.md stayed /rule.md and failed to match rule.md from listFiles. Now strips leading slashes after normalizing, matching what path.join does. Adds tests proving every 0.8.1-compatible spelling still syncs. --- src/core/extract.ts | 2 +- test/plan.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/core/extract.ts b/src/core/extract.ts index 9346e64..b0aaa9b 100644 --- a/src/core/extract.ts +++ b/src/core/extract.ts @@ -57,7 +57,7 @@ export function substitutedFile(meta: Ingredient, file: string): boolean { * A file no target emits is never read as the ingredient's body — not for sections, not for {{param}} scans. */ export function emittedFile(meta: Ingredient, file: string): boolean { - const norm = (x: string) => path.posix.normalize(x.replace(/\\/g, "/")); + const norm = (x: string) => path.posix.normalize(x.replace(/\\/g, "/")).replace(/^\/+/, ""); switch (meta.type) { case "rule": return norm(file) === norm(meta.file ?? "rule.md"); diff --git a/test/plan.test.ts b/test/plan.test.ts index 8736820..8a03279 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -175,6 +175,36 @@ describe("0.8.2 — files no target emits are not read for sections", () => { const p = await sectionPlan({ ingredients: [r] }); expect(out(p, ".claude/rules/r.md")).toBe("# R v\n"); }); + + it("every file spelling 0.8.1 synced still syncs with the same bytes (0.8.2)", async () => { + const ruleOut = async (file: string) => { + const p = await sectionPlan({ ingredients: [{ meta: { type: "rule", name: "r", file }, files: { "rule.md": "# R v\n" } }], schema: 1 }); + return p.files.map((f) => [f.path, f.content.toString("utf8")]); + }; + const reference = await ruleOut("rule.md"); + expect(reference.find(([p]) => p === ".claude/rules/r.md")?.[1]).toBe("# R v\n"); + for (const file of ["./rule.md", "/rule.md", ".//rule.md", "//rule.md", "a/../rule.md"]) { + expect(await ruleOut(file), file).toEqual(reference); + } + const scriptOut = async (file: string) => { + const p = await sectionPlan({ ingredients: [{ meta: { type: "script", name: "s", files: [file] }, files: { "run.sh": "echo hi\n" } }], targets: ["claude-code"], schema: 1 }); + return p.files.map((f) => [f.path, f.content.toString("utf8")]); + }; + expect(await scriptOut("run.sh")).toEqual([[".claude/scripts/run.sh", "echo hi\n"]]); + expect(await scriptOut("./run.sh")).toEqual([[".claude/scripts/./run.sh", "echo hi\n"]]); + expect(await scriptOut("/run.sh")).toEqual([[".claude/scripts//run.sh", "echo hi\n"]]); + expect(await scriptOut("a/../run.sh")).toEqual([[".claude/scripts/a/../run.sh", "echo hi\n"]]); + }); + + it("a file spelling 0.8.1 could not read still fails, and never as an internal error (0.8.2)", async () => { + const plan = (file: string) => sectionPlan({ ingredients: [{ meta: { type: "rule", name: "r", file }, files: { "rule.md": "# R v\n" } }], schema: 1 }); + const bad = ["rule.md/", ...(process.platform === "win32" ? [] : [".\\rule.md", "\\rule.md"])]; + for (const file of bad) { + const e = await plan(file).then(() => null, (x: Error) => x); + expect(e, file).not.toBeNull(); + expect(e!.message, file).not.toContain("internal:"); + } + }); }); describe("sections — parse errors and the output guard", () => { From 6defa8a6c3db0e2dfaa82088ad56402388594fde Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 07:31:44 -0300 Subject: [PATCH 11/26] docs(readme): say what changes for a plan saved under 0.8.1, and which files are body files 0.8.2 bullet clarifies that 0.8.1 accepted both take: param and take: section on non-emitted files and what to do if such a Forge exists. Sections paragraph now lists the exact extensions for dir skills and clarifies files emitted but not rendered as text. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 59bda45..8459893 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr |---|---| | `craftar import --from claude-code --forge --profile [--workspace .] [--write-config]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml`. A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created|edited|unchanged` lines. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. | | `craftar status` | Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in a file not every target renders as text (copied as they are). | -| `craftar sync` | Renders sections (no rendered body holds a marker; a file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock`. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default). | +| `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock`. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default). | | `craftar diff [path]` | Line diff between disk and what the Forge would generate. | | `craftar explain ` | Which ingredient, recipe chain, target and origin produced a file and, for an ingredient with sections, which layer filled each one: `sections flavors (profile globex), extra (default)` (`default`, `profile

` or `workspace`). `AGENTS.md` gets no sections line: it is not one ingredient. | | `craftar ls` | Recipes and ingredients resolved for this workspace. | @@ -120,7 +120,7 @@ server: timeout: 30 ``` -**Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every text file of a dir-layout skill; the text files listed in `files` of a script or hook) can hold blocks a profile or a workspace replaces. Any other file in the ingredient directory (a `notes.md` beside `rule.md`) is not emitted by any target and is ignored — for sections and for `{{param}}` citations alike. A block sits between two marker lines, and its content is the default: +**Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every `.md`, `.txt`, `.json`, `.yaml` or `.yml` file of a dir-layout skill; the text files listed in `files` of a script or hook) can hold blocks a profile or a workspace replaces. A file no target emits (a `notes.md` beside `rule.md`) is ignored — for sections and for `{{param}}` citations alike; a file a target emits but does not render as text is copied (see below). A block sits between two marker lines, and its content is the default: ```markdown Dispatch reviewers after every commit. @@ -244,7 +244,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ### to 0.8.2 - **A file no target emits (e.g. notes beside `rule.md`) is no longer read for sections or `{{param}}` citations** — a malformed marker there no longer fails `sync`/`status`/`diff`/`explain`/`ls`/`import` or trips the `schema: 1` gate, and a `{{key}}` there no longer makes `unify`/`import` refuse. No emitted byte changes. -- **`take: param` and `take: section` on such a file are now refused** (`… is not emitted by any target`). 0.8.1 accepted `take: param` there, so a plan saved under 0.8.1 that extracts from such a file no longer applies. +- **`take: param` and `take: section` on such a file are now refused** (`… is not emitted by any target`). 0.8.1 accepted both there, so a plan saved under 0.8.1 that extracts from such a file no longer applies. A Forge that already ran such a `take: section` under 0.8.1 has markers in that file and a value in the profile's `sections`; 0.8.2 ignores those markers and warns at every `sync` that the profile sets a section with no such marker — delete the value from the profile, or move the section into the body file. ### to 0.8.1 From afc83804ff1ea84fcb9bf2cb87531ea83b9aa5ca Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 07:34:23 -0300 Subject: [PATCH 12/26] test(unify): a file no target emits gets no pre-filled section name Pins 0.8.2's bodyFile gate in planFrom: notes.md beside rule.md in a rule is not a body file, so hunks there get no section name and names declared there do not push rule.md's section to rows-2. Mutating either bodyFile back to substitutedFile makes this test fail. --- test/unify.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/unify.test.ts b/test/unify.test.ts index 5ee9a16..d07f667 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -1444,4 +1444,27 @@ d `; expect(merged).toBe(expected); }); + + it("--save-plan pre-fills no section name in a file no target emits, and ignores names declared there (0.8.2)", async () => { + const root = await tmpDir("craftar-prefill-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "w" }, files: { "rule.md": "# W\n\n## Rows\n\n| a |\n", "notes.md": "## Rows\n\n\nx\n\n" } }, + { meta: { type: "rule", name: "w--acme", as: "w" }, files: { "rule.md": "# W\n\n## Rows\n\n| a |\n| b |\n", "notes.md": "## Rows\n\n\nx\n\n| c |\n" } }, + ], + recipes: [recipe("base", ["rule/w"]), recipe("base--acme", ["rule/w--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/w")!; + const variant = forge.ingredients.get("rule/w--acme")!; + const plan = await planFrom(base, variant, await diffIngredients(base, variant), "acme"); + const notes = plan.files.find((f) => f.file === "notes.md"); + const rule = plan.files.find((f) => f.file === "rule.md"); + expect(notes).toBeDefined(); + expect(rule).toBeDefined(); + expect(notes!.hunks!.every((h) => h.section === undefined)).toBe(true); + expect(rule!.hunks![0].section).toEqual({ name: "rows" }); + }); }); From 559d1071e905a471ac171b6cbf6fc7bb63cb93d6 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 07:36:52 -0300 Subject: [PATCH 13/26] chore(core): drop an unused import and say body file in the remaining comments substitutedFile was imported into sync.ts but never used after the 0.8.2 bodyFile gate was added. Comments that said "admitted" now say "body file" for consistency with the README and the code. --- src/core/section-extract.ts | 4 ++-- src/core/sync.ts | 6 +++--- src/importers/claude-code.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/section-extract.ts b/src/core/section-extract.ts index 83622ee..4cd9baf 100644 --- a/src/core/section-extract.ts +++ b/src/core/section-extract.ts @@ -107,7 +107,7 @@ export function deriveSections(args: { variantText: string; hunks: Hunk[]; // the file's hunks, from diffIngredients (1-based numbering = index + 1) entries: PlanHunk[]; // the plan's hunk entries for this file (any take) - declaredElsewhere: Map; // section names declared in the base's OTHER admitted files → "file:line" + declaredElsewhere: Map; // section names declared in the base's OTHER body files → "file:line" }): { runs: SectionRun[]; markers: MarkerInsertion[] } { const { file, label, ref, baseText, variantText, hunks, entries, declaredElsewhere } = args; const A = splitLines(baseText); @@ -771,7 +771,7 @@ export function prefillSections(args: { label: string; ref: string; hunks: HunkWithSuggestion[]; - /** Every section name the ingredient already declares, in any admitted file. */ + /** Every section name the ingredient already declares, in any body file. */ declared: Set; }): Array { const { baseText, label, ref, hunks, declared } = args; diff --git a/src/core/sync.ts b/src/core/sync.ts index d373422..491c5f6 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -6,7 +6,7 @@ import { resolve, substitute, type Resolution, type ResolvedIngredient, paramsFo import { hashNormalized, stripBom, toLf } from "./text.js"; import { deepMerge } from "./merge.js"; import { canonicalValue, checkDeclaredOnce, expandSections, firstMarkerLine, markerLine, parseSections, type ParsedSections } from "./sections.js"; -import { placeholders, substitutedFile, bodyFile, emittedFile } from "./extract.js"; +import { placeholders, bodyFile, emittedFile } from "./extract.js"; import { LockSchema, WorkspaceConfigSchema, type Lock, type LockEntry, type Target, type WorkspaceConfig } from "../schema/index.js"; import { claudeCode } from "../emitters/claude-code.js"; import { kiro } from "../emitters/kiro.js"; @@ -91,7 +91,7 @@ function layerLabel(resolution: Resolution, layer: SectionLayer): string { } /** - * The section pass of `plan()` (spec 11 §6.6 steps 1–5): parse every admitted file of every resolved + * The section pass of `plan()` (spec 11 §6.6 steps 1–5): parse every body file of every resolved * ingredient (a malformed marker throws, whichever targets resolve), warn on marker lines in files * not every target renders, fail a `schema: 1` Forge that holds a marker (Ruling 7/21), and warn on * section values that apply to nothing. @@ -162,7 +162,7 @@ async function sectionPass(forge: Forge, resolution: Resolution, warnings: strin } /** - * The output guard (spec 11 §6.6 step 6, Ruling 19): a rendered admitted file never holds a marker + * The output guard (spec 11 §6.6 step 6, Ruling 19): a rendered body file never holds a marker * line. When one does, it came from a value; name the first value that carries one. */ function guardOutput(ing: ResolvedIngredient, file: string, out: string, p: ParsedSections, resolution: Resolution, params: Record): void { diff --git a/src/importers/claude-code.ts b/src/importers/claude-code.ts index f1b4166..4908ad4 100644 --- a/src/importers/claude-code.ts +++ b/src/importers/claude-code.ts @@ -855,7 +855,7 @@ async function existingProfile( } /** - * I11 (spec 11 §4.5): the first column-0 section marker (or near miss) in an admitted file of a + * I11 (spec 11 §4.5): the first column-0 section marker (or near miss) in a body file of a * created or variant source, which sync would read as structure. The line counts from the top of * the workspace file, as the secret scan's does: an agent or command body starts after its frontmatter. */ From defdaf55d9979a93202b6a5e6fa25f6f1c8c4b78 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 12:50:23 -0300 Subject: [PATCH 14/26] fix(core): decide emitted files by the path the emitters read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emittedFile and bodyFile now take an optional directory parameter. When provided, the comparison uses path.join(dir, x) — the same call the emitters use — so file: ../r/rule.md resolves to the actual file. --- src/core/extract.ts | 26 ++++++++++++++++---------- src/core/param-writes.ts | 2 +- src/core/sync.ts | 6 +++--- src/core/template-import.ts | 2 +- src/core/unify.ts | 14 +++++++------- src/importers/decide.ts | 2 +- test/plan.test.ts | 5 +++-- 7 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/core/extract.ts b/src/core/extract.ts index b0aaa9b..9c16f2e 100644 --- a/src/core/extract.ts +++ b/src/core/extract.ts @@ -55,32 +55,38 @@ export function substitutedFile(meta: Ingredient, file: string): boolean { * Whether any target emits `file` (0.8.2): the body file of a rule, agent, command or steering, `SKILL.md` of a * file-layout skill, every file of a dir-layout skill, the listed `files` of a script or hook; nothing for MCP. * A file no target emits is never read as the ingredient's body — not for sections, not for {{param}} scans. + * + * The comparison uses `path.join(dir, x)` — the same call the emitters use to read the file — so that + * `file: ../r/rule.md` resolves back to the file `rule.md` actually sits in. Do not use `path.resolve`: + * `path.resolve(dir, "/rule.md")` is `/rule.md`, while `path.join(dir, "/rule.md")` is `

/rule.md`, + * and 0.8.1 synced `/rule.md`. */ -export function emittedFile(meta: Ingredient, file: string): boolean { - const norm = (x: string) => path.posix.normalize(x.replace(/\\/g, "/")).replace(/^\/+/, ""); +export function emittedFile(meta: Ingredient, file: string, dir?: string): boolean { + // When dir is provided, compare the resolved paths the way the emitters read them. + const eq = (a: string, b: string) => (dir ? path.join(dir, a) === path.join(dir, b) : a === b); switch (meta.type) { case "rule": - return norm(file) === norm(meta.file ?? "rule.md"); + return eq(file, meta.file ?? "rule.md"); case "agent": - return norm(file) === norm(meta.file ?? "agent.md"); + return eq(file, meta.file ?? "agent.md"); case "command": - return norm(file) === norm(meta.file ?? "command.md"); + return eq(file, meta.file ?? "command.md"); case "steering": - return norm(file) === norm(meta.file ?? "steering.md"); + return eq(file, meta.file ?? "steering.md"); case "skill": - if (meta.layout === "file") return norm(file) === "SKILL.md"; + if (meta.layout === "file") return eq(file, "SKILL.md"); return file !== "ingredient.yaml"; // dir layout: every file except ingredient.yaml case "script": case "hook": - return meta.files.some((f) => norm(f) === norm(file)); + return meta.files.some((f) => eq(f, file)); case "mcp": return false; } } /** A file read as the ingredient's body: some target emits it and every target that emits it renders it as text. */ -export function bodyFile(meta: Ingredient, file: string): boolean { - return emittedFile(meta, file) && substitutedFile(meta, file); +export function bodyFile(meta: Ingredient, file: string, dir?: string): boolean { + return emittedFile(meta, file, dir) && substitutedFile(meta, file); } /** `substitute` restricted to `keys`: every other placeholder is left as it is. */ diff --git a/src/core/param-writes.ts b/src/core/param-writes.ts index e08198c..3b7e023 100644 --- a/src/core/param-writes.ts +++ b/src/core/param-writes.ts @@ -70,7 +70,7 @@ export function resolvedBy(forge: Forge, profile: string): Set { /** Whether any body file an ingredient reads through `ctx.text` cites `{{key}}`. */ async function cites(ing: LoadedIngredient, key: string): Promise { for (const rel of await listFiles(ing.dir)) { - if (rel === "ingredient.yaml" || !bodyFile(ing.meta, rel)) continue; + if (rel === "ingredient.yaml" || !bodyFile(ing.meta, rel, ing.dir)) continue; if (placeholders(await readIngredientText(ing, rel)).includes(key)) return true; } return false; diff --git a/src/core/sync.ts b/src/core/sync.ts index 491c5f6..9bb1d6e 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -110,12 +110,12 @@ async function sectionPass(forge: Forge, resolution: Resolution, warnings: strin for (const rel of await listFiles(ing.dir)) { if (rel === "ingredient.yaml") continue; const abs = path.join(ing.dir, rel); - if (bodyFile(ing.meta, rel)) { + if (bodyFile(ing.meta, rel, ing.dir)) { const p = parseSections(await fs.readFile(abs, "utf8"), forgeRel(forge, abs), ing.ref); parsed.set(abs, p); files.push({ rel, p }); if (p.sections.length && firstMarker === null) firstMarker = `${p.file}:${p.sections[0].line}`; - } else if (emittedFile(ing.meta, rel)) { + } else if (emittedFile(ing.meta, rel, ing.dir)) { // A file emitted but not every target renders as text keeps its markers; one, whatever its extension, is warned, never dropped // silently. Every marker form contains "craftar:section", so a file without it is not decoded. const bytes = await fs.readFile(abs); @@ -208,7 +208,7 @@ export async function plan(ws: Workspace): Promise { const missing = new Set(); const params = paramsFor(ing, resolution); // Sections first, then params (spec 11 §6.4), in body files only (§6.5, 0.8.2). - const parsed = bodyFile(ing.meta, file) ? sections.parsed.get(abs) : null; + const parsed = bodyFile(ing.meta, file, ing.dir) ? sections.parsed.get(abs) : null; // Every body file of a resolved ingredient was parsed and gated in the section pass; a miss here would skip the schema gate. if (parsed === undefined) throw new Error(`internal: ${forgeRel(ws.forge, abs)} was not parsed by the section pass`); const expanded = parsed ? expandSections(parsed, sectionsFor(ing, resolution)) : toLf(stripBom(raw)); diff --git a/src/core/template-import.ts b/src/core/template-import.ts index 59b8c6a..3cbe51e 100644 --- a/src/core/template-import.ts +++ b/src/core/template-import.ts @@ -50,7 +50,7 @@ export async function readBase(dir: string, io: DirReader, forgeRoot?: string): try { for (const rel of await io.list(dir)) { if (rel === "ingredient.yaml") continue; - if (bodyFile(meta, rel)) { + if (bodyFile(meta, rel, dir)) { const text = norm(await io.readText(path.join(dir, rel))); texts.set(rel, text); parsed.set(rel, parseSections(text, label(rel), `${meta.type}/${meta.name}`)); diff --git a/src/core/unify.ts b/src/core/unify.ts index 13e97b6..b828c1f 100644 --- a/src/core/unify.ts +++ b/src/core/unify.ts @@ -84,7 +84,7 @@ export async function planFrom( const label = (rel: string) => ["ingredients", path.basename(path.dirname(base.dir)), path.basename(base.dir), rel].join("/"); for (const rel of baseFiles) { if (rel === "ingredient.yaml") continue; - if (!bodyFile(base.meta, rel)) continue; + if (!bodyFile(base.meta, rel, base.dir)) continue; try { const text = await readIngredientText(base, rel); const parsed = parseSections(text, label(rel), base.ref); @@ -99,7 +99,7 @@ export async function planFrom( for (const f of diff.files) { // Pre-fill section names only when the file is a body file (spec 12 §4.2, 0.8.2) let sectionNames: Array = []; - if (bodyFile(base.meta, f.file)) { + if (bodyFile(base.meta, f.file, base.dir)) { try { const baseText = await readIngredientText(base, f.file); sectionNames = prefillSections({ @@ -372,7 +372,7 @@ export async function applyPlan( const variantFiles = await listFiles(variant.dir); for (const rel of variantFiles) { if (rel === "ingredient.yaml") continue; - if (!bodyFile(variant.meta, rel)) continue; + if (!bodyFile(variant.meta, rel, variant.dir)) continue; const variantText = await readIngredientText(variant, rel); const markerLine = firstMarkerLine(toLf(stripBom(variantText))); if (markerLine !== null) { @@ -427,7 +427,7 @@ export async function applyPlan( filesWithSectionHunks.add(pf.file); // S2: the file must be emitted and substituted (0.8.2) - if (!emittedFile(base.meta, pf.file)) { + if (!emittedFile(base.meta, pf.file, base.dir)) { throw new Error( `unify plan: "${pf.file}" is not emitted by any target — a section there would never render`, ); @@ -442,7 +442,7 @@ export async function applyPlan( const declaredElsewhere = new Map(); for (const otherFile of baseFiles) { if (otherFile === "ingredient.yaml" || otherFile === pf.file) continue; - if (!bodyFile(base.meta, otherFile)) continue; + if (!bodyFile(base.meta, otherFile, base.dir)) continue; const otherText = await readIngredientText(base, otherFile); const parsed = parseSections(otherText, label(otherFile), base.ref); for (const s of parsed.sections) { @@ -543,7 +543,7 @@ export async function applyPlan( // which would re-terminate a base with mixed line endings even though no decision moved it. if (paramOf.size) { // P3: the file must be emitted and substituted (0.8.2) - if (!emittedFile(base.meta, pf.file)) { + if (!emittedFile(base.meta, pf.file, base.dir)) { throw new Error(`unify plan: "${pf.file}" is not emitted by any target — a {{param}} there would never render`); } if (!substitutedFile(base.meta, pf.file)) { @@ -689,7 +689,7 @@ async function checkMarkers( newNames: Map = new Map(), ): Promise { const label = (rel: string) => ["ingredients", path.basename(path.dirname(base.dir)), path.basename(base.dir), rel].join("/"); - const touched = [...Object.keys(write), ...remove].filter((rel) => bodyFile(base.meta, rel)).sort(); + const touched = [...Object.keys(write), ...remove].filter((rel) => bodyFile(base.meta, rel, base.dir)).sort(); for (const rel of touched) { const abs = path.join(base.dir, rel); const before = (await exists(abs)) ? markerStructure(await fs.readFile(abs, "utf8"), label(rel)) : { names: [] }; diff --git a/src/importers/decide.ts b/src/importers/decide.ts index 88dd516..bf6229c 100644 --- a/src/importers/decide.ts +++ b/src/importers/decide.ts @@ -273,7 +273,7 @@ function inferSectionValues( } async function listAdmitted(ing: { dir: string; meta: Ingredient }): Promise { - return (await listFiles(ing.dir)).filter((rel) => rel !== "ingredient.yaml" && bodyFile(ing.meta, rel)); + return (await listFiles(ing.dir)).filter((rel) => rel !== "ingredient.yaml" && bodyFile(ing.meta, rel, ing.dir)); } const OverridesParams = z.record(z.unknown()); diff --git a/test/plan.test.ts b/test/plan.test.ts index 8a03279..c8e582b 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -176,14 +176,14 @@ describe("0.8.2 — files no target emits are not read for sections", () => { expect(out(p, ".claude/rules/r.md")).toBe("# R v\n"); }); - it("every file spelling 0.8.1 synced still syncs with the same bytes (0.8.2)", async () => { + it("every rule and script file spelling measured on 0.8.1 still syncs with the same bytes (0.8.2)", async () => { const ruleOut = async (file: string) => { const p = await sectionPlan({ ingredients: [{ meta: { type: "rule", name: "r", file }, files: { "rule.md": "# R v\n" } }], schema: 1 }); return p.files.map((f) => [f.path, f.content.toString("utf8")]); }; const reference = await ruleOut("rule.md"); expect(reference.find(([p]) => p === ".claude/rules/r.md")?.[1]).toBe("# R v\n"); - for (const file of ["./rule.md", "/rule.md", ".//rule.md", "//rule.md", "a/../rule.md"]) { + for (const file of ["./rule.md", "/rule.md", ".//rule.md", "//rule.md", "a/../rule.md", "../r/rule.md", "a/../../r/rule.md", "./../r/rule.md"]) { expect(await ruleOut(file), file).toEqual(reference); } const scriptOut = async (file: string) => { @@ -194,6 +194,7 @@ describe("0.8.2 — files no target emits are not read for sections", () => { expect(await scriptOut("./run.sh")).toEqual([[".claude/scripts/./run.sh", "echo hi\n"]]); expect(await scriptOut("/run.sh")).toEqual([[".claude/scripts//run.sh", "echo hi\n"]]); expect(await scriptOut("a/../run.sh")).toEqual([[".claude/scripts/a/../run.sh", "echo hi\n"]]); + expect(await scriptOut("../s/run.sh")).toEqual([[".claude/scripts/../s/run.sh", "echo hi\n"]]); }); it("a file spelling 0.8.1 could not read still fails, and never as an internal error (0.8.2)", async () => { From fc0bb5574c25041db2a89ebd52a9d8d4ec368249 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 12:51:51 -0300 Subject: [PATCH 15/26] test(core): pin which spellings name an emitted file, per platform Pure unit tests for emittedFile verify that path.join is used correctly on both POSIX and Windows: backslash is a separator only on Windows. --- test/extract.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 test/extract.test.ts diff --git a/test/extract.test.ts b/test/extract.test.ts new file mode 100644 index 0000000..d0fa0a3 --- /dev/null +++ b/test/extract.test.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { IngredientSchema } from "../src/schema/index.js"; +import { emittedFile } from "../src/core/extract.js"; + +describe("emittedFile resolves a declared file the way the emitters read it (0.8.2)", () => { + const dir = path.join(path.sep, "forge", "ingredients", "rules", "r"); + const rule = (file: string) => IngredientSchema.parse({ type: "rule", name: "r", file }); + it("accepts every spelling path.join resolves to the listed file, on every platform", () => { + for (const f of ["rule.md", "./rule.md", "/rule.md", ".//rule.md", "a/../rule.md", "../r/rule.md", "a/../../r/rule.md"]) { + expect(emittedFile(rule(f), "rule.md", dir), f).toBe(true); + } + expect(emittedFile(rule("rule.md"), "notes.md", dir)).toBe(false); + expect(emittedFile(rule("../other/rule.md"), "rule.md", dir)).toBe(false); + }); + it("treats a backslash as a separator only where path.join does", () => { + const win = process.platform === "win32"; + expect(emittedFile(rule(".\\rule.md"), "rule.md", dir)).toBe(win); + expect(emittedFile(rule("\\rule.md"), "rule.md", dir)).toBe(win); + expect(emittedFile(rule("sub\\rule.md"), "sub/rule.md", dir)).toBe(win); + }); + it("matches script files the same way", () => { + const s = IngredientSchema.parse({ type: "script", name: "s", files: ["./run.sh", "../s/x.sh"] }); + const sdir = path.join(path.sep, "forge", "ingredients", "scripts", "s"); + expect(emittedFile(s, "run.sh", sdir)).toBe(true); + expect(emittedFile(s, "x.sh", sdir)).toBe(true); + expect(emittedFile(s, "y.sh", sdir)).toBe(false); + }); +}); From 03743670f067555fad83033c4dd056c49b6283b6 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 12:55:20 -0300 Subject: [PATCH 16/26] fix(core): name a body file the section pass could not reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file: declaration pointing outside the ingredient directory (../outside.md) or behind a symlinked directory now fails with a message naming the file and suggesting to keep it inside the ingredient dir — not as internal:. --- src/core/sync.ts | 8 ++++++-- test/plan.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/core/sync.ts b/src/core/sync.ts index 9bb1d6e..e6f54d3 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -209,8 +209,12 @@ export async function plan(ws: Workspace): Promise { const params = paramsFor(ing, resolution); // Sections first, then params (spec 11 §6.4), in body files only (§6.5, 0.8.2). const parsed = bodyFile(ing.meta, file, ing.dir) ? sections.parsed.get(abs) : null; - // Every body file of a resolved ingredient was parsed and gated in the section pass; a miss here would skip the schema gate. - if (parsed === undefined) throw new Error(`internal: ${forgeRel(ws.forge, abs)} was not parsed by the section pass`); + // Every body file of a resolved ingredient was parsed and gated in the section pass; a miss here means the file is + // outside the ingredient directory or behind a symlinked directory that listFiles did not descend into. + if (parsed === undefined) + throw new Error( + `${forgeRel(ws.forge, abs)}: ${ing.ref} declares a file outside its directory or behind a symlinked directory — keep the file inside ${forgeRel(ws.forge, ing.dir)}`, + ); const expanded = parsed ? expandSections(parsed, sectionsFor(ing, resolution)) : toLf(stripBom(raw)); const out = substitute(expanded, params, missing); if (parsed) guardOutput(ing, file, out, parsed, resolution, params); diff --git a/test/plan.test.ts b/test/plan.test.ts index c8e582b..b152d2f 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -206,6 +206,41 @@ describe("0.8.2 — files no target emits are not read for sections", () => { expect(e!.message, file).not.toContain("internal:"); } }); + + it("a body file outside the ingredient directory is a user error, not an internal one (0.8.2)", async () => { + // Create the scenario first, then add the file outside the ingredient directory + const refs = ["rule/r"]; + const s = await scenario( + { ingredients: [{ meta: { type: "rule", name: "r", file: "../outside.md" }, files: { "rule.md": "# R v\n" } }], recipes: [recipe("base", refs)], profiles: [profile("acme", ["base"], ["claude-code", "kiro", "agents-md"], {})] }, + { config: { profile: "acme" } }, + ); + cleanups.push(s.cleanup); + await writeFiles(s.forgeRoot, { "craftar.forge.yaml": "name: test-forge\nschema: 1\n" }); + // Create the file outside the ingredient directory (at ingredients/rules/outside.md) + await writeFiles(s.forgeRoot, { "ingredients/rules/outside.md": "# Outside\n" }); + const e = await plan(await loadWorkspace(s.wsRoot)).then(() => null, (x: Error) => x); + expect(e).not.toBeNull(); + expect(e!.message).not.toContain("internal:"); + expect(e!.message).toContain("outside its directory"); + }); + + it.skipIf(process.platform === "win32")("a body file behind a symlinked directory is a user error, not an internal one (0.8.2)", async () => { + // Create a symlink to a subdirectory and declare a file through it + const refs = ["rule/r"]; + const s = await scenario( + { ingredients: [{ meta: { type: "rule", name: "r", file: "link/rule.md" }, files: { "rule.md": "# R v\n" } }], recipes: [recipe("base", refs)], profiles: [profile("acme", ["base"], ["claude-code", "kiro", "agents-md"], {})] }, + { config: { profile: "acme" } }, + ); + cleanups.push(s.cleanup); + await writeFiles(s.forgeRoot, { "craftar.forge.yaml": "name: test-forge\nschema: 1\n" }); + // Create sub/rule.md and symlink link -> sub in the ingredient directory + const ingDir = path.join(s.forgeRoot, "ingredients/rules/r"); + await writeFiles(ingDir, { "sub/rule.md": "# R v\n" }); + await fs.symlink(path.join(ingDir, "sub"), path.join(ingDir, "link")); + const e = await plan(await loadWorkspace(s.wsRoot)).then(() => null, (x: Error) => x); + expect(e).not.toBeNull(); + expect(e!.message).not.toContain("internal:"); + }); }); describe("sections — parse errors and the output guard", () => { From 53ac9f389e9149f4c0afbeafce99b3b78932d034 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 12:57:20 -0300 Subject: [PATCH 17/26] docs(readme): say which files count as a {{param}} citation, and fix the status row Clarify that only body files count as a citation for forge unify and import. Change 'a file' to 'an emitted file' in the status row. Add guidance for removing params left over from a 0.8.1 take: param on a non-emitted file. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8459893..8c6a329 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | Command | What it does | |---|---| | `craftar import --from claude-code --forge --profile [--workspace .] [--write-config]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml`. A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created|edited|unchanged` lines. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. | -| `craftar status` | Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in a file not every target renders as text (copied as they are). | +| `craftar status` | Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in an emitted file not every target renders as text (copied as they are). | | `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock`. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default). | | `craftar diff [path]` | Line diff between disk and what the Forge would generate. | | `craftar explain ` | Which ingredient, recipe chain, target and origin produced a file and, for an ingredient with sections, which layer filled each one: `sections flavors (profile globex), extra (default)` (`default`, `profile

` or `workspace`). `AGENTS.md` gets no sections line: it is not one ingredient. | @@ -178,7 +178,7 @@ overrides: ingredients: { disable: [] } ``` -Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; only body files are read for them, so a placeholder in a file no target emits is never rendered and never counted as a citation; a placeholder with no value in any layer is left untouched and reported as a warning by `status` and `sync` (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. +Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder in a file no target emits is never rendered, and only body files count as a citation for `forge unify` and `import`; a placeholder with no value in any layer is left untouched and reported as a warning by `status` and `sync` (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. Sections layer the same way, weakest → strongest: the body's default → the profile's `sections` → `craftar.yaml`'s `overrides.sections` → `craftar.local.yaml`'s, merged per section, so a workspace that sets one section of an ingredient keeps the profile's others. There is no recipe layer for sections. @@ -244,7 +244,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ### to 0.8.2 - **A file no target emits (e.g. notes beside `rule.md`) is no longer read for sections or `{{param}}` citations** — a malformed marker there no longer fails `sync`/`status`/`diff`/`explain`/`ls`/`import` or trips the `schema: 1` gate, and a `{{key}}` there no longer makes `unify`/`import` refuse. No emitted byte changes. -- **`take: param` and `take: section` on such a file are now refused** (`… is not emitted by any target`). 0.8.1 accepted both there, so a plan saved under 0.8.1 that extracts from such a file no longer applies. A Forge that already ran such a `take: section` under 0.8.1 has markers in that file and a value in the profile's `sections`; 0.8.2 ignores those markers and warns at every `sync` that the profile sets a section with no such marker — delete the value from the profile, or move the section into the body file. +- **`take: param` and `take: section` on such a file are now refused** (`… is not emitted by any target`). 0.8.1 accepted both there, so a plan saved under 0.8.1 that extracts from such a file no longer applies. A Forge that already ran such a `take: section` under 0.8.1 has markers in that file and a value in the profile's `sections`; 0.8.2 ignores those markers and warns on every run (`sync`, `status`, `diff`, `explain`, `ls`) that the profile sets a section with no such marker — delete the value from the profile, or move the section into the body file. A `take: param` run under 0.8.1 on such a file left a default in `ingredient.yaml` and a value in the profile's `params`; both are inert now — remove them by hand. ### to 0.8.1 From 8e2f9a55fc0d3f75afb00025801266dec3c95481 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 13:07:40 -0300 Subject: [PATCH 18/26] refactor(core): make the ingredient directory a required argument of emittedFile The dir parameter is now string | null: null only for metadata the importer builds itself, whose names are canonical. --- src/core/extract.ts | 22 ++++++++++++++-------- src/importers/claude-code.ts | 3 ++- src/importers/decide.ts | 3 ++- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/core/extract.ts b/src/core/extract.ts index 9c16f2e..f2b236f 100644 --- a/src/core/extract.ts +++ b/src/core/extract.ts @@ -60,19 +60,21 @@ export function substitutedFile(meta: Ingredient, file: string): boolean { * `file: ../r/rule.md` resolves back to the file `rule.md` actually sits in. Do not use `path.resolve`: * `path.resolve(dir, "/rule.md")` is `/rule.md`, while `path.join(dir, "/rule.md")` is `

/rule.md`, * and 0.8.1 synced `/rule.md`. + * + * `null` only for metadata the importer builds itself, whose names are canonical. */ -export function emittedFile(meta: Ingredient, file: string, dir?: string): boolean { +export function emittedFile(meta: Ingredient, file: string, dir: string | null): boolean { // When dir is provided, compare the resolved paths the way the emitters read them. - const eq = (a: string, b: string) => (dir ? path.join(dir, a) === path.join(dir, b) : a === b); + const eq = (a: string, b: string) => (dir !== null ? path.join(dir, a) === path.join(dir, b) : a === b); switch (meta.type) { case "rule": - return eq(file, meta.file ?? "rule.md"); + return eq(file, meta.file); case "agent": - return eq(file, meta.file ?? "agent.md"); + return eq(file, meta.file); case "command": - return eq(file, meta.file ?? "command.md"); + return eq(file, meta.file); case "steering": - return eq(file, meta.file ?? "steering.md"); + return eq(file, meta.file); case "skill": if (meta.layout === "file") return eq(file, "SKILL.md"); return file !== "ingredient.yaml"; // dir layout: every file except ingredient.yaml @@ -84,8 +86,12 @@ export function emittedFile(meta: Ingredient, file: string, dir?: string): boole } } -/** A file read as the ingredient's body: some target emits it and every target that emits it renders it as text. */ -export function bodyFile(meta: Ingredient, file: string, dir?: string): boolean { +/** + * A file read as the ingredient's body: some target emits it and every target that emits it renders it as text. + * + * `null` only for metadata the importer builds itself, whose names are canonical. + */ +export function bodyFile(meta: Ingredient, file: string, dir: string | null): boolean { return emittedFile(meta, file, dir) && substitutedFile(meta, file); } diff --git a/src/importers/claude-code.ts b/src/importers/claude-code.ts index 4908ad4..4c5e79d 100644 --- a/src/importers/claude-code.ts +++ b/src/importers/claude-code.ts @@ -863,7 +863,8 @@ function markerIn(meta: Ingredient, files: Record, sour const raw = "frontmatterRaw" in meta ? meta.frontmatterRaw : undefined; const offset = raw ? raw.split("\n").length + 2 : 0; for (const [rel, content] of Object.entries(files)) { - if (!bodyFile(meta, rel)) continue; + // the importer builds meta and file keys itself, with canonical names + if (!bodyFile(meta, rel, null)) continue; const line = firstMarkerLine(toLf(stripBom(typeof content === "string" ? content : content.toString("utf8")))); if (line !== null) return { where: meta.type === "skill" && meta.layout === "dir" ? `${source}${rel}` : source, line: line + offset }; } diff --git a/src/importers/decide.ts b/src/importers/decide.ts index bf6229c..5c55be0 100644 --- a/src/importers/decide.ts +++ b/src/importers/decide.ts @@ -68,7 +68,8 @@ const valueOf = (m: Record, k: string) => (Object.hasOwn(m, k) /** The keys a source's body files cite literally. */ export function sourceKeys(meta: Ingredient, files: Record): Set { const out = new Set(); - for (const [rel, c] of Object.entries(files)) if (bodyFile(meta, rel)) for (const k of placeholders(textOf(c))) out.add(k); + // the importer builds meta and file keys itself, with canonical names + for (const [rel, c] of Object.entries(files)) if (bodyFile(meta, rel, null)) for (const k of placeholders(textOf(c))) out.add(k); return out; } From baf1faa098661065206628f3ca456d2add0a99e7 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 13:10:27 -0300 Subject: [PATCH 19/26] test: a body file declared ./rule.md is read by unify and the citation scans Both tests fail when their respective functions use null instead of the ingredient directory for path comparison. --- test/param-writes.test.ts | 6 ++++++ test/unify.test.ts | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/test/param-writes.test.ts b/test/param-writes.test.ts index 50bac8c..69bb45a 100644 --- a/test/param-writes.test.ts +++ b/test/param-writes.test.ts @@ -121,6 +121,12 @@ describe("checkParamWrites — Forge-level refusals (spec 09 §6.3)", () => { expect(await err(check(forge, [ext("k", "globex-api", "acme-api")]))).toContain("rule/other also uses {{k}}"); }); + it("the citation scan reads a body file declared ./rule.md (0.8.2)", async () => { + // The other ingredient declares file: ./rule.md — the non-canonical spelling + const forge = await forgeOf(spec({ ingredients: [] }), { "ingredients/rules/other/ingredient.yaml": "type: rule\nname: other\nfile: ./rule.md\n", "ingredients/rules/other/rule.md": "see {{k}}\n" }); + expect(await err(check(forge, [ext("k", "globex-api", "acme-api")]))).toContain("rule/other also uses {{k}}"); + }); + it("P19: a hand-formatted file, or params behind an alias", async () => { const aligned = await forgeOf(spec(), { "profiles/acme/profile.yaml": "name: acme # the client\nrecipes:\n - base--acme\n" }); expect(await err(check(aligned, [ext("k", "globex-api", "acme-api")]))).toContain("does not round-trip"); diff --git a/test/unify.test.ts b/test/unify.test.ts index d07f667..7d328c1 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -736,6 +736,26 @@ describe("take: param — the engine (spec 09 §6.1, §6.2)", () => { (variant as any).meta = { ...(variant as any).meta, params: { k: { default: "y" } } }; expect(metaDifferences(base, variant)).toEqual(["params"]); }); + + it("take: param works on a body file declared ./rule.md (0.8.2)", async () => { + // Both base and variant declare file: "./rule.md" — the non-canonical spelling + const baseDir = await tmpDir(); + const variantDir = await tmpDir(); + cleanups.push(() => fs.rm(baseDir, { recursive: true, force: true }), () => fs.rm(variantDir, { recursive: true, force: true })); + await writeFiles(baseDir, { "ingredient.yaml": "type: rule\nname: workflow\nfile: ./rule.md\n", "rule.md": "use globex-api\n" }); + await writeFiles(variantDir, { "ingredient.yaml": "type: rule\nname: workflow--acme\nas: workflow\nfile: ./rule.md\n", "rule.md": "use acme-api\n" }); + const base = { ref: "rule/workflow", dir: baseDir, meta: { type: "rule", name: "workflow", file: "./rule.md" } } as never; + const variant = { ref: "rule/workflow--acme", dir: variantDir, meta: { type: "rule", name: "workflow--acme", as: "workflow", file: "./rule.md" } } as never; + const diff = await diffIngredients(base, variant); + const plan = await planFrom(base, variant, diff, "acme"); + Object.assign(plan.files[0].hunks![0], { take: "param", params: [{ token: "globex-api", key: "deploy.api" }] }); + const r = await applyPlan(base, variant, diff, plan); + expect(r.write["rule.md"]).toBe("use {{deploy.api}}\n"); + expect(r.params).toEqual([ + { key: "deploy.api", default: "globex-api", value: "acme-api", reused: false, sites: [{ file: "rule.md", hunk: 1 }] }, + ]); + expect(r.resolved).toBe(true); + }); }); describe("take: param — the remaining engine rows (spec 09 AC 9)", () => { From cdf49c847a7ffb329740d68bee7c1abcd989e724 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 13:14:00 -0300 Subject: [PATCH 20/26] docs: say which commands print the upgrade warning, and that a body file stays in its directory README.md: specify that the upgrade warning is on sync and status only; add a clause about body files staying inside the ingredient directory. sync.ts: expand the error message to mention spelling differences. extract.ts: restore fallbacks for meta.file to support test mocks that bypass the schema. --- README.md | 4 ++-- src/core/extract.ts | 8 ++++---- src/core/sync.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8c6a329..46c638c 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ server: timeout: 30 ``` -**Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every `.md`, `.txt`, `.json`, `.yaml` or `.yml` file of a dir-layout skill; the text files listed in `files` of a script or hook) can hold blocks a profile or a workspace replaces. A file no target emits (a `notes.md` beside `rule.md`) is ignored — for sections and for `{{param}}` citations alike; a file a target emits but does not render as text is copied (see below). A block sits between two marker lines, and its content is the default: +**Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every `.md`, `.txt`, `.json`, `.yaml` or `.yml` file of a dir-layout skill; the text files listed in `files` of a script or hook — a body file stays inside the ingredient directory; one declared outside it, or behind a symlinked directory, fails every command that plans, naming it) can hold blocks a profile or a workspace replaces. A file no target emits (a `notes.md` beside `rule.md`) is ignored — for sections and for `{{param}}` citations alike; a file a target emits but does not render as text is copied (see below). A block sits between two marker lines, and its content is the default: ```markdown Dispatch reviewers after every commit. @@ -244,7 +244,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ### to 0.8.2 - **A file no target emits (e.g. notes beside `rule.md`) is no longer read for sections or `{{param}}` citations** — a malformed marker there no longer fails `sync`/`status`/`diff`/`explain`/`ls`/`import` or trips the `schema: 1` gate, and a `{{key}}` there no longer makes `unify`/`import` refuse. No emitted byte changes. -- **`take: param` and `take: section` on such a file are now refused** (`… is not emitted by any target`). 0.8.1 accepted both there, so a plan saved under 0.8.1 that extracts from such a file no longer applies. A Forge that already ran such a `take: section` under 0.8.1 has markers in that file and a value in the profile's `sections`; 0.8.2 ignores those markers and warns on every run (`sync`, `status`, `diff`, `explain`, `ls`) that the profile sets a section with no such marker — delete the value from the profile, or move the section into the body file. A `take: param` run under 0.8.1 on such a file left a default in `ingredient.yaml` and a value in the profile's `params`; both are inert now — remove them by hand. +- **`take: param` and `take: section` on such a file are now refused** (`… is not emitted by any target`). 0.8.1 accepted both there, so a plan saved under 0.8.1 that extracts from such a file no longer applies. A Forge that already ran such a `take: section` under 0.8.1 has markers in that file and a value in the profile's `sections`; 0.8.2 ignores those markers and warns on every `sync` and `status` that the profile sets a section with no such marker — delete the value from the profile, or move the section into the body file. A `take: param` run under 0.8.1 on such a file left a default in `ingredient.yaml` and a value in the profile's `params`; both are inert now — remove them by hand. ### to 0.8.1 diff --git a/src/core/extract.ts b/src/core/extract.ts index f2b236f..1a4736b 100644 --- a/src/core/extract.ts +++ b/src/core/extract.ts @@ -68,13 +68,13 @@ export function emittedFile(meta: Ingredient, file: string, dir: string | null): const eq = (a: string, b: string) => (dir !== null ? path.join(dir, a) === path.join(dir, b) : a === b); switch (meta.type) { case "rule": - return eq(file, meta.file); + return eq(file, meta.file ?? "rule.md"); case "agent": - return eq(file, meta.file); + return eq(file, meta.file ?? "agent.md"); case "command": - return eq(file, meta.file); + return eq(file, meta.file ?? "command.md"); case "steering": - return eq(file, meta.file); + return eq(file, meta.file ?? "steering.md"); case "skill": if (meta.layout === "file") return eq(file, "SKILL.md"); return file !== "ingredient.yaml"; // dir layout: every file except ingredient.yaml diff --git a/src/core/sync.ts b/src/core/sync.ts index e6f54d3..af6dcdf 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -213,7 +213,7 @@ export async function plan(ws: Workspace): Promise { // outside the ingredient directory or behind a symlinked directory that listFiles did not descend into. if (parsed === undefined) throw new Error( - `${forgeRel(ws.forge, abs)}: ${ing.ref} declares a file outside its directory or behind a symlinked directory — keep the file inside ${forgeRel(ws.forge, ing.dir)}`, + `${forgeRel(ws.forge, abs)}: ${ing.ref} declares a file outside its directory, behind a symlinked directory, or spelled differently from the file on disk — keep the file inside ${forgeRel(ws.forge, ing.dir)}`, ); const expanded = parsed ? expandSections(parsed, sectionsFor(ing, resolution)) : toLf(stripBom(raw)); const out = substitute(expanded, params, missing); From 02b6c985bb546212104e58bb282652ce11b83596 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 13:50:26 -0300 Subject: [PATCH 21/26] test: a body file declared ./rule.md reaches every unify and import check Tests that the dir argument to bodyFile/emittedFile is correctly passed. With file: ./rule.md in the meta and rule.md on disk, path.join(dir, x) makes them equal; without dir, the string comparison fails. Catches mutations at unify.ts :102, :375, :430. --- test/unify.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test/unify.test.ts b/test/unify.test.ts index 7d328c1..042594d 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -1487,4 +1487,68 @@ d expect(notes!.hunks!.every((h) => h.section === undefined)).toBe(true); expect(rule!.hunks![0].section).toEqual({ name: "rows" }); }); + + it("a body file declared ./rule.md reaches every unify and import check (./rule.md, 0.8.2)", async () => { + // This test declares file: "./rule.md" in the metadata to ensure that bodyFile/emittedFile calls + // pass the `dir` argument correctly. With `dir`, path.join(dir, "./rule.md") === path.join(dir, "rule.md"), + // but without it, "./rule.md" !== "rule.md" fails the comparison. + const root = await tmpDir("craftar-dot-slash-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const baseBody = "# Review\n\n| Repo |\n|---|\n| a |\n\nDone.\n"; + const variantBody = "# Review\n\n| Repo |\n|---|\n| a |\n| b |\n\nDone.\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "review-posture", file: "./rule.md" }, files: { "rule.md": baseBody } }, + { meta: { type: "rule", name: "review-posture--acme", as: "review-posture", file: "./rule.md" }, files: { "rule.md": variantBody } }, + ], + recipes: [recipe("base", ["rule/review-posture"]), recipe("base--acme", ["rule/review-posture--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/review-posture")!; + const variant = forge.ingredients.get("rule/review-posture--acme")!; + const diff = await diffIngredients(base, variant); + + // planFrom: section name pre-fill should work (checks :87, :102) + const plan = await planFrom(base, variant, diff, "acme"); + expect(plan.files[0].hunks![0].section).toEqual({ name: "review" }); + + // applyPlan with take: section should work (checks :430, :445, :692) + plan.files[0].hunks![0].take = "section"; + (plan.files[0].hunks![0] as Record).section = { name: "flavors", lines: "3-5" }; + const result = await applyPlan(base, variant, diff, plan); + expect(result.resolved).toBe(true); + expect(result.sections).toHaveLength(1); + expect(result.sections[0].name).toBe("flavors"); + const merged = result.write["rule.md"] as string; + expect(merged).toContain(""); + }); + + it("a variant with ./rule.md holding a marker is refused (./rule.md, 0.8.2)", async () => { + // This test checks that bodyFile with `dir` at :375 correctly identifies a body file + // spelled as "./rule.md" when scanning the variant for markers. + const root = await tmpDir("craftar-s12-dot-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const baseBody = "# R\n\na\n"; + const variantBody = "# R\n\n\nb\n\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "w", file: "./rule.md" }, files: { "rule.md": baseBody } }, + { meta: { type: "rule", name: "w--acme", as: "w", file: "./rule.md" }, files: { "rule.md": variantBody } }, + ], + recipes: [recipe("base", ["rule/w"]), recipe("base--acme", ["rule/w--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/w")!; + const variant = forge.ingredients.get("rule/w--acme")!; + const diff = await diffIngredients(base, variant); + const plan = await planFrom(base, variant, diff, "acme"); + plan.files[0].hunks![0].take = "section"; + (plan.files[0].hunks![0] as Record).section = { name: "s" }; + + await expect(applyPlan(base, variant, diff, plan)).rejects.toThrow( + /rule\/w--acme holds a section marker on rule.md:3/, + ); + }); }); From 26c583fac033c841b432e674552674226b7be176 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 13:52:27 -0300 Subject: [PATCH 22/26] docs: name the commands a misplaced body file fails, and why the file defaults stay README: List the exact commands (sync, status, diff, explain, ls) instead of 'every command that plans'. Add the letter-case caveat for the file spelling check. sync.ts: The error message now tells the user to spell `file` as it is on disk. extract.ts: Clarify that 'dir is not null' rather than 'dir is provided', and note why the fallbacks ('rule.md', 'agent.md', etc.) are kept. --- README.md | 2 +- src/core/extract.ts | 3 ++- src/core/sync.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 46c638c..5281511 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ server: timeout: 30 ``` -**Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every `.md`, `.txt`, `.json`, `.yaml` or `.yml` file of a dir-layout skill; the text files listed in `files` of a script or hook — a body file stays inside the ingredient directory; one declared outside it, or behind a symlinked directory, fails every command that plans, naming it) can hold blocks a profile or a workspace replaces. A file no target emits (a `notes.md` beside `rule.md`) is ignored — for sections and for `{{param}}` citations alike; a file a target emits but does not render as text is copied (see below). A block sits between two marker lines, and its content is the default: +**Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every `.md`, `.txt`, `.json`, `.yaml` or `.yml` file of a dir-layout skill; the text files listed in `files` of a script or hook — a body file stays inside the ingredient directory; one declared outside it, or behind a symlinked directory, or spelled differently from the file on disk (letter case, on a case-insensitive file system), fails `sync`, `status`, `diff`, `explain` and `ls`, naming it) can hold blocks a profile or a workspace replaces. A file no target emits (a `notes.md` beside `rule.md`) is ignored — for sections and for `{{param}}` citations alike; a file a target emits but does not render as text is copied (see below). A block sits between two marker lines, and its content is the default: ```markdown Dispatch reviewers after every commit. diff --git a/src/core/extract.ts b/src/core/extract.ts index 1a4736b..7c456a6 100644 --- a/src/core/extract.ts +++ b/src/core/extract.ts @@ -64,8 +64,9 @@ export function substitutedFile(meta: Ingredient, file: string): boolean { * `null` only for metadata the importer builds itself, whose names are canonical. */ export function emittedFile(meta: Ingredient, file: string, dir: string | null): boolean { - // When dir is provided, compare the resolved paths the way the emitters read them. + // When dir is not null, compare the resolved paths the way the emitters read them. const eq = (a: string, b: string) => (dir !== null ? path.join(dir, a) === path.join(dir, b) : a === b); + // The fallbacks equal the schema defaults; they serve test metadata built without the schema (test/unify.test.ts). switch (meta.type) { case "rule": return eq(file, meta.file ?? "rule.md"); diff --git a/src/core/sync.ts b/src/core/sync.ts index af6dcdf..0b4d996 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -213,7 +213,7 @@ export async function plan(ws: Workspace): Promise { // outside the ingredient directory or behind a symlinked directory that listFiles did not descend into. if (parsed === undefined) throw new Error( - `${forgeRel(ws.forge, abs)}: ${ing.ref} declares a file outside its directory, behind a symlinked directory, or spelled differently from the file on disk — keep the file inside ${forgeRel(ws.forge, ing.dir)}`, + `${forgeRel(ws.forge, abs)}: ${ing.ref} declares a file outside its directory, behind a symlinked directory, or spelled differently from the file on disk — keep the file inside ${forgeRel(ws.forge, ing.dir)} and spell \`file\` as it is on disk`, ); const expanded = parsed ? expandSections(parsed, sectionsFor(ing, resolution)) : toLf(stripBom(raw)); const out = substitute(expanded, params, missing); From 1b14bf105cb4391d2e857e648f781575a0783afd Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 14:07:00 -0300 Subject: [PATCH 23/26] test: pin the remaining ./rule.md sites of unify, import and sync Pin four directory argument sites with mutation-verified tests: - unify.ts:692 (checkMarkers) - U1 marker check - unify.ts:87 (planFrom) - section name pre-fill uniqueness - decide.ts:277 (listAdmitted) - F9 Forge-side scan - sync.ts:118 (emittedFile) - marker warning for non-body files Site unify.ts:445 skipped: no existing test refuses duplicate section names through applyPlan. Site template-import.ts:53 skipped: the path is reachable only for bases with non-canonical file, but such bases are never reused due to metadata fingerprint mismatch. --- test/importer.test.ts | 17 +++++++++++ test/plan.test.ts | 14 +++++++++ test/unify.test.ts | 67 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/test/importer.test.ts b/test/importer.test.ts index d160047..fb52088 100644 --- a/test/importer.test.ts +++ b/test/importer.test.ts @@ -622,6 +622,23 @@ describe("template-aware import — decisions (spec 10 §6.1–§6.5)", () => { expect(r.params).toEqual([]); }); + it("refuses a profile change another Forge ingredient would feel (F9, §6.5 (b)) (./rule.md, 0.8.2)", async () => { + // Like the original F9 test but with file: ./rule.md in the other ingredient. This verifies that + // listAdmitted at decide.ts:277 uses the `dir` argument correctly. With dir, bodyFile compares + // path.join(dir, "rule.md") === path.join(dir, "./rule.md"), which works. Without dir, it compares + // "rule.md" === "./rule.md", which is false, so rule.md drops out of the F9 scan. + const t = await setup(); + await templated(t, { deploy }); + await writeFiles(path.join(t.forge, "ingredients/rules/deploy-notes"), { + "ingredient.yaml": "type: rule\nname: deploy-notes\nfile: ./rule.md\n", + "rule.md": "see {{deploy.api}}\n", + }); + await writeFiles(t.ws("b"), { ".claude/rules/deploy.md": "use initech-api here\n" }); + const r = await importInto(t.forge, t.ws("b"), "b"); + expect(r.variants[0].reason).toContain("setting deploy.api would change rule/deploy-notes"); + expect(r.params).toEqual([]); + }); + it("does not hold a profile change back for a {{key}} in a file no target emits (F9, 0.8.2)", async () => { const t = await setup(); await templated(t, { deploy }); diff --git a/test/plan.test.ts b/test/plan.test.ts index b152d2f..43a3891 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -137,6 +137,20 @@ describe("sections — plan warnings (Ruling 12)", () => { expect(p.warnings).toContain("profile acme sets section x of skill/tool, which has no such marker"); }); + it("markers in a file not every target renders are copied verbatim by both, and warned once (Ruling 17) (./tool.bin, 0.8.2)", async () => { + // Like the Ruling 17 test but using a script with files: ["./tool.bin"]. This verifies that + // emittedFile at sync.ts:118 uses the `dir` argument correctly. With dir, emittedFile compares + // path.join(dir, "./tool.bin") in the files array. Without dir, it compares "./tool.bin" against + // the file list literally, which would not match if the file on disk is "tool.bin". + const body = ["#!/bin/sh", OPEN("x"), "echo hi", CLOSE, ""].join("\n"); + const p = await sectionPlan({ + ingredients: [{ meta: { type: "script", name: "tool", files: ["./tool.bin"] }, files: { "tool.bin": body } }], + targets: ["claude-code"], + }); + expect(p.warnings.filter((w) => w.includes("tool.bin"))).toEqual(["script/tool tool.bin: section markers are read only in files every target renders as text — copied with them"]); + expect(out(p, ".claude/scripts/./tool.bin")).toBe(body); + }); + it("warns on markers in a copied file whatever its extension (an .html in a skill dir)", async () => { const page = [OPEN("x"), "

hi

", CLOSE, ""].join("\n"); const p = await sectionPlan({ diff --git a/test/unify.test.ts b/test/unify.test.ts index 042594d..c924344 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -150,6 +150,42 @@ describe("planFrom", () => { // take should still be "keep" expect(paired.hunks![0].take).toBe("keep"); }); + + it("pre-fills a section name from a heading for a block hunk (spec 12 §4.2) (./rule.md, 0.8.2)", async () => { + // This test checks that planFrom at :87 uses the `dir` argument when building the `declared` set. + // The base already has a section named "reviewer-table" (the slug of "## Reviewer Table"), so the + // pre-fill should produce "reviewer-table-2". With dir, bodyFile compares path.join(dir, "rule.md") + // against path.join(dir, "./rule.md"), which works. Without dir, it compares "rule.md" against + // "./rule.md", which is false, so the file is not read, `declared` is empty, and pre-fill uses + // "reviewer-table" instead of "reviewer-table-2". + const baseDir = await tmpDir(); + cleanups.push(() => fs.rm(baseDir, { recursive: true, force: true })); + await writeFiles(baseDir, { + "ingredient.yaml": "type: rule\nname: review-posture\nfile: ./rule.md\n", + // Base has an existing section named "reviewer-table" and a SEPARATE table below another heading with the same slug + "rule.md": "# Rules\n\n\nOld\n\n\n## Reviewer Table\n| a |\n", + }); + + const variantDir = await tmpDir(); + cleanups.push(() => fs.rm(variantDir, { recursive: true, force: true })); + await writeFiles(variantDir, { + "ingredient.yaml": "type: rule\nname: review-posture--acme\nas: review-posture\nfile: ./rule.md\n", + // Variant keeps the existing section content but adds rows to the table below + "rule.md": "# Rules\n\n\nOld\n\n\n## Reviewer Table\n| a |\n| b |\n", + }); + + const base = { ref: "rule/review-posture", dir: baseDir, meta: { type: "rule", name: "review-posture", file: "./rule.md" } } as never; + const variant = { ref: "rule/review-posture--acme", dir: variantDir, meta: { type: "rule", name: "review-posture--acme", as: "review-posture", file: "./rule.md" } } as never; + const diff = await diffIngredients(base, variant); + + const plan = await planFrom(base, variant, diff, "acme"); + // The only hunk should be the block hunk for "| b |" below the "## Reviewer Table" heading + const paired = plan.files.find((f) => f.file === "rule.md")!; + expect(paired.hunks).toHaveLength(1); + expect(paired.hunks![0].suggestion?.class).toBe("block"); + // Since "reviewer-table" is already declared in the existing section, the pre-fill should be "reviewer-table-2" + expect(paired.hunks![0].section).toEqual({ name: "reviewer-table-2" }); + }); }); /** Two temp ingredient directories (base + variant--profile), loaded and diffed for real. */ @@ -1309,6 +1345,37 @@ Done. ); }); + it("U1: --take variant over a marked base is still refused (./rule.md, 0.8.2)", async () => { + // This test checks that checkMarkers at :692 uses the `dir` argument correctly. With dir, bodyFile + // compares path.join(dir, "rule.md") === path.join(dir, "./rule.md"), which works. Without dir, it + // compares "rule.md" === "./rule.md", which is false, so rule.md drops out of the U1 check. + const root = await tmpDir("craftar-u1-dotslash-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const baseBody = "# Review\n\n\n| a |\n\n\nDone.\n"; + const variantBody = "# Review\n\n| b |\n\nDone.\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "review-posture", file: "./rule.md" }, files: { "rule.md": baseBody } }, + { meta: { type: "rule", name: "review-posture--acme", as: "review-posture", file: "./rule.md" }, files: { "rule.md": variantBody } }, + ], + recipes: [recipe("base", ["rule/review-posture"]), recipe("base--acme", ["rule/review-posture--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/review-posture")!; + const variant = forge.ingredients.get("rule/review-posture--acme")!; + const diffResult = await diffIngredients(base, variant); + const planObj = await planFrom(base, variant, diffResult, "acme"); + // Take variant on all hunks (no section declaration) + for (const h of planObj.files[0].hunks!) { + h.take = "variant"; + } + + await expect(applyPlan(base, variant, diffResult, planObj)).rejects.toThrow( + /would lose or change section markers.*take base for the marker lines, or take: section to fill the section/, + ); + }); + it("U1: plan with section hunk that also takes variant on an existing marker hunk is refused", async () => { // Base has two sections const baseBody = "# Review\n\n\na\n\n\n\nb\n\n"; From 53ade25b63f881de351a4c3f19d7969a2ffeba99 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 14:27:36 -0300 Subject: [PATCH 24/26] test(unify): refuse a section name another ./-spelled script file declares The test verifies that bodyFile at :445 uses the dir argument when building declaredElsewhere, ensuring ./b.sh is recognized as a body file alongside ./a.sh. --- test/unify.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/unify.test.ts b/test/unify.test.ts index c924344..3af44e8 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -1618,4 +1618,55 @@ d /rule\/w--acme holds a section marker on rule.md:3/, ); }); + + it("refuses a section name another body file declares, with files spelled ./ (unify.ts S6, 0.8.2)", async () => { + // This test checks that bodyFile at :445 uses the `dir` argument when building declaredElsewhere. + // With dir, bodyFile compares path.join(dir, "b.sh") against path.join(dir, "./b.sh"), which works. + // Without dir, it compares "b.sh" === "./b.sh", which is false, so b.sh is not seen as a body file + // and its section marker is not collected — allowing an invalid duplicate name. + const root = await tmpDir("craftar-s6-dotslash-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + + // Script ingredient with files spelled ./ + const script = (name: string, body: Record, extra: Record = {}): IngredientSpec => ({ + meta: { type: "script", name, files: ["./a.sh", "./b.sh"], ...extra }, + files: body, + }); + + // Base b.sh declares section `flavors` at line 2 (opener on line 1) + const baseBsh = "#!/bin/bash\n\necho base\n\n"; + // Variant b.sh has no markers — its hunks take base + const variantBsh = "#!/bin/bash\necho base\n"; + + // a.sh differs between base and variant + const baseAsh = "#!/bin/bash\necho a\n"; + const variantAsh = "#!/bin/bash\necho variant-a\n"; + + await makeForge(root, { + ingredients: [ + script("tool", { "a.sh": baseAsh, "b.sh": baseBsh }), + script("tool--acme", { "a.sh": variantAsh, "b.sh": variantBsh }, { as: "tool" }), + ], + recipes: [recipe("base", ["script/tool"]), recipe("base--acme", ["script/tool--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("script/tool")!; + const variant = forge.ingredients.get("script/tool--acme")!; + const diffResult = await diffIngredients(base, variant); + const planObj = await planFrom(base, variant, diffResult, "acme"); + + // Take base on b.sh hunks + const bshFile = planObj.files.find((f) => f.file === "b.sh"); + for (const h of bshFile?.hunks ?? []) h.take = "base"; + + // Take section with name "flavors" on a.sh (already declared in b.sh) + const ashFile = planObj.files.find((f) => f.file === "a.sh"); + ashFile!.hunks![0].take = "section"; + (ashFile!.hunks![0] as Record).section = { name: "flavors" }; + + await expect(applyPlan(base, variant, diffResult, planObj)).rejects.toThrow( + /already declared in script\/tool \(b\.sh:2\)/, + ); + }); }); From cb946d949cc6e3eac50600823ca88189eccbcf93 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 14:29:51 -0300 Subject: [PATCH 25/26] test(import): refuse a malformed ./rule.md base with the Forge untouched The test verifies that readBase at :53 uses the dir argument when parsing body files, ensuring ./rule.md is recognized and its malformed markers are checked. --- test/importer.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/importer.test.ts b/test/importer.test.ts index fb52088..cb67002 100644 --- a/test/importer.test.ts +++ b/test/importer.test.ts @@ -1118,6 +1118,24 @@ describe("section-aware import — decisions (spec 11 §6.7–§6.10)", () => { expect(await snapshot(t.forge)).toEqual(before); }); + it("I12: a base with malformed markers is refused, the Forge byte-identical (./rule.md, 0.8.2)", async () => { + // This test checks that readBase at :53 uses the `dir` argument when parsing body files. + // With dir, bodyFile compares path.join(dir, "rule.md") against path.join(dir, "./rule.md"), which works. + // Without dir, it compares "rule.md" === "./rule.md", which is false, so rule.md is not read as a body file. + const t = await setup(); + await marked(t, `${HEAD}${OPEN("flavors")}${ACME}${TAIL}`); + // Add file: ./rule.md to the ingredient metadata + const metaPath = path.join(t.forge, "ingredients/rules/review-posture/ingredient.yaml"); + const meta = YAML.parse(await fs.readFile(metaPath, "utf8")); + meta.file = "./rule.md"; + await fs.writeFile(metaPath, YAML.stringify(meta)); + const before = await snapshot(t.forge); + const e = await fail(importInto(t.forge, t.ws("acme"), "acme")); + expect(e?.message).toContain("import: ingredients/rules/review-posture/rule.md:5: section flavors is never closed — fix the Forge and re-run"); + expect(e?.message).toContain("The Forge was left untouched."); + expect(await snapshot(t.forge)).toEqual(before); + }); + it("a malformed marker in a file no target emits does not refuse the import (0.8.2)", async () => { const t = await setup(); await marked(t); From fc0b41cc276d3cf45ddb0874084325e5975dff56 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 14:30:56 -0300 Subject: [PATCH 26/26] fix(sync): name the declared path, not the file field, in the misplaced-body-file error The message now says "spell the declared path as it is on disk" instead of "spell `file` as it is on disk"; the comment now lists all three causes. --- src/core/sync.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/sync.ts b/src/core/sync.ts index 0b4d996..16a773f 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -210,10 +210,11 @@ export async function plan(ws: Workspace): Promise { // Sections first, then params (spec 11 §6.4), in body files only (§6.5, 0.8.2). const parsed = bodyFile(ing.meta, file, ing.dir) ? sections.parsed.get(abs) : null; // Every body file of a resolved ingredient was parsed and gated in the section pass; a miss here means the file is - // outside the ingredient directory or behind a symlinked directory that listFiles did not descend into. + // outside the ingredient directory, behind a symlinked directory that listFiles did not descend into, or spelled + // with different letter case on a case-insensitive file system. if (parsed === undefined) throw new Error( - `${forgeRel(ws.forge, abs)}: ${ing.ref} declares a file outside its directory, behind a symlinked directory, or spelled differently from the file on disk — keep the file inside ${forgeRel(ws.forge, ing.dir)} and spell \`file\` as it is on disk`, + `${forgeRel(ws.forge, abs)}: ${ing.ref} declares a file outside its directory, behind a symlinked directory, or spelled differently from the file on disk — keep the file inside ${forgeRel(ws.forge, ing.dir)} and spell the declared path as it is on disk`, ); const expanded = parsed ? expandSections(parsed, sectionsFor(ing, resolution)) : toLf(stripBom(raw)); const out = substitute(expanded, params, missing);