Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d5e41ad
fix(core): read only the files a target emits as an ingredient's body
llima Oct 4, 2026
574e458
docs(readme): say that only emitted files are read as an ingredient's…
llima Oct 4, 2026
b38b51c
chore: update version
llima Oct 4, 2026
0daeb10
fix(core): compare emitted paths in normal form
llima Oct 4, 2026
153c715
test(import): a {{key}} in a file no target emits does not hold a par…
llima Oct 4, 2026
ddcb02a
test(unify): a rule's notes.md with markers is carried over by take: …
llima Oct 4, 2026
2a20144
test: make two 0.8.2 assertions strict
llima Oct 4, 2026
af1968d
docs(core): say body file, not admitted file
llima Oct 4, 2026
b78b22c
docs(readme): say which files are an ingredient's body
llima Oct 4, 2026
1e40309
fix(core): normalize emitted paths the way path.join resolves them
llima Oct 4, 2026
6defa8a
docs(readme): say what changes for a plan saved under 0.8.1, and whic…
llima Oct 4, 2026
afc8380
test(unify): a file no target emits gets no pre-filled section name
llima Oct 4, 2026
559d107
chore(core): drop an unused import and say body file in the remaining…
llima Oct 4, 2026
defdaf5
fix(core): decide emitted files by the path the emitters read
llima Oct 4, 2026
fc0bb55
test(core): pin which spellings name an emitted file, per platform
llima Oct 4, 2026
0374367
fix(core): name a body file the section pass could not reach
llima Oct 4, 2026
53ac9f3
docs(readme): say which files count as a {{param}} citation, and fix …
llima Oct 4, 2026
8e2f9a5
refactor(core): make the ingredient directory a required argument of …
llima Oct 4, 2026
baf1faa
test: a body file declared ./rule.md is read by unify and the citatio…
llima Oct 4, 2026
cdf49c8
docs: say which commands print the upgrade warning, and that a body f…
llima Oct 4, 2026
02b6c98
test: a body file declared ./rule.md reaches every unify and import c…
llima Oct 4, 2026
26c583f
docs: name the commands a misplaced body file fails, and why the file…
llima Oct 4, 2026
1b14bf1
test: pin the remaining ./rule.md sites of unify, import and sync
llima Oct 4, 2026
53ade25
test(unify): refuse a section name another ./-spelled script file dec…
llima Oct 4, 2026
cb946d9
test(import): refuse a malformed ./rule.md base with the Forge untouched
llima Oct 4, 2026
fc0b41c
fix(sync): name the declared path, not the file field, in the misplac…
llima Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 49 additions & 2 deletions src/core/extract.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -29,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) {
Expand All @@ -49,6 +51,51 @@ 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 `<dir>/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 | null): boolean {
// 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");
case "agent":
return eq(file, meta.file ?? "agent.md");
case "command":
return eq(file, meta.file ?? "command.md");
case "steering":
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
case "script":
case "hook":
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.
*
* `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);
}

/** `substitute` restricted to `keys`: every other placeholder is left as it is. */
export function substituteKeys(text: string, values: Map<string, string>): string {
return text.replace(PLACEHOLDER, (m, key: string) => (values.has(key) ? values.get(key)! : m));
Expand Down
6 changes: 3 additions & 3 deletions src/core/param-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -67,10 +67,10 @@ export function resolvedBy(forge: Forge, profile: string): Set<string> {
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<boolean> {
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, ing.dir)) continue;
if (placeholders(await readIngredientText(ing, rel)).includes(key)) return true;
}
return false;
Expand Down
4 changes: 2 additions & 2 deletions src/core/section-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; // section names declared in the base's OTHER admitted files → "file:line"
declaredElsewhere: Map<string, string>; // 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);
Expand Down Expand Up @@ -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<string>;
}): Array<string | undefined> {
const { baseText, label, ref, hunks, declared } = args;
Expand Down
26 changes: 16 additions & 10 deletions src/core/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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";
Expand Down Expand Up @@ -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.
Expand All @@ -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, 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 {
// 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, 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);
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 }> = [];
Expand Down Expand Up @@ -161,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<string, unknown>): void {
Expand Down Expand Up @@ -206,10 +207,15 @@ export async function plan(ws: Workspace): Promise<Plan> {
const raw = await fs.readFile(abs, "utf8");
const missing = new Set<string>();
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.
if (parsed === undefined) throw new Error(`internal: ${forgeRel(ws.forge, abs)} was not parsed by the section pass`);
// 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, 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 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);
if (parsed) guardOutput(ing, file, out, parsed, resolution, params);
Expand Down
18 changes: 9 additions & 9 deletions src/core/template-import.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,18 +25,18 @@ export function renderMap(meta: Ingredient, profileParams: Record<string, unknow
return out;
}

/** An ingredient directory as the comparison sees it: its validated metadata and its files, admitted ones as normalized text. */
/** An ingredient directory as the comparison sees it: its validated metadata and its files, body files as normalized text. */
export interface ImportBase {
meta: Ingredient;
texts: Map<string, string>;
bytes: Map<string, Buffer>;
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<string, ParsedSections>;
}

/**
* 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.
*/
Expand All @@ -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, 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}`));
Expand All @@ -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<string, string> = {}): Map<string, string> {
const out = new Map<string, string>();
for (const [rel, text] of base.texts) {
Expand All @@ -79,7 +79,7 @@ export function expandedTexts(base: ImportBase, values: Record<string, string> =
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<string, string>): Set<string> {
const out = new Set<string>();
for (const t of texts.values()) for (const k of placeholders(t)) out.add(k);
Expand All @@ -88,7 +88,7 @@ export function citedKeys(texts: Map<string, string>): Set<string> {

/**
* 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`.
*/
Expand Down
Loading
Loading