diff --git a/README.md b/README.md index e8888c7..cdc9431 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Sections layer the same way, weakest → strongest: the body's default → the p **kiro** — reproduces, then extends, the hand-written `sync-steering.ps1` script it replaces: steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/`, UTF-8 without BOM, CRLF. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it, in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning. -**agents-md** — one `AGENTS.md` with the always-on rules concatenated and the scoped rules after them — each listed at the file a target of the workspace writes for it (`.claude/rules/.md` when `claude-code` writes it, else `.kiro/steering/.md` when `kiro` does), or, when no target writes it, embedded in full under a `> Scoped rule — ` line, for tools that read the open standard (Codex, Cursor, Warp, Copilot, Kimi…). Every other ingredient type aimed at `agents-md` — including through the default `targets: "*"` — has no `AGENTS.md` equivalent: it is skipped with a warning — one line per type, naming every skipped ingredient. The file keeps the line endings and BOM of the one it replaces; a new one is LF without a BOM. +**agents-md** — one `AGENTS.md` with the always-on rules concatenated and the scoped rules after them — each listed at the file a target of the workspace writes for it (`.claude/rules/.md` when `claude-code` writes it, else `.kiro/steering/.md` when `kiro` does), or, when no target writes it, embedded in full under a `> Scoped rule — ` line, for tools that read the open standard (Codex, Cursor, Warp, Copilot, Kimi…). A `.claude/rules/.md` reference inside a body follows the rule it names: it is kept when `claude-code` writes that rule, becomes `.kiro/steering/.md` when only `kiro` writes that file (from the rule or from a `steering` ingredient of that name), becomes `AGENTS.md (rule: )` when the rule's text is in this file, and otherwise becomes ` (rule not in this workspace)` with a warning (an unknown name is left as written when `claude-code` is a target); without `claude-code`, the warning also names references to other `.claude/` files, which are left as written. Every other ingredient type aimed at `agents-md` — including through the default `targets: "*"` — has no `AGENTS.md` equivalent: it is skipped with a warning — one line per type, naming every skipped ingredient. The file keeps the line endings and BOM of the one it replaces; a new one is LF without a BOM. Conversion of hooks/subagents to other tools is out of scope here: the plan is to delegate that to [rulesync](https://github.com/dyoshikawa/rulesync) rather than reimplement it. @@ -241,6 +241,16 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ## Upgrading +### to 0.8.4 + +- **A `.claude/rules/.md` reference inside a rule body that goes into `AGENTS.md` is resolved by the rule it names.** Only `AGENTS.md` changes; no other generated file and no lock field. + - **No change, no warning:** a workspace whose cited rules are all written by `claude-code` — every workspace `craftar import` produced — and one whose bodies cite no `.claude/rules/` path and, without `claude-code`, no other `.claude/` path either. Without `claude-code`, a body citing other `.claude/` files keeps its bytes but gets the warning. + - **No `claude-code`:** each reference moves. It becomes `.kiro/steering/.md` (kiro writes it), `AGENTS.md (rule: )` (its text is in the file) or ` (rule not in this workspace)`. + - **With `claude-code`:** only a reference to a rule `claude-code` does not write, or to a `steering` ingredient `kiro` writes, moves. An unknown name is left as written. +- **At most one warning per `AGENTS.md`** names every reference turned into ` (rule not in this workspace)` (a reference made to point at `.kiro/steering/` or into `AGENTS.md` is not reported). Without `claude-code`, it also names references to other `.claude/` files (agents, commands, skills, scripts, hooks), which are left as written. It repeats on every run until the body, the cited rule's `targets` or the workspace's targets change, and it never changes an exit code. +- An affected workspace shows `AGENTS.md` as `update`, and `craftar sync --check` exits 1 until it syncs. +- The kiro emitter still rewrites every `.claude/rules/` to `.kiro/steering/`; a later release (0.8.5) addresses it. + ### to 0.8.3 - **`AGENTS.md` lists a scoped rule (`fileMatch`, `manual`, `auto`) only at a file a target of the workspace really writes, and embeds it when none does.** Only `AGENTS.md` changes; no other generated file and no lock field. @@ -249,7 +259,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC - With `agents-md` alone, each scoped line becomes the embedded rule, and the file grows. - A scoped rule whose own `targets` exclude `claude-code` moves to `.kiro/steering/`, or is embedded when no file-writing target of the workspace admits it. - An affected workspace shows `AGENTS.md` as `update`, and `craftar sync --check` exits 1 until it syncs. To keep a scoped rule's text out of `AGENTS.md`, take `agents-md` out of that rule's `targets`. -- References to `.claude/rules/…` inside a rule body are emitted as the Forge holds them. In a workspace without `claude-code` such a reference points at no file; a later release addresses it. +- References to `.claude/rules/…` inside a rule body are emitted as the Forge holds them. In a workspace without `claude-code` such a reference points at no file; a later release (0.8.4) addresses it. ### to 0.8.2 diff --git a/package-lock.json b/package-lock.json index b0563b7..f7b49db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.3", + "version": "0.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.3", + "version": "0.8.4", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 1f23209..98a4bfc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.8.3", + "version": "0.8.4", "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 9b7c9a6..a73edce 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.3"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.4"); /* ---------------------------------------------------------------- import */ program diff --git a/src/emitters/agents-md.ts b/src/emitters/agents-md.ts index 90071fe..0ecd5a7 100644 --- a/src/emitters/agents-md.ts +++ b/src/emitters/agents-md.ts @@ -1,4 +1,4 @@ -import { appliesTo, outName, ruleFile, textFile } from "./shared.js"; +import { appliesTo, buildRuleLookup, outName, resolveRuleRefs, ruleFile, ruleWriter, textFile, type RefReport } from "./shared.js"; import type { Emitter } from "./types.js"; /** @@ -23,6 +23,10 @@ export const agentsMd: Emitter = { } const rules = aimed.filter((i) => i.meta.type === "rule"); if (!rules.length) return []; + + // Build the lookup once per AGENTS.md, not once per body (spec 15 §4.2) + const lookup = buildRuleLookup(ctx.resolution.ingredients); + const parts: string[] = [ "", "", @@ -34,26 +38,35 @@ export const agentsMd: Emitter = { ]; const pathLines: string[] = []; const embedded: string[] = []; + // Collect reports in AGENTS.md order: always-on first, then embedded (spec 15 §4.5) + const alwaysReports: RefReport[] = []; + const embeddedReports: RefReport[] = []; for (const ing of rules) { if (ing.meta.type !== "rule") continue; - const body = (await ctx.text(ing, ing.meta.file)).replace(/\n+$/, ""); + const rawBody = await ctx.text(ing, ing.meta.file); if (ing.meta.inclusion === "always") { + // Resolve references, then trim trailing newlines (spec 15: after ctx.text, before the trim) + const { text: resolved, report } = resolveRuleRefs(rawBody, ing.ref, lookup, ctx.resolution.targets); + alwaysReports.push(report); + const body = resolved.replace(/\n+$/, ""); parts.push(``, body, ""); } else { // Spec 14 §4.1: decide which target writes this rule file - const writers = (["claude-code", "kiro"] as const).filter( - (t) => ctx.resolution.targets.includes(t) && appliesTo(ing.meta.targets, t), - ); + const writer = ruleWriter(ctx.resolution.targets, ing.meta.targets); const scopeText = ing.meta.inclusion === "fileMatch" ? `applies to \`${[ing.meta.fileMatchPattern].flat().join("`, `")}\`` : ing.meta.inclusion; - if (writers.includes("claude-code")) { + if (writer === "claude-code") { pathLines.push(`- \`${ruleFile("claude-code", ing.meta)}\` — ${scopeText}`); - } else if (writers.includes("kiro")) { + } else if (writer === "kiro") { pathLines.push(`- \`${ruleFile("kiro", ing.meta)}\` — ${scopeText}`); } else { // State C: no target writes the rule file; embed it + // Resolve references in embedded bodies too (spec 15 §4, after ctx.text, before trim) + const { text: resolved, report } = resolveRuleRefs(rawBody, ing.ref, lookup, ctx.resolution.targets); + embeddedReports.push(report); + const body = resolved.replace(/\n+$/, ""); embedded.push(``, `> Scoped rule — ${scopeText}`, "", body, ""); } } @@ -63,6 +76,56 @@ export const agentsMd: Emitter = { if (pathLines.length) parts.push(""); parts.push(...embedded); } + + // Emit the warning per spec 15 §4.5: at most one ctx.warn for AGENTS.md + // Reports in AGENTS.md order: always-on first, then embedded + emitRefWarning(ctx.warn, [...alwaysReports, ...embeddedReports]); + return [await textFile(ctx, "AGENTS.md", parts.join("\n"), "agents-md", "rule/*")]; }, }; + +/** + * Emit the spec 15 §4.5 warning: one line with reworded rule references and/or dead .claude/ paths. + * Deduplicates entries by (reference, citing) and (path, citing). + */ +function emitRefWarning(warn: (msg: string) => void, reports: RefReport[]): void { + // Backstop only: each report covers one body, and resolveRuleRefs already dedups by (reference, citing ingredient). + const rewordedSet = new Map(); + for (const r of reports) { + for (const e of r.reworded) { + const key = `${e.ref}|${e.citing}`; + if (!rewordedSet.has(key)) rewordedSet.set(key, e); + } + } + // Collect and deduplicate other paths by (path, citing) + const othersSet = new Map(); + for (const r of reports) { + for (const e of r.others) { + const key = `${e.path}|${e.citing}`; + if (!othersSet.has(key)) othersSet.set(key, e); + } + } + + const reworded = [...rewordedSet.values()]; + const others = [...othersSet.values()]; + + if (!reworded.length && !others.length) return; + + const formatReworded = (entries: typeof reworded) => + entries.map((e) => `${e.ref} (in ${e.citing}; ${e.kind})`).join(", "); + const formatOthers = (entries: typeof others) => + entries.map((e) => `${e.path} (in ${e.citing})`).join(", "); + + let msg = ""; + if (reworded.length) { + msg = `agents-md: ${reworded.length} reference(s) to rule files this workspace does not have — reworded in AGENTS.md: ${formatReworded(reworded)}`; + if (others.length) { + msg += `; ${others.length} reference(s) to other .claude/ files it does not have — left as written: ${formatOthers(others)}`; + } + } else if (others.length) { + msg = `agents-md: ${others.length} reference(s) to .claude/ files this workspace does not have — left as written: ${formatOthers(others)}`; + } + + if (msg) warn(msg); +} diff --git a/src/emitters/kiro.ts b/src/emitters/kiro.ts index 1d7fd98..46a1d4e 100644 --- a/src/emitters/kiro.ts +++ b/src/emitters/kiro.ts @@ -1,7 +1,7 @@ import { toCrlf } from "../core/text.js"; import { serializeFrontmatter } from "../core/frontmatter.js"; import { listFiles } from "../core/forge.js"; -import { appliesTo, mcpServers, outName, ruleFile } from "./shared.js"; +import { appliesTo, mcpServers, outName, ruleFile, RULE_NAME_CHARS } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; import type { ResolvedIngredient } from "../core/resolve.js"; @@ -152,7 +152,7 @@ export function agentResources(agentName: string, text: string, known: Set.md` or `.kiro/steering/.md`, in order of first appearance. */ export function referencedRules(text: string, known: Set): string[] { const out: string[] = []; - for (const m of text.matchAll(/\.(?:claude\/rules|kiro\/steering)\/([A-Za-z0-9._-]+)\.md/g)) { + for (const m of text.matchAll(new RegExp(`\\.(?:claude\\/rules|kiro\\/steering)\\/([${RULE_NAME_CHARS}]+)\\.md`, "g"))) { const name = m[1]; if (known.has(name) && !out.includes(name)) out.push(name); } diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 5a97045..ece009f 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -1,10 +1,235 @@ import { detectEol, hasBom, withEol, type Eol } from "../core/text.js"; import type { EmitContext, PlannedFile } from "./types.js"; +import type { ResolvedIngredient } from "../core/resolve.js"; export function appliesTo(targets: "*" | string[], target: string): boolean { return targets === "*" || targets.includes(target); } +/** The characters of a rule name in a `.claude/rules/.md` reference (spec 15 §4.1). */ +export const RULE_NAME_CHARS = "A-Za-z0-9._-"; + +/** + * The target that writes a rule's own file in this workspace, `claude-code` first, else `kiro`, else none + * (spec 14 §4.1): a workspace target the rule's `targets` admit. + */ +export function ruleWriter(targets: readonly string[], ruleTargets: "*" | string[]): "claude-code" | "kiro" | null { + if (targets.includes("claude-code") && appliesTo(ruleTargets, "claude-code")) return "claude-code"; + if (targets.includes("kiro") && appliesTo(ruleTargets, "kiro")) return "kiro"; + return null; +} + +/** Report of what resolveRuleRefs reworded or found dead (spec 15 §4.5). */ +export interface RefReport { + reworded: Array<{ ref: string; citing: string; kind: string }>; + others: Array<{ path: string; citing: string }>; +} + +/** Lookup maps for rule reference resolution (spec 15 §4.2), built once per AGENTS.md. */ +export interface RuleLookup { + rulesByName: Map; + steeringsByName: Map; +} + +/** Build the lookup maps for rule reference resolution, once per AGENTS.md. */ +export function buildRuleLookup(ingredients: ResolvedIngredient[]): RuleLookup { + const rulesByName = new Map(); + const steeringsByName = new Map(); + for (const ing of ingredients) { + const name = outName(ing.meta); + if (ing.meta.type === "rule") rulesByName.set(name, ing); + else if (ing.meta.type === "steering") steeringsByName.set(name, ing); + } + return { rulesByName, steeringsByName }; +} + +/** + * Resolve `.claude/rules/.md` references in a body emitted into AGENTS.md (spec 15 §4.1–§4.3). + * Returns the transformed text and a report of what was reworded or found dead. + */ +export function resolveRuleRefs( + body: string, + citing: string, + lookup: RuleLookup, + targets: readonly string[], +): { text: string; report: RefReport } { + const report: RefReport = { reworded: [], others: [] }; + const hasCc = targets.includes("claude-code"); + const hasKiro = targets.includes("kiro"); + const { rulesByName, steeringsByName } = lookup; + + // Determine rule state: A, B, C, D, or unknown (spec 15 §4.2) + const ruleState = (name: string): "A" | "B" | "C" | "D" | "unknown" => { + const rule = rulesByName.get(name); + if (rule) { + // A or B (rule): use ruleWriter to decide + const writer = ruleWriter(targets, rule.meta.targets); + if (writer === "claude-code") return "A"; + if (writer === "kiro") return "B"; + } + // B (steering): kiro writes a steering with this name — checked before C/D (spec 15 §4.2) + const steering = steeringsByName.get(name); + if (steering && hasKiro && appliesTo(steering.meta.targets, "kiro")) return "B"; + // C or D only when a rule exists + if (rule) { + // C: rule aimed at agents-md (text is in AGENTS.md) + if (appliesTo(rule.meta.targets, "agents-md")) return "C"; + // D: rule exists but not written by any target here + return "D"; + } + // No rule with this name + return "unknown"; + }; + + // Right boundary: not followed by letter, digit, `_`, `-`, or `.` followed by one of those (spec 15 §4.1) + // NOTE: The right boundary class is NOT the same as RULE_NAME_CHARS (which includes `.` for names). + const rightBoundary = (after: string): boolean => { + if (!after) return true; + const c = after[0]; + if (/[A-Za-z0-9_-]/.test(c)) return false; + if (c === "." && after.length > 1 && /[A-Za-z0-9_-]/.test(after[1])) return false; + return true; + }; + + // Collect all matches from the original body with their offsets, then apply in reverse order + // so that earlier offsets remain valid. This ensures warning order follows the original body (spec 15 §4.5). + interface Match { + offset: number; + length: number; + replacement: string; + reworded?: { ref: string; kind: string }; + } + const matches: Match[] = []; + + // Link pattern: [text](.claude/rules/.md) or [text](.claude/rules/.md#frag) + const linkPattern = new RegExp( + `\\[([^\\]]+)\\]\\(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md(#[^)]*)?\\)`, + "g", + ); + let linkMatch; + while ((linkMatch = linkPattern.exec(body)) !== null) { + const [match, text, name, frag] = linkMatch as RegExpExecArray & [string, string, string, string | undefined]; + const offset = linkMatch.index; + const state = ruleState(name); + const rule = rulesByName.get(name); + let replacement: string; + let reworded: { ref: string; kind: string } | undefined; + switch (state) { + case "A": + continue; // unchanged, skip + case "B": + replacement = `[${text}](${ruleFile("kiro", { name })}${frag ?? ""})`; + break; + case "C": + replacement = `[${text}](AGENTS.md)`; // fragment dropped + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${text} (${name}, rule not in this workspace)`; + break; + case "unknown": + if (hasCc) continue; + reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; + replacement = `${text} (${name}, rule not in this workspace)`; + break; + } + matches.push({ offset, length: match.length, replacement, reworded }); + } + + // Token pattern: .claude/rules/.md (not in a link) with left boundary + const tokenPattern = new RegExp( + `(^|[^/${RULE_NAME_CHARS}])(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md)`, + "g", + ); + let tokenMatch; + while ((tokenMatch = tokenPattern.exec(body)) !== null) { + const [match, before, token, name] = tokenMatch as RegExpExecArray & [string, string, string, string]; + // The offset of the actual token, not the left boundary; used for overlap checks and ordering + const offset = tokenMatch.index + before.length; + const fullOffset = tokenMatch.index + match.length; + const after = body.slice(fullOffset); + if (!rightBoundary(after)) continue; + // Skip if this offset overlaps with a link match (link pattern already captured it) + if (matches.some((m) => offset >= m.offset && offset < m.offset + m.length)) continue; + const state = ruleState(name); + const rule = rulesByName.get(name); + let replacement: string; + let reworded: { ref: string; kind: string } | undefined; + switch (state) { + case "A": + continue; // unchanged, skip + case "B": + replacement = ruleFile("kiro", { name }); + break; + case "C": + replacement = `AGENTS.md (rule: ${name})`; + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${name} (rule not in this workspace)`; + break; + case "unknown": + if (hasCc) continue; + reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; + replacement = `${name} (rule not in this workspace)`; + break; + } + matches.push({ offset, length: token.length, replacement, reworded }); + } + + // Sort by offset for correct warning order, then apply replacements in reverse order + matches.sort((a, b) => a.offset - b.offset); + + // Collect reworded entries with their original offsets for sorting (spec 15 §4.5) + const rewordedWithOffset: Array<{ offset: number; ref: string; citing: string; kind: string }> = []; + for (const m of matches) { + if (m.reworded) { + rewordedWithOffset.push({ offset: m.offset, ref: m.reworded.ref, citing, kind: m.reworded.kind }); + } + } + + // Apply replacements from end to start so earlier offsets remain valid + let result = body; + for (let i = matches.length - 1; i >= 0; i--) { + const m = matches[i]; + result = result.slice(0, m.offset) + m.replacement + result.slice(m.offset + m.length); + } + + // Sort by offset and deduplicate by (ref, citing), keeping first occurrence (spec 15 §4.5) + rewordedWithOffset.sort((a, b) => a.offset - b.offset); + const seen = new Set(); + for (const e of rewordedWithOffset) { + const key = `${e.ref}|${e.citing}`; + if (!seen.has(key)) { + seen.add(key); + report.reworded.push({ ref: e.ref, citing: e.citing, kind: e.kind }); + } + } + + // Collect other .claude/ paths (agents, commands, skills, scripts, hooks) only when no claude-code (spec 15 §4.5) + if (!hasCc) { + const otherDirs = ["agents", "commands", "skills", "scripts", "hooks"]; + // The path class adds `/` for subdirectories; in `[.../-]` the `/` goes before `-` to avoid a range error. + const otherPattern = new RegExp( + `(^|[^/${RULE_NAME_CHARS}])\\.claude\\/(${otherDirs.join("|")})\\/([A-Za-z0-9._/-]+)`, + "g", + ); + let otherMatch; + while ((otherMatch = otherPattern.exec(result)) !== null) { + // Trim trailing `.` or `/` from the path, and skip if empty after trimming + let path = otherMatch[3].replace(/[./]+$/, ""); + if (!path) continue; + const fullPath = `.claude/${otherMatch[2]}/${path}`; + // Deduplicate by (path, citing) + if (!report.others.some((o) => o.path === fullPath && o.citing === citing)) { + report.others.push({ path: fullPath, citing }); + } + } + } + + return { text: result, report }; +} + const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]); /** Text file that keeps the EOL and the BOM of the file it replaces (LF, no BOM when new). */ diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index a756cb0..e838036 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -184,7 +184,7 @@ describe("agents-md emitter — scoped rules (spec 14)", () => { it("agents-md only: every scoped rule is embedded with its scope (spec 14 test 1)", async () => { const p = await planFor(forge14()); - expect(agentsMd(p)).toBe(ONLY_MD.join("\n")); + expect(agentsMd(p)).toBe(ONLY_MD.join("\n").replaceAll("See .claude/rules/style.md.", "See AGENTS.md (rule: style).")); noAgentsMdWarning(p); }); @@ -198,7 +198,7 @@ describe("agents-md emitter — scoped rules (spec 14)", () => { "- `.kiro/steering/kiro-only.md` — " + K, "", ...embedded("md-only", M), - ].join("\n")); + ].join("\n").replaceAll("See .claude/rules/style.md.", "See .kiro/steering/style.md.")); noAgentsMdWarning(p); }); @@ -252,6 +252,303 @@ describe("agents-md emitter — scoped rules (spec 14)", () => { it("keeps the CRLF of the AGENTS.md it replaces (spec 14 test 9)", async () => { const p = await planFor(forge14(), { "AGENTS.md": "# old\r\n" }); - expect(agentsMd(p)).toBe(ONLY_MD.join("\r\n")); + expect(agentsMd(p)).toBe(ONLY_MD.join("\r\n").replaceAll("See .claude/rules/style.md.", "See AGENTS.md (rule: style).")); + }); +}); + +describe("agents-md emitter — rule references in bodies (spec 15)", () => { + const HUB = [ + "# hub", "", + "- backticks: see `.claude/rules/style.md` and `.claude/rules/api.md`.", + "- link: [the md-only rule](.claude/rules/md-only.md).", + "- bare: .claude/rules/cc-only.md and .claude/rules/kiro-only.md", + "- dead link: [the cc-only rule](.claude/rules/cc-only.md)", + "- unknown name: `.claude/rules/handwritten.md`", + "- pattern: `.claude/rules/.md` and `.claude/rules/*.md`", + "- not a rule: `.claude/agents/reviewer.md`, `.claude/commands/open-pr.md`", "", + ].join("\n"); + const forge15 = (): IngredientSpec[] => [ + rule("hub", HUB), + rule("style", "# style\n"), + rule("api", "# api\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" }), + rule("kiro-only", "# kiro-only\n", { inclusion: "fileMatch", fileMatchPattern: "projects/k/**", targets: ["kiro", "agents-md"] }), + rule("md-only", "# md-only\n", { inclusion: "fileMatch", fileMatchPattern: "projects/m/**", targets: ["agents-md"] }), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + ]; + const TAIL = ["- pattern: `.claude/rules/.md` and `.claude/rules/*.md`", "- not a rule: `.claude/agents/reviewer.md`, `.claude/commands/open-pr.md`"]; + const hubAndStyle = (lines: string[]) => ["", "# hub", "", ...lines, "", "", "# style", "", "## Scoped rules", ""]; + const scoped = (name: string, pattern: string) => [``, `> Scoped rule — applies to \`${pattern}\``, "", `# ${name}`, ""]; + const WARN = + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here), .claude/rules/handwritten.md (in rule/hub; no such rule); 2 reference(s) to other .claude/ files it does not have — left as written: .claude/agents/reviewer.md (in rule/hub), .claude/commands/open-pr.md (in rule/hub)"; + const amWarnings = (p: Plan) => p.warnings.filter((w) => w.startsWith("agents-md:")); + /** The section of `name` in AGENTS.md: from its marker to the next marker or the scoped heading, trailing newlines trimmed. */ + const sectionOf = (md: string, name: string) => { + const start = md.indexOf(`\n`); + if (start < 0) return undefined; + const rest = md.slice(start); + const end = rest.search(/\n\n(?:", "# hub", "", "- `.claude/rules/style.md`", "- .claude/rules/api.md", "- [api](.claude/rules/api.md)", "", + "", "# style", "", + "## Scoped rules", "", "- `.claude/rules/api.md` — applies to `projects/api/**`", "", + ].join("\n")); + expect(amWarnings(p)).toEqual([]); + }); + + it("an embedded body is resolved too (spec 15 test 5)", async () => { + const p = await planFor([rule("style", "# style\n"), rule("api", "# api\n\nSee .claude/rules/style.md for style.\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" })]); + expect(agentsMd(p)).toBe([ + ...HEADER, "", "# style", "", + "## Scoped rules", "", "", "> Scoped rule — applies to `projects/api/**`", "", "# api", "", "See AGENTS.md (rule: style) for style.", "", + ].join("\n")); + }); + + it("a steering ingredient kiro writes is state B; without kiro it is no rule (spec 15 test 6)", async () => { + const ings = () => hubOnly("See `.claude/rules/product.md`.", [{ meta: { type: "steering", name: "product" }, files: { "steering.md": "# product\n" } }]); + const withKiro = await planFor(ings(), undefined, ["kiro", "agents-md"]); + expect(sectionOf(agentsMd(withKiro)!, "hub")).toBe("\n# hub\n\nSee `.kiro/steering/product.md`."); + expect(amWarnings(withKiro)).toEqual([]); + const mdOnly = await planFor(ings()); + expect(sectionOf(agentsMd(mdOnly)!, "hub")).toBe("\n# hub\n\nSee `product (rule not in this workspace)`."); + expect(amWarnings(mdOnly)).toEqual(["agents-md: 1 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/product.md (in rule/hub; no such rule)"]); + }); + + it("a fragment is kept in B, dropped in C, gone with the link in D (spec 15 test 7)", async () => { + const p = await planFor( + hubOnly("[b](.claude/rules/kb.md#part)\n[c](.claude/rules/mdr.md#part)\n[d](.claude/rules/cc-only.md#part)", [ + rule("kb", "# kb\n", { inclusion: "manual" }), + rule("mdr", "# mdr\n", { inclusion: "manual", targets: ["agents-md"] }), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + ]), + undefined, + ["kiro", "agents-md"], + ); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\n[b](.kiro/steering/kb.md#part)\n[c](AGENTS.md)\nd (cc-only, rule not in this workspace)"); + }); + + it("what is not a reference is left alone and not reported (spec 15 test 8)", async () => { + const lines = [ + "projects/web/.claude/rules/style.md", + "x.claude/rules/style.md", + "`.claude/rules/style.md.bak`", + "`.claude/rules/style.mdx`", + "`.claude/rules/.md` and `.claude/rules/*.md`", + ]; + const p = await planFor(hubOnly(lines.join("\n"), [rule("style", "# style\n")])); + expect(sectionOf(agentsMd(p)!, "hub")).toBe(["", "# hub", "", ...lines].join("\n")); + expect(amWarnings(p)).toEqual([]); + }); + + it("a variant is resolved by its output name (spec 15 test 9)", async () => { + const api = (extra: Record) => rule("api--acme", "# API\n", { as: "api", inclusion: "fileMatch", fileMatchPattern: "projects/api/**", ...extra }); + const c = await planFor(hubOnly("See .claude/rules/api.md now.", [api({})])); + expect(sectionOf(agentsMd(c)!, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: api) now."); + const d = await planFor(hubOnly("See .claude/rules/api.md now.", [api({ targets: ["claude-code"] })])); + expect(sectionOf(agentsMd(d)!, "hub")).toBe("\n# hub\n\nSee api (rule not in this workspace) now."); + expect(amWarnings(d)).toEqual(["agents-md: 1 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/api.md (in rule/hub; rule/api--acme reaches no target here)"]); + }); + + it("a reference that comes from a section value is resolved (spec 15 test 10)", async () => { + const s = await scenario( + { + ingredients: [rule("hub", "# hub\n\n\ndefault\n\n"), rule("style", "# style\n")], + recipes: [recipe("base", ["rule/hub", "rule/style"])], + profiles: [profile("acme", ["base"], ["agents-md"], { sections: { "rule/hub": { s: "See .claude/rules/style.md now." } } })], + }, + { config: { profile: "acme" } }, + ); + cleanups.push(s.cleanup); + await writeFiles(s.forgeRoot, { "craftar.forge.yaml": "name: test-forge\nschema: 2\n" }); + const md = agentsMd(await plan(await loadWorkspace(s.wsRoot)))!; + expect(sectionOf(md, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: style) now."); + }); + + it("other .claude/ paths only: left as written, named in the second form; nothing with claude-code (spec 15 test 11)", async () => { + const mdOnly = await planFor(hubOnly("See `.claude/agents/reviewer.md`.")); + expect(sectionOf(agentsMd(mdOnly)!, "hub")).toBe("\n# hub\n\nSee `.claude/agents/reviewer.md`."); + expect(amWarnings(mdOnly)).toEqual(["agents-md: 1 reference(s) to .claude/ files this workspace does not have — left as written: .claude/agents/reviewer.md (in rule/hub)"]); + const withCc = await planFor(hubOnly("See `.claude/agents/reviewer.md`."), undefined, ["claude-code", "agents-md"]); + expect(amWarnings(withCc)).toEqual([]); + }); + + it("one entry per reference and citing rule (spec 15 test 12)", async () => { + const p = await planFor(hubOnly(".claude/rules/cc-only.md and .claude/rules/cc-only.md", [rule("cc-only", "# cc-only\n", { targets: ["claude-code"] })])); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\ncc-only (rule not in this workspace) and cc-only (rule not in this workspace)"); + expect(amWarnings(p)).toEqual(["agents-md: 1 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here)"]); + }); + + it("state D with claude-code: the first form alone (spec 15 test 13)", async () => { + const p = await planFor(hubOnly("See .claude/rules/k.md.", [rule("k", "# k\n", { targets: ["kiro"] })]), undefined, ["claude-code", "agents-md"]); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\nSee k (rule not in this workspace)."); + expect(amWarnings(p)).toEqual(["agents-md: 1 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/k.md (in rule/hub; rule/k reaches no target here)"]); + }); + + it("an unknown name as a link (spec 15 test 14)", async () => { + const p = await planFor(hubOnly("[the notes](.claude/rules/handwritten.md)")); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\nthe notes (handwritten, rule not in this workspace)"); + }); + + it("a reference that comes from a param value is resolved (spec 15 test 15)", async () => { + const s = await scenario( + { + ingredients: [rule("hub", "# hub\n\nSee {{ref}}.\n"), rule("style", "# style\n")], + recipes: [recipe("base", ["rule/hub", "rule/style"])], + profiles: [profile("acme", ["base"], ["agents-md"], { params: { ref: ".claude/rules/style.md" } })], + }, + { config: { profile: "acme" } }, + ); + cleanups.push(s.cleanup); + const md = agentsMd(await plan(await loadWorkspace(s.wsRoot)))!; + expect(sectionOf(md, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: style)."); + }); + + it("a sentence-ending full stop is not part of the reference (spec 15 test 16)", async () => { + const p = await planFor(hubOnly("See .claude/rules/style.md.", [rule("style", "# style\n")])); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: style)."); + }); + + it("entries follow the order of AGENTS.md, not of resolution (spec 15 test 17)", async () => { + const p = await planFor([ + rule("emb", "# emb\n\nSee .claude/rules/cc-only.md.\n", { inclusion: "manual" }), + rule("hub", "# hub\n\nSee .claude/rules/cc-only.md.\n"), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + ]); + expect(amWarnings(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here), .claude/rules/cc-only.md (in rule/emb; rule/cc-only reaches no target here)", + ]); + }); + + it("a link is a link whatever stands before or after it (spec 15 §4.1, review fix)", async () => { + const p = await planFor( + hubOnly("rules/[s](.claude/rules/style.md)\n[s](.claude/rules/style.md)-based\n[a](.claude/rules/style.md)/[b](.claude/rules/api.md)\n[c](.claude/rules/cc-only.md)s", [ + rule("style", "# style\n"), + rule("api", "# api\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" }), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + ]), + ); + expect(sectionOf(agentsMd(p)!, "hub")).toBe( + "\n# hub\n\nrules/[s](AGENTS.md)\n[s](AGENTS.md)-based\n[a](AGENTS.md)/[b](AGENTS.md)\nc (cc-only, rule not in this workspace)s", + ); + }); + + it("a command with the same name does not hide the rule (spec 15 §4.2, review fix)", async () => { + const cmd = { meta: { type: "command" as const, name: "style" }, files: { "command.md": "# style command\n" } }; + for (const ings of [hubOnly("See .claude/rules/style.md.", [rule("style", "# style\n"), cmd]), hubOnly("See .claude/rules/style.md.", [cmd, rule("style", "# style\n")])]) { + const p = await planFor(ings); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: style)."); + expect(amWarnings(p).filter((w) => w.includes("reference(s)"))).toEqual([]); + } + }); + + it("a dead token before a dead link is reported first (spec 15 §4.5, review fix)", async () => { + const p = await planFor(hubOnly("x .claude/rules/nope.md\n[c](.claude/rules/cc-only.md)", [rule("cc-only", "# cc-only\n", { targets: ["claude-code"] })])); + expect(amWarnings(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/nope.md (in rule/hub; no such rule), .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here)", + ]); + }); + + it("a steering file kiro writes wins over a rule of the same name no target writes (spec 15 §4.2, review fix)", async () => { + const steering = { meta: { type: "steering" as const, name: "x" }, files: { "steering.md": "# x steering\n" } }; + for (const ruleTargets of [["claude-code"], ["agents-md"]]) { + const p = await planFor( + hubOnly("see .claude/rules/x.md and [l](.claude/rules/x.md)", [rule("x", "# x\n", { inclusion: "manual", targets: ruleTargets }), steering]), + undefined, + ["kiro", "agents-md"], + ); + expect(sectionOf(agentsMd(p)!, "hub"), ruleTargets.join()).toBe("\n# hub\n\nsee .kiro/steering/x.md and [l](.kiro/steering/x.md)"); + expect(amWarnings(p).filter((w) => w.includes("reference(s)")), ruleTargets.join()).toEqual([]); + } + }); + + it("warning order follows the original body even after rewritten links (spec 15 §4.5, review fix)", async () => { + const dead = (x: string) => `.claude/rules/${x}.md (in rule/hub; no such rule)`; + const p1 = await planFor(hubOnly("[a](.claude/rules/d1.md) [b](.claude/rules/d2.md) .claude/rules/z.md [c](.claude/rules/d3.md)")); + expect(amWarnings(p1)).toEqual([`agents-md: 4 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: ${["d1", "d2", "z", "d3"].map(dead).join(", ")}`]); + const p2 = await planFor( + hubOnly("[l](.claude/rules/a-very-long-rule-name.md) [u](.claude/rules/dd.md) .claude/rules/z.md", [rule("a-very-long-rule-name", "# long\n", { inclusion: "manual" })]), + ); + expect(amWarnings(p2)).toEqual([`agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: ${["dd", "z"].map(dead).join(", ")}`]); + }); + + it("a token right after a link is still a reference (spec 15 §4.1, review fix)", async () => { + const p = await planFor(hubOnly("[y](.claude/rules/u5.md).claude/rules/u6.md")); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\ny (u5, rule not in this workspace)u6 (rule not in this workspace)"); + expect(amWarnings(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/u5.md (in rule/hub; no such rule), .claude/rules/u6.md (in rule/hub; no such rule)", + ]); }); });