diff --git a/README.md b/README.md index 373fb5a..195603c 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,9 @@ Sections layer the same way, weakest → strongest: the body's default → the p **claude-code** — emits `.claude/rules|agents|commands|skills|scripts|hooks` and `.mcp.json` verbatim from the Forge (frontmatter kept byte-for-byte). Each MCP server is written exactly as the Forge holds it, undeclared keys included, in its own key order. Existing files keep their line endings and BOM; new files are LF without a BOM. Steering is Kiro-only (its `targets` default to `[kiro]`); one aimed at `claude-code` explicitly (`targets: "*"` or a list naming it) is skipped with a warning. **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/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/.md` when `claude-code` writes it, `AGENTS.md (rule: )` when only `AGENTS.md` holds it, otherwise ` (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported. Files are UTF-8 without BOM, CRLF. The same applies to agent prompts and descriptions, command bodies and descriptions, and skill text files; a `steering` ingredient is emitted as written. 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. +steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/.md` when `claude-code` writes it, `AGENTS.md (rule: )` when only `AGENTS.md` holds it, otherwise ` (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported. A reference inside a link's text follows the same rules (in a link kept for `claude-code`, only those references move), and a link whose target reads "rule not in this workspace" and whose text is exactly its own path becomes ` (rule not in this workspace)` (backticks kept); an unknown name keeps its link. Files are UTF-8 without BOM, CRLF. The same applies to agent prompts and descriptions, command bodies and descriptions, and skill text files; a `steering` ingredient is emitted as written. 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…). 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. +**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); a reference inside a link's text is resolved the same way, and a link whose target reads "rule not in this workspace" and whose text is exactly its own path becomes ` (rule not in this workspace)` (backticks kept); 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,14 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ## Upgrading +### to 0.8.6 + +- **A rule reference inside the text of a Markdown link is now resolved like any other**, in `AGENTS.md` and in the kiro files. Before, the text of a link that `AGENTS.md` or kiro rewrote was copied as the Forge held it, or blanket-rewritten to `.kiro/steering/`. + - A link whose target reads "rule not in this workspace" and whose text is exactly its own path — `[.claude/rules/x.md](.claude/rules/x.md)`, backticks or not — becomes `x (rule not in this workspace)`, backticks kept. Any other text keeps the link's usual wording, with its references resolved. + - In a link kiro keeps for `claude-code`, only the references in the text move; the rest of the text stays as written. +- **No byte change** for the `.kiro/` files of a workspace whose kiro texts cite only rules kiro writes or names that are no rule, nor for an `AGENTS.md` written with `claude-code` whose links cite only rules `claude-code` writes or names that are neither a rule nor a `steering` file kiro writes — a workspace `craftar import` produced keeps its `.kiro/` bytes, and its `AGENTS.md` bytes unless a link's text names one of its hand-written steering files. An `AGENTS.md` written without `claude-code` updates wherever a link's text holds a `.claude/rules/.md` reference, known rule or not. In a kiro file, a link's text that cites an unknown name now adds an entry to the `kiro:` warning; its bytes do not change, except in a link kept for `claude-code`, where that name moves to `.kiro/steering/`. +- An affected workspace shows `AGENTS.md` or the `.kiro/` files as `update`, and `craftar sync --check` exits 1 until it syncs. + ### to 0.8.5 - **The kiro target stops turning a reference to a rule it does not write into a `.kiro/steering/` path that does not exist.** Only files under `.kiro/` change; no other generated file and no lock field. diff --git a/package-lock.json b/package-lock.json index a3cb02d..bbc0745 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.5", + "version": "0.8.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.5", + "version": "0.8.6", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 3487231..37a0f5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.8.5", + "version": "0.8.6", "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 e4f5fe4..32726e1 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.5"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.6"); /* ---------------------------------------------------------------- import */ program diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index d0997b6..4333d10 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -9,6 +9,9 @@ export function appliesTo(targets: "*" | string[], target: string): boolean { /** 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._-"; +/** Token pattern for rule references: `.claude/rules/.md` with left boundary (spec 15 §4.1). */ +const TOKEN_PATTERN_SOURCE = `(^|[^/${RULE_NAME_CHARS}])(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md)`; + /** * 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. @@ -47,7 +50,7 @@ export function buildRuleLookup(ingredients: ResolvedIngredient[]): RuleLookup { } /** - * Resolve `.claude/rules/.md` references in a body (spec 15 §4.1–§4.3, spec 17 §4.2–§4.3). + * Resolve `.claude/rules/.md` references in a body (spec 15 §4.1–§4.3, spec 17 §4.2–§4.3, spec 20). * Mode "agents-md" (default) is for AGENTS.md; mode "kiro" is for kiro files. * Returns the transformed text and a report of what was reworded or found dead. */ @@ -113,14 +116,84 @@ export function resolveRuleRefs( // Blanket rewrite for kiro mode: .claude/rules/ → .kiro/steering/ const kiroRewrite = (s: string): string => s.replace(/\.claude\/rules\//g, ".kiro/steering/"); + // Resolve a single token reference (spec 20 §4.1): used for tokens and link text references. + // Returns the replacement text and, if it rewords to "not in this workspace", the reworded entry. + const resolveToken = (name: string): { text: string; reworded?: { ref: string; kind: string } } => { + const state = ruleState(name); + const rule = rulesByName.get(name); + if (mode === "kiro") { + switch (state) { + case "K1": return { text: ruleFile("kiro", { name }) }; + case "K2": return { text: `.claude/rules/${name}.md` }; // unchanged + case "K3": return { text: `AGENTS.md (rule: ${name})` }; + case "D": return { text: `${name} (rule not in this workspace)`, reworded: { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` } }; + case "unknown": return { text: ruleFile("kiro", { name }), reworded: { ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND } }; + } + } else { + switch (state) { + case "A": return { text: `.claude/rules/${name}.md` }; // unchanged + case "B": return { text: ruleFile("kiro", { name }) }; + case "C": return { text: `AGENTS.md (rule: ${name})` }; + case "D": return { text: `${name} (rule not in this workspace)`, reworded: { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` } }; + case "unknown": + if (hasCc) return { text: `.claude/rules/${name}.md` }; // unchanged + return { text: `${name} (rule not in this workspace)`, reworded: { ref: `.claude/rules/${name}.md`, kind: "no such rule" } }; + } + } + return { text: `.claude/rules/${name}.md` }; // fallback + }; + + // Collapse: a link whose text is exactly its path becomes the token form (spec 20 §4.2). + // Returns the token form if collapsible, null otherwise. + const collapse = (text: string, name: string): string | null => { + const exactPath = `.claude/rules/${name}.md`; + const backtickedPath = `\`${exactPath}\``; + if (text !== exactPath && text !== backtickedPath) return null; + const hasBackticks = text === backtickedPath; + const tokenText = `${name} (rule not in this workspace)`; + return hasBackticks ? `\`${tokenText}\`` : tokenText; + }; + + // Resolve references inside link text (spec 20 §4.1). Returns the resolved text and any reworded + // entries from the text's references. In kiro mode, non-references get the directory rewrite + // except for K2 links (Ruling 4: non-references stay as written). + const resolveTextRefs = (text: string, isK2: boolean): { resolved: string; reworded: Array<{ offset: number; ref: string; kind: string }> } => { + const textPattern = new RegExp(TOKEN_PATTERN_SOURCE, "g"); + const textMatches: Array<{ offset: number; length: number; replacement: string; reworded?: { ref: string; kind: string } }> = []; + let textMatch; + while ((textMatch = textPattern.exec(text)) !== null) { + const [match, before, token, name] = textMatch as RegExpExecArray & [string, string, string, string]; + const offset = textMatch.index + before.length; + const fullOffset = textMatch.index + match.length; + const after = text.slice(fullOffset); + if (!rightBoundary(after)) continue; + const { text: replacement, reworded } = resolveToken(name); + textMatches.push({ offset, length: token.length, replacement, reworded }); + } + // Build the result: gaps get the blanket rewrite in kiro mode, unless this is a K2 link (Ruling 4) + let resolved = ""; + let lastEnd = 0; + const rewordedEntries: Array<{ offset: number; ref: string; kind: string }> = []; + for (const m of textMatches) { + const gap = text.slice(lastEnd, m.offset); + resolved += (mode === "kiro" && !isK2) ? kiroRewrite(gap) : gap; + resolved += m.replacement; + lastEnd = m.offset + m.length; + if (m.reworded) rewordedEntries.push({ offset: m.offset, ref: m.reworded.ref, kind: m.reworded.kind }); + } + const tail = text.slice(lastEnd); + resolved += (mode === "kiro" && !isK2) ? kiroRewrite(tail) : tail; + return { resolved, reworded: rewordedEntries }; + }; + // Collect all matches from the original body with their offsets. Warning order follows the - // original body (spec 15 §4.5); matches are applied in order, and in kiro mode the gaps between - // them get the directory rewrite. + // original body (spec 15 §4.5, spec 20 §4.3); matches are applied in order, and in kiro mode the + // gaps between them get the directory rewrite. interface Match { offset: number; length: number; replacement: string; - reworded?: { ref: string; kind: string }; + reworded: Array<{ offset: number; ref: string; kind: string }>; } const matches: Match[] = []; @@ -133,35 +206,52 @@ export function resolveRuleRefs( while ((linkMatch = linkPattern.exec(body)) !== null) { const [match, text, name, frag] = linkMatch as RegExpExecArray & [string, string, string, string | undefined]; const offset = linkMatch.index; + // Warning offset is at the target's start, not at `[` (spec 20 §4.3) + const targetOffset = offset + 1 + text.length + 2; // `[` + text + `](` const state = ruleState(name); const rule = rulesByName.get(name); let replacement: string; - let reworded: { ref: string; kind: string } | undefined; + const reworded: Array<{ offset: number; ref: string; kind: string }> = []; + + // Resolve references in the link's text (spec 20 §4.1) + const isK2 = mode === "kiro" && state === "K2"; + const { resolved: resolvedText, reworded: textReworded } = resolveTextRefs(text, isK2); + // Add text reworded entries, positioned before the link's target (spec 20 §4.3) + for (const tw of textReworded) reworded.push({ offset: offset + 1 + tw.offset, ref: tw.ref, kind: tw.kind }); if (mode === "kiro") { // Kiro mode (spec 17 §4.3) switch (state) { case "K1": // Fragment gets the blanket rewrite too (spec 17 §4.3) - replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; + replacement = `[${resolvedText}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; break; case "K2": - // unchanged — exempt from directory rewrite; keep original text exactly - replacement = match; + // unchanged target — but text references are resolved (spec 20 §4.1, Ruling 4) + replacement = `[${resolvedText}](${`.claude/rules/${name}.md`}${frag ?? ""})`; break; case "K3": - replacement = `[${kiroRewrite(text)}](AGENTS.md)`; // fragment dropped + replacement = `[${resolvedText}](AGENTS.md)`; // fragment dropped break; - case "D": - reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; - replacement = `${kiroRewrite(text)} (${name}, rule not in this workspace)`; + case "D": { + // Check for collapse (spec 20 §4.2) + const collapsed = collapse(text, name); + if (collapsed) { + replacement = collapsed; + reworded.length = 0; // Collapsed: only one entry, from the token form + } else { + replacement = `${resolvedText} (${name}, rule not in this workspace)`; + } + reworded.push({ offset: targetOffset, ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }); break; - case "unknown": + } + case "unknown": { // Unknown name gets blanket rewrite and is reported (no kind string) // Fragment gets the blanket rewrite too (spec 17 §4.3) - reworded = { ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND }; - replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; + reworded.push({ offset: targetOffset, ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND }); + replacement = `[${resolvedText}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; break; + } default: continue; } @@ -169,21 +259,51 @@ export function resolveRuleRefs( // agents-md mode (spec 15 §4.3) switch (state) { case "A": - continue; + // State A: link unchanged, but text is now resolved like a token (spec 20 §4.1) + // The text resolution already happened; we only emit a match if the text changed + if (resolvedText !== text) { + replacement = `[${resolvedText}](${`.claude/rules/${name}.md`}${frag ?? ""})`; + } else { + continue; // No change needed + } + break; case "B": - replacement = `[${text}](${ruleFile("kiro", { name })}${frag ?? ""})`; + replacement = `[${resolvedText}](${ruleFile("kiro", { name })}${frag ?? ""})`; break; case "C": - replacement = `[${text}](AGENTS.md)`; + replacement = `[${resolvedText}](AGENTS.md)`; break; - case "D": - reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; - replacement = `${text} (${name}, rule not in this workspace)`; + case "D": { + // Check for collapse (spec 20 §4.2) + const collapsed = collapse(text, name); + if (collapsed) { + replacement = collapsed; + reworded.length = 0; // Collapsed: only one entry, from the token form + } else { + replacement = `${resolvedText} (${name}, rule not in this workspace)`; + } + reworded.push({ offset: targetOffset, ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }); break; + } case "unknown": - if (hasCc) continue; - reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; - replacement = `${text} (${name}, rule not in this workspace)`; + if (hasCc) { + // With claude-code, unknown links are not consumed — but text is resolved (spec 20 §4.1) + if (resolvedText !== text) { + replacement = `[${resolvedText}](${`.claude/rules/${name}.md`}${frag ?? ""})`; + } else { + continue; // No change needed + } + } else { + // Without claude-code: collapse or reword (spec 20 §4.2) + const collapsed = collapse(text, name); + if (collapsed) { + replacement = collapsed; + reworded.length = 0; // Collapsed: clear text entries + } else { + replacement = `${resolvedText} (${name}, rule not in this workspace)`; + } + reworded.push({ offset: targetOffset, ref: `.claude/rules/${name}.md`, kind: "no such rule" }); + } break; default: continue; @@ -193,10 +313,7 @@ export function resolveRuleRefs( } // 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", - ); + const tokenPattern = new RegExp(TOKEN_PATTERN_SOURCE, "g"); let tokenMatch; while ((tokenMatch = tokenPattern.exec(body)) !== null) { const [match, before, token, name] = tokenMatch as RegExpExecArray & [string, string, string, string]; @@ -207,71 +324,22 @@ export function resolveRuleRefs( 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; - - if (mode === "kiro") { - // Kiro mode (spec 17 §4.3) - switch (state) { - case "K1": - replacement = ruleFile("kiro", { name }); - break; - case "K2": - // unchanged — exempt from directory rewrite; keep original text exactly - replacement = token; - break; - case "K3": - 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": - // Unknown name gets blanket rewrite and is reported (no kind string) - reworded = { ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND }; - replacement = ruleFile("kiro", { name }); - break; - default: - continue; - } - } else { - // agents-md mode (spec 15 §4.3) - switch (state) { - case "A": - continue; - 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; - default: - continue; - } - } - matches.push({ offset, length: token.length, replacement, reworded }); + const { text: replacement, reworded } = resolveToken(name); + const rewordedEntries: Array<{ offset: number; ref: string; kind: string }> = []; + if (reworded) rewordedEntries.push({ offset, ref: reworded.ref, kind: reworded.kind }); + // Always add the match even if unchanged (K2, state A, unknown with claude-code) so the token + // is excluded from gap rewriting in kiro mode + matches.push({ offset, length: token.length, replacement, reworded: rewordedEntries }); } // Sort by offset for correct warning order matches.sort((a, b) => a.offset - b.offset); - // Collect reworded entries with their original offsets for sorting (spec 15 §4.5) + // Collect reworded entries with their original offsets for sorting (spec 15 §4.5, spec 20 §4.3) 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 }); + for (const rw of m.reworded) { + rewordedWithOffset.push({ offset: rw.offset, ref: rw.ref, citing, kind: rw.kind }); } } diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index e838036..e4db0c8 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -552,3 +552,94 @@ describe("agents-md emitter — rule references in bodies (spec 15)", () => { ]); }); }); + +describe("agents-md emitter — references inside link text (spec 20)", () => { + const LINES = [ + "1 [`.claude/rules/cc.md`](.claude/rules/cc.md)", + "2 [.claude/rules/cc.md](.claude/rules/cc.md#part)", + "3 [`.claude/rules/k.md`](.claude/rules/k.md)", + "4 [see .claude/rules/cc.md](.claude/rules/a.md)", + "5 [.claude/rules/md.md](.claude/rules/md.md)", + "6 [see .claude/rules/a.md](.claude/rules/cc.md)", + "7 [see .claude/rules/*.md](.claude/rules/cc.md)", + "8 [see .claude/rules/cc.md](.claude/rules/cc.md)", + "9 [.claude/rules/cc.md#part](.claude/rules/cc.md#part)", + "10 [.claude/rules/zz.md](.claude/rules/zz.md)", + ]; + const forge20 = (): IngredientSpec[] => [ + rule("hub", "# hub\n\n" + LINES.join("\n") + "\n"), + rule("a", "# a\n"), + rule("cc", "# cc\n", { targets: ["claude-code"] }), + rule("md", "# md\n", { inclusion: "manual", targets: ["agents-md"] }), + rule("k", "# k\n", { inclusion: "manual", targets: ["kiro", "agents-md"] }), + ]; + const numbered = (p: Plan) => agentsMd(p)!.split("\n").filter((l) => /^\d+ /.test(l)); + const amw = (p: Plan) => p.warnings.filter((w) => w.startsWith("agents-md:")); + const D = (x: string) => `${x} (rule not in this workspace)`; + + it("agents-md only: link text resolved like a token, dead self-links collapse (spec 20 §4.4)", async () => { + const p = await planFor(forge20()); + expect(numbered(p)).toEqual([ + "1 `" + D("cc") + "`", + "2 " + D("cc"), + "3 [`AGENTS.md (rule: k)`](AGENTS.md)", + "4 [see " + D("cc") + "](AGENTS.md)", + "5 [AGENTS.md (rule: md)](AGENTS.md)", + "6 see AGENTS.md (rule: a) (cc, rule not in this workspace)", + "7 see .claude/rules/*.md (cc, rule not in this workspace)", + "8 see " + D("cc") + " (cc, rule not in this workspace)", + "9 " + D("cc") + "#part (cc, rule not in this workspace)", + "10 " + D("zz"), + ]); + expect(amw(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/cc.md (in rule/hub; rule/cc reaches no target here), .claude/rules/zz.md (in rule/hub; no such rule)", + ]); + }); + + it("kiro + agents-md: link text follows the rule it names (spec 20 §4.4)", async () => { + const p = await planFor(forge20(), undefined, ["kiro", "agents-md"]); + expect(numbered(p)).toEqual([ + "1 `" + D("cc") + "`", + "2 " + D("cc"), + "3 [`.kiro/steering/k.md`](.kiro/steering/k.md)", + "4 [see " + D("cc") + "](.kiro/steering/a.md)", + "5 [AGENTS.md (rule: md)](AGENTS.md)", + "6 see .kiro/steering/a.md (cc, rule not in this workspace)", + "7 see .claude/rules/*.md (cc, rule not in this workspace)", + "8 see " + D("cc") + " (cc, rule not in this workspace)", + "9 " + D("cc") + "#part (cc, rule not in this workspace)", + "10 " + D("zz"), + ]); + }); + + it("claude-code + agents-md: only the text of a consumed link moves (spec 20 §4.1)", async () => { + const p = await planFor(forge20(), undefined, ["claude-code", "agents-md"]); + const before = LINES.slice(); + before[2] = "3 [`AGENTS.md (rule: k)`](AGENTS.md)"; + before[4] = "5 [AGENTS.md (rule: md)](AGENTS.md)"; + expect(numbered(p)).toEqual(before); + expect(amw(p)).toEqual([]); + }); + + it("a reference in a link's text comes before the link's target in the warning (spec 20 §4.3)", async () => { + const p = await planFor([rule("hub", "# hub\n\n[see .claude/rules/zz.md](.claude/rules/cc.md)\n"), rule("cc", "# cc\n", { targets: ["claude-code"] })]); + expect(amw(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/zz.md (in rule/hub; no such rule), .claude/rules/cc.md (in rule/hub; rule/cc reaches no target here)", + ]); + }); + + it("pin: a reference-style definition and a link with a title stay tokens (spec 15 §12.6)", async () => { + const p = await planFor([rule("hub", '# hub\n\n1 [x]: .claude/rules/cc.md\n2 [t](.claude/rules/cc.md "T")\n'), rule("cc", "# cc\n", { targets: ["claude-code"] })]); + expect(numbered(p)).toEqual(["1 [x]: " + D("cc"), '2 [t](' + D("cc") + ' "T")']); + }); + + it("pin: an imported-shape Forge keeps its AGENTS.md (spec 20 §5)", async () => { + const p = await planFor( + [rule("hub", "# hub\n\n1 [see .claude/rules/b.md](.claude/rules/a.md)\n2 [`.claude/rules/a.md`](.claude/rules/a.md)\n3 [t](.claude/rules/zz.md)\n"), rule("a", "# a\n"), rule("b", "# b\n")], + undefined, + ["claude-code", "kiro", "agents-md"], + ); + expect(numbered(p)).toEqual(["1 [see .claude/rules/b.md](.claude/rules/a.md)", "2 [`.claude/rules/a.md`](.claude/rules/a.md)", "3 [t](.claude/rules/zz.md)"]); + expect(amw(p)).toEqual([]); + }); +}); diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index cf4b483..d772f8b 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -339,4 +339,99 @@ describe("kiro emitter — rule references (spec 17)", () => { const p = await planFor([rule("hub", "# hub\n\n[t](.claude/rules/style.md#see-.claude/rules/z) [u](.claude/rules/nope.md#see-.claude/rules/z)\n"), rule("style", "# style\n")]); expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["[t](.kiro/steering/style.md#see-.kiro/steering/z) [u](.kiro/steering/nope.md#see-.kiro/steering/z)"])); }); -}); \ No newline at end of file +}); + +describe("kiro emitter — references inside link text (spec 20)", () => { + const LINES = [ + "1 [`.claude/rules/cc.md`](.claude/rules/cc.md)", + "2 [.claude/rules/cc.md](.claude/rules/cc.md#part)", + "3 [`.claude/rules/k.md`](.claude/rules/k.md)", + "4 [see .claude/rules/cc.md](.claude/rules/a.md)", + "5 [.claude/rules/md.md](.claude/rules/md.md)", + "6 [see .claude/rules/a.md](.claude/rules/cc.md)", + "7 [see .claude/rules/*.md](.claude/rules/cc.md)", + "8 [see .claude/rules/cc.md](.claude/rules/cc.md)", + "9 [.claude/rules/cc.md#part](.claude/rules/cc.md#part)", + "10 [.claude/rules/zz.md](.claude/rules/zz.md)", + ]; + const forge20 = (): IngredientSpec[] => [ + rule("hub", "# hub\n\n" + LINES.join("\n") + "\n"), + rule("a", "# a\n"), + rule("cc", "# cc\n", { targets: ["claude-code"] }), + rule("md", "# md\n", { inclusion: "manual", targets: ["agents-md"] }), + rule("k", "# k\n", { inclusion: "manual", targets: ["kiro", "agents-md"] }), + ]; + const numbered = (p: Plan) => file(p, ".kiro/steering/hub.md")!.content.toString("utf8").replace(/\r\n/g, "\n").split("\n").filter((l) => /^\d+ /.test(l)); + const kw = (p: Plan) => p.warnings.filter((w) => w.startsWith("kiro:")); + const D = (x: string) => `${x} (rule not in this workspace)`; + + it("kiro only (spec 20 §4.4)", async () => { + const p = await planFor(forge20()); + expect(numbered(p)).toEqual([ + "1 `" + D("cc") + "`", + "2 " + D("cc"), + "3 [`.kiro/steering/k.md`](.kiro/steering/k.md)", + "4 [see " + D("cc") + "](.kiro/steering/a.md)", + "5 " + D("md"), + "6 see .kiro/steering/a.md (cc, rule not in this workspace)", + "7 see .kiro/steering/*.md (cc, rule not in this workspace)", + "8 see " + D("cc") + " (cc, rule not in this workspace)", + "9 " + D("cc") + "#part (cc, rule not in this workspace)", + "10 [.kiro/steering/zz.md](.kiro/steering/zz.md)", + ]); + expect(kw(p)).toEqual([ + "kiro: 2 reference(s) to rule files this workspace does not have — reworded: .claude/rules/cc.md (in rule/hub; rule/cc reaches no target here), .claude/rules/md.md (in rule/hub; rule/md reaches no target here); 1 reference(s) to names that are no rule or steering here — rewritten to .kiro/steering/ as before: .claude/rules/zz.md (in rule/hub)", + ]); + }); + + it("claude-code + kiro: a K2 link keeps its non-references, its text references resolve (spec 20 §4.1, Ruling 4)", async () => { + const p = await planFor(forge20(), {}, ["claude-code", "kiro"]); + expect(numbered(p)).toEqual([ + "1 [`.claude/rules/cc.md`](.claude/rules/cc.md)", + "2 [.claude/rules/cc.md](.claude/rules/cc.md#part)", + "3 [`.kiro/steering/k.md`](.kiro/steering/k.md)", + "4 [see .claude/rules/cc.md](.kiro/steering/a.md)", + "5 " + D("md"), + "6 [see .kiro/steering/a.md](.claude/rules/cc.md)", + "7 [see .claude/rules/*.md](.claude/rules/cc.md)", + "8 [see .claude/rules/cc.md](.claude/rules/cc.md)", + "9 [.claude/rules/cc.md#part](.claude/rules/cc.md#part)", + "10 [.kiro/steering/zz.md](.kiro/steering/zz.md)", + ]); + }); + + it("claude-code + kiro + agents-md: K3 text (spec 20 §4.4)", async () => { + const p = await planFor(forge20(), {}, ["claude-code", "kiro", "agents-md"]); + expect(numbered(p)[4]).toBe("5 [AGENTS.md (rule: md)](AGENTS.md)"); + }); + + it("pin: an imported-shape Forge keeps its kiro bytes and warning (spec 20 §5)", async () => { + const p = await planFor( + [rule("hub", "# hub\n\n1 [see .claude/rules/b.md](.claude/rules/a.md)\n2 [`.claude/rules/a.md`](.claude/rules/a.md)\n3 [t](.claude/rules/zz.md)\n"), rule("a", "# a\n"), rule("b", "# b\n")], + {}, + ["claude-code", "kiro", "agents-md"], + ); + expect(numbered(p)).toEqual(["1 [see .kiro/steering/b.md](.kiro/steering/a.md)", "2 [`.kiro/steering/a.md`](.kiro/steering/a.md)", "3 [t](.kiro/steering/zz.md)"]); + expect(kw(p)).toEqual(["kiro: 1 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/zz.md (in rule/hub)"]); + }); + + it("a reference in a link's text is reported before the link's target (spec 20 §4.3)", async () => { + const p = await planFor([rule("hub", "# hub\n\n1 [see .claude/rules/yy.md](.claude/rules/zz.md)\n")]); + expect(numbered(p)).toEqual(["1 [see .kiro/steering/yy.md](.kiro/steering/zz.md)"]); + expect(kw(p)).toEqual([ + "kiro: 2 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/yy.md (in rule/hub), .claude/rules/zz.md (in rule/hub)", + ]); + }); + + it("an imported-shape Forge keeps its kiro bytes; an unknown name in link text adds one entry (spec 20 §5)", async () => { + const p = await planFor([rule("hub", "# hub\n\n1 [see .claude/rules/zz.md](.claude/rules/a.md)\n"), rule("a", "# a\n")], {}, ["claude-code", "kiro", "agents-md"]); + expect(numbered(p)).toEqual(["1 [see .kiro/steering/zz.md](.kiro/steering/a.md)"]); + expect(kw(p)).toEqual(["kiro: 1 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/zz.md (in rule/hub)"]); + }); + + it("an unknown name in the text of a link kept for claude-code is rewritten and reported (spec 20 §4.1)", async () => { + const p = await planFor([rule("hub", "# hub\n\n1 [see .claude/rules/zz.md](.claude/rules/cc.md)\n"), rule("cc", "# cc\n", { targets: ["claude-code"] })], {}, ["claude-code", "kiro"]); + expect(numbered(p)).toEqual(["1 [see .kiro/steering/zz.md](.claude/rules/cc.md)"]); + expect(kw(p)).toEqual(["kiro: 1 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/zz.md (in rule/hub)"]); + }); +});