diff --git a/README.md b/README.md index 0fa8e69..5281511 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,14 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | Command | What it does | |---|---| | `craftar import --from claude-code --forge --profile [--workspace .] [--write-config]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml`. A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created|edited|unchanged` lines. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. | -| `craftar status` | Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in a file not every target renders as text (copied as they are). | -| `craftar sync` | Renders sections (no rendered body holds a marker; a file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock`. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default). | +| `craftar status` | Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in an emitted file not every target renders as text (copied as they are). | +| `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock`. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default). | | `craftar diff [path]` | Line diff between disk and what the Forge would generate. | | `craftar explain ` | Which ingredient, recipe chain, target and origin produced a file and, for an ingredient with sections, which layer filled each one: `sections flavors (profile globex), extra (default)` (`default`, `profile

` or `workspace`). `AGENTS.md` gets no sections line: it is not one ingredient. | | `craftar ls` | Recipes and ingredients resolved for this workspace. | | `craftar forge variants [--forge

\| --workspace ] [--json]` | Lists ingredients that have variants, nearest first, with the profile each came from, its distance to the base and its hunks counted by suggested class (`[1 evolution · 2 block]`), then any variant whose base is missing from the Forge. Read-only; exits 0 either way. `--json` prints `{groups, orphans}`; each variant carries `classes: {evolution, value, block}`, which add up to `distance.hunks`. | | `craftar forge diff [--against ] [--forge \| --workspace ] [--json]` | Shows the differences between a base ingredient and each of its variants: a header carrying the same distance `forge variants` reports, then hunk by hunk, each with a suggested class and reason — `evolution` (one side is newer text), `value` (an identifier-like token swapped in shared prose, with a suggested `param.`) or `block` (lines only one side has) — then the files that exist on only one side. A suggestion never decides anything. It compares the raw Forge text, section marker lines included, so a base with markers shows them as hunks against a variant that has none. Read-only. `--json` prints an array of `{ref, profile, distance, diff}`; each hunk carries `suggestion: {class, reason, tokens?}` next to its `kind`. | -| `craftar forge unify --profile

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

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

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

\| --workspace ] [--json]` | Resolves one variant back into its base, hunk by hunk. `--save-plan` writes a reviewable plan with every decision set to `keep`, each hunk annotated with its suggested class (which `--plan` ignores) and, for a `value` hunk, a pre-filled `params` list (`token` → suggested `key`); on a `block` hunk, a pre-filled `section: { name }` from the Markdown heading above (or `section-`); and on a hunk touching a section the base already has, that section's name. It refuses a path that resolves inside the Forge (after `..` segments and symlinks) and a path that already exists, so it never overwrites a Forge file or a plan you already edited; it writes nothing else. A hunk set to `take: param` turns each listed token into `{{key}}` in the base, declares the base's text as the key's default in the base's `ingredient.yaml` and writes the variant's text into the variant profile's `profile.yaml` `params` — only after proving that both the base's and the variant's text render back exactly, and only when the same plan resolves the variant; it refuses (with the Forge untouched) a key another layer, profile or ingredient already uses, a variant another profile also uses, a whitespace-only difference, and a YAML file that would not round-trip unchanged. A hunk set to `take: section` (with `section: { name, lines? }`) wraps the base's lines in section markers as the default and writes the variant's lines into the variant profile's `sections` — consecutive hunks with one name form one section, `lines: "-"` widens it over equal base lines, and hunks inside a section the base already has only write the profile value; it is proved (both sides render back exactly) and needs the same plan to resolve the variant; `take: param` and `take: section` may share a plan, never a hunk. It refuses (Forge untouched) a `take: param` or `take: section` on a file no target emits, a variant that holds markers, a profile that already sets that section (another profile for a new section, or the variant's profile with other content), a span that cuts a hunk or crosses another section, a span that covers a hunk outside the run (including `take: base` on a hunk inside a section the plan fills), a hunk mixing lines inside and outside an existing section, and a span reaching a missing final newline; when it adds a section to a Forge still at `schema: 1` it sets `schema: 2` in `craftar.forge.yaml` in place (refused if the file does not round-trip). `--plan` applies an edited plan, refusing when it is not for this exact ingredient/profile or either side's fingerprint moved since it was saved. `--take base\|variant` resolves every decision to that side. Writes the merged base and, once every difference is resolved, removes the variant and rewrites every recipe reference to it (`ingredients`) to name the base. It never deletes a recipe and never edits an `extends`; it edits a profile only to write the values of a `take: param` or `take: section` extraction into the variant's own profile, and otherwise never: a `--` left identical to `` is reported as a warning, to be removed by hand after repointing the lists that name it — unify does not, because a workspace or an `extends` chain may also name `` and the recipe order or param precedence would change. Unify cannot reach workspaces, so a removed variant is reported as a warning for any `craftar.yaml` that disables it in `overrides.ingredients.disable`. Every recipe rewrite is checked before the first write, so one that cannot land (a reference behind a YAML alias) is refused with the Forge untouched; a failure after writing began (an I/O error, a locked file) names the paths already touched and the `git checkout` / `git clean` commands that undo them. A merge never adds, removes or changes a section marker other than the sections the plan declares; a plan or `--take variant` that would is refused before the first write — take `base` for the marker lines, or `take: section` to fill the section; the citation checks of `take: param` also read the profiles' section values, which can cite `{{key}}`. `ingredient.yaml` is never merged: when the two sides' metadata differs (beyond `name`, `as` and `origin`), the variant stays unresolved and the differing fields are named — edit it by hand, or `--take base` to discard the variant, metadata included. Requires a clean git checkout in the Forge with at least one commit (`--save-plan` excepted) — the Forge has no lock, so git is the undo. It also refuses when any path it would overwrite or delete (the base, the variant, the recipe files it rewrites, the profile a parameter or section extraction edits, `craftar.forge.yaml` when it is bumped) is ignored, untracked, modified, or flagged skip-worktree or assume-unchanged in the index, since git could not restore it. `--json` prints `{base, profile, resolved, written, removed, unresolved, variantRemoved, recipes: {rewritten, identicalToSibling}, metaDiffers, params, profileEdited, sections, manifestEdited, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. | All commands take `--workspace ` (default: current directory); the `forge` commands also take `--forge ` as an alternative to it. @@ -120,7 +120,7 @@ server: timeout: 30 ``` -**Sections.** A body (`rule.md`, `agent.md`, `command.md`, `steering.md`, `SKILL.md`, a script or hook text file, a skill file every target renders as text) can hold blocks a profile or a workspace replaces. A block sits between two marker lines, and its content is the default: +**Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every `.md`, `.txt`, `.json`, `.yaml` or `.yml` file of a dir-layout skill; the text files listed in `files` of a script or hook — a body file stays inside the ingredient directory; one declared outside it, or behind a symlinked directory, or spelled differently from the file on disk (letter case, on a case-insensitive file system), fails `sync`, `status`, `diff`, `explain` and `ls`, naming it) can hold blocks a profile or a workspace replaces. A file no target emits (a `notes.md` beside `rule.md`) is ignored — for sections and for `{{param}}` citations alike; a file a target emits but does not render as text is copied (see below). A block sits between two marker lines, and its content is the default: ```markdown Dispatch reviewers after every commit. @@ -134,7 +134,7 @@ Dispatch reviewers after every commit. Never edit what a reviewer reads. ``` -A marker is a whole line starting at column 0, with single spaces (trailing spaces or tabs are tolerated). Sections do not nest, a name is declared once per ingredient, and a line that looks almost like a marker is an error. An indented marker is plain text — indent it to show the syntax in a rule. The parser is line-based and not Markdown-aware, so a column-0 marker inside a fenced block is still a marker. Markers are read only in files every target renders as text; elsewhere they are copied as they are, with a warning. Agent and command frontmatter lives in `ingredient.yaml` and is never expanded; a rule's or skill's frontmatter is part of its body file, so a column-0 marker there is read like any other. A Forge whose bodies hold a marker must declare `schema: 2` in `craftar.forge.yaml`, so that craftar 0.6.2 and older refuse it instead of emitting the markers; `import` sets it when it relies on markers. A section can also be extracted from a variant with `forge unify` (`take: section`), not only added by hand and re-imported. +A marker is a whole line starting at column 0, with single spaces (trailing spaces or tabs are tolerated). Sections do not nest, a name is declared once per ingredient, and a line that looks almost like a marker is an error. An indented marker is plain text — indent it to show the syntax in a rule. The parser is line-based and not Markdown-aware, so a column-0 marker inside a fenced block is still a marker. Markers are read only in body files; in a file a target emits but does not render as text (a skill's `.sh`, an image) they are copied as they are, with a warning; in a file no target emits they are ignored. Agent and command frontmatter lives in `ingredient.yaml` and is never expanded; a rule's or skill's frontmatter is part of its body file, so a column-0 marker there is read like any other. A Forge whose bodies hold a marker must declare `schema: 2` in `craftar.forge.yaml`, so that craftar 0.6.2 and older refuse it instead of emitting the markers; `import` sets it when it relies on markers. A section can also be extracted from a variant with `forge unify` (`take: section`), not only added by hand and re-imported. Profile `profile.yaml`, with section values keyed by `/` (the output name: a variant's values are its base's), then by section name: @@ -178,7 +178,7 @@ overrides: ingredients: { disable: [] } ``` -Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder with no value in any layer is left untouched and reported as a warning by `status` and `sync` (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. +Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder in a file no target emits is never rendered, and only body files count as a citation for `forge unify` and `import`; a placeholder with no value in any layer is left untouched and reported as a warning by `status` and `sync` (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. Sections layer the same way, weakest → strongest: the body's default → the profile's `sections` → `craftar.yaml`'s `overrides.sections` → `craftar.local.yaml`'s, merged per section, so a workspace that sets one section of an ingredient keeps the profile's others. There is no recipe layer for sections. @@ -241,6 +241,11 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ## Upgrading +### to 0.8.2 + +- **A file no target emits (e.g. notes beside `rule.md`) is no longer read for sections or `{{param}}` citations** — a malformed marker there no longer fails `sync`/`status`/`diff`/`explain`/`ls`/`import` or trips the `schema: 1` gate, and a `{{key}}` there no longer makes `unify`/`import` refuse. No emitted byte changes. +- **`take: param` and `take: section` on such a file are now refused** (`… is not emitted by any target`). 0.8.1 accepted both there, so a plan saved under 0.8.1 that extracts from such a file no longer applies. A Forge that already ran such a `take: section` under 0.8.1 has markers in that file and a value in the profile's `sections`; 0.8.2 ignores those markers and warns on every `sync` and `status` that the profile sets a section with no such marker — delete the value from the profile, or move the section into the body file. A `take: param` run under 0.8.1 on such a file left a default in `ingredient.yaml` and a value in the profile's `params`; both are inert now — remove them by hand. + ### to 0.8.1 - **No behaviour change.** This is a documentation and test-only release. diff --git a/package-lock.json b/package-lock.json index 73a190e..5a5a650 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.1", + "version": "0.8.2", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 6b16438..c1ff576 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.8.1", + "version": "0.8.2", "description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.", "license": "MIT", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index b0e56da..d00b8cb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,7 @@ import { HUNK_CLASSES, UnifyPlanSchema, type HunkClass, type HunkSuggestion, typ process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE") process.exit(0); }); const program = new Command(); -program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.1"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.2"); /* ---------------------------------------------------------------- import */ program diff --git a/src/core/extract.ts b/src/core/extract.ts index 2a97154..7c456a6 100644 --- a/src/core/extract.ts +++ b/src/core/extract.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { Ingredient, PlanParam } from "../schema/index.js"; import { changedRegions } from "./classify.js"; import { TEXT_EXT } from "../emitters/claude-code.js"; @@ -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) { @@ -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 `/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 { return text.replace(PLACEHOLDER, (m, key: string) => (values.has(key) ? values.get(key)! : m)); diff --git a/src/core/param-writes.ts b/src/core/param-writes.ts index 928bfc8..3b7e023 100644 --- a/src/core/param-writes.ts +++ b/src/core/param-writes.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; import { IngredientSchema, ProfileSchema, WorkspaceConfigSchema } from "../schema/index.js"; -import { placeholders, substitutedFile, type Extraction } from "./extract.js"; +import { placeholders, bodyFile, type Extraction } from "./extract.js"; import { exists, listFiles, readIngredientText, FORGE_MANIFEST, type Forge, type LoadedIngredient } from "./forge.js"; import { manifestWithSections } from "./manifest-edit.js"; import { resolve, sectionKey } from "./resolve.js"; @@ -67,10 +67,10 @@ export function resolvedBy(forge: Forge, profile: string): Set { return new Set(r.ingredients.map((i) => i.ref)); } -/** Whether any file an ingredient reads through `ctx.text` cites `{{key}}`. */ +/** Whether any body file an ingredient reads through `ctx.text` cites `{{key}}`. */ async function cites(ing: LoadedIngredient, key: string): Promise { for (const rel of await listFiles(ing.dir)) { - if (rel === "ingredient.yaml" || !substitutedFile(ing.meta, rel)) continue; + if (rel === "ingredient.yaml" || !bodyFile(ing.meta, rel, ing.dir)) continue; if (placeholders(await readIngredientText(ing, rel)).includes(key)) return true; } return false; diff --git a/src/core/section-extract.ts b/src/core/section-extract.ts index 83622ee..4cd9baf 100644 --- a/src/core/section-extract.ts +++ b/src/core/section-extract.ts @@ -107,7 +107,7 @@ export function deriveSections(args: { variantText: string; hunks: Hunk[]; // the file's hunks, from diffIngredients (1-based numbering = index + 1) entries: PlanHunk[]; // the plan's hunk entries for this file (any take) - declaredElsewhere: Map; // section names declared in the base's OTHER admitted files → "file:line" + declaredElsewhere: Map; // section names declared in the base's OTHER body files → "file:line" }): { runs: SectionRun[]; markers: MarkerInsertion[] } { const { file, label, ref, baseText, variantText, hunks, entries, declaredElsewhere } = args; const A = splitLines(baseText); @@ -771,7 +771,7 @@ export function prefillSections(args: { label: string; ref: string; hunks: HunkWithSuggestion[]; - /** Every section name the ingredient already declares, in any admitted file. */ + /** Every section name the ingredient already declares, in any body file. */ declared: Set; }): Array { const { baseText, label, ref, hunks, declared } = args; diff --git a/src/core/sync.ts b/src/core/sync.ts index bf2aa8b..16a773f 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -6,7 +6,7 @@ import { resolve, substitute, type Resolution, type ResolvedIngredient, paramsFo import { hashNormalized, stripBom, toLf } from "./text.js"; import { deepMerge } from "./merge.js"; import { canonicalValue, checkDeclaredOnce, expandSections, firstMarkerLine, markerLine, parseSections, type ParsedSections } from "./sections.js"; -import { placeholders, substitutedFile } from "./extract.js"; +import { placeholders, bodyFile, emittedFile } from "./extract.js"; import { LockSchema, WorkspaceConfigSchema, type Lock, type LockEntry, type Target, type WorkspaceConfig } from "../schema/index.js"; import { claudeCode } from "../emitters/claude-code.js"; import { kiro } from "../emitters/kiro.js"; @@ -91,7 +91,7 @@ function layerLabel(resolution: Resolution, layer: SectionLayer): string { } /** - * The section pass of `plan()` (spec 11 §6.6 steps 1–5): parse every admitted file of every resolved + * The section pass of `plan()` (spec 11 §6.6 steps 1–5): parse every body file of every resolved * ingredient (a malformed marker throws, whichever targets resolve), warn on marker lines in files * not every target renders, fail a `schema: 1` Forge that holds a marker (Ruling 7/21), and warn on * section values that apply to nothing. @@ -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 }> = []; @@ -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): void { @@ -206,10 +207,15 @@ export async function plan(ws: Workspace): Promise { const raw = await fs.readFile(abs, "utf8"); const missing = new Set(); const params = paramsFor(ing, resolution); - // Sections first, then params (spec 11 §6.4), in admitted files only (§6.5). - const parsed = substitutedFile(ing.meta, file) ? sections.parsed.get(abs) : null; - // Every admitted file of a resolved ingredient was parsed and gated in the section pass; a miss here would skip the schema gate. - 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); diff --git a/src/core/template-import.ts b/src/core/template-import.ts index 047ac9f..3cbe51e 100644 --- a/src/core/template-import.ts +++ b/src/core/template-import.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { IngredientSchema, type Ingredient } from "../schema/index.js"; -import { placeholders, substitutedFile } from "./extract.js"; +import { placeholders, bodyFile } from "./extract.js"; import { fingerprintOf, type DirReader } from "./fingerprint.js"; import { parseYaml } from "./forge.js"; import { substitute } from "./resolve.js"; @@ -25,18 +25,18 @@ export function renderMap(meta: Ingredient, profileParams: Record; bytes: Map; metaFile: string; - /** Each admitted text parsed for section markers (spec 11 §6.7); a file with none is one outside segment. */ + /** Each body file parsed for section markers (spec 11 §6.7); a file with none is one outside segment. */ parsed: Map; } /** - * Read a base. Every admitted text is parsed for section markers; a malformed one, or a name + * Read a base. Every body file is parsed for section markers; a malformed one, or a name * declared twice across the ingredient's files, is I12 (spec 11 §4.5). `forgeRoot`, when given, * makes the file the error names Forge-relative. */ @@ -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}`)); @@ -64,12 +64,12 @@ export async function readBase(dir: string, io: DirReader, forgeRoot?: string): return { meta, texts, bytes, metaFile, parsed }; } -/** The names of the sections a base declares, across its admitted files. */ +/** The names of the sections a base declares, across its body files. */ export function sectionNames(base: ImportBase): string[] { return [...base.parsed.values()].flatMap((p) => p.sections.map((s) => s.name)); } -/** The base's admitted texts with their sections expanded by `values` (spec 11 §6.2) — what sync substitutes into. */ +/** The base's body files with their sections expanded by `values` (spec 11 §6.2) — what sync substitutes into. */ export function expandedTexts(base: ImportBase, values: Record = {}): Map { const out = new Map(); for (const [rel, text] of base.texts) { @@ -79,7 +79,7 @@ export function expandedTexts(base: ImportBase, values: Record = return out; } -/** The keys every admitted file of a base cites (`C(X)`); pass `expandedTexts` for what the importing profile renders (spec 11 §6.7). */ +/** The keys every body file of a base cites (`C(X)`); pass `expandedTexts` for what the importing profile renders (spec 11 §6.7). */ export function citedKeys(texts: Map): Set { const out = new Set(); for (const t of texts.values()) for (const k of placeholders(t)) out.add(k); @@ -88,7 +88,7 @@ export function citedKeys(texts: Map): Set { /** * The fingerprint of a base rendered through `map` (spec 10 §6.2): its metadata without `params` - * (a workspace cannot express a declaration), admitted files expanded with the section values + * (a workspace cannot express a declaration), body files expanded with the section values * `sections` and then substituted, exactly as `ctx.text` does (spec 11 §6.4, §6.7), every other file * as bytes. With no declaration, no marker and nothing set, this is `fingerprintDir`. */ diff --git a/src/core/unify.ts b/src/core/unify.ts index f34c7c3..b828c1f 100644 --- a/src/core/unify.ts +++ b/src/core/unify.ts @@ -8,7 +8,7 @@ import { splitLines, type Hunk } from "./diff.js"; import { detectEol, withEol, type Eol } from "./text.js"; import type { IngredientDiff } from "./variants.js"; import type { HunkTake, Ingredient, IngredientRef, Recipe, UnifyPlan, PlanFile, PlanHunk } from "../schema/index.js"; -import { collect, deriveHunk, prove, substitutedFile, type Extraction } from "./extract.js"; +import { collect, deriveHunk, prove, substitutedFile, bodyFile, emittedFile, type Extraction } from "./extract.js"; import { firstMarkerLine, parseSections, SectionMarkerError } from "./sections.js"; import { sectionKey } from "./resolve.js"; import { deriveSections, prefillSections, proveSections, type MarkerInsertion, type SectionRun } from "./section-extract.js"; @@ -78,13 +78,13 @@ export async function planFrom( ): Promise { await assertTextMergeable(base, variant); - // Build `declared`: every section name the ingredient already declares, in any admitted file (spec 12 §4.2). + // Build `declared`: every section name the ingredient already declares, in any body file (spec 12 §4.2, 0.8.2). const declared = new Set(); const baseFiles = await listFiles(base.dir); const label = (rel: string) => ["ingredients", path.basename(path.dirname(base.dir)), path.basename(base.dir), rel].join("/"); for (const rel of baseFiles) { if (rel === "ingredient.yaml") continue; - if (!substitutedFile(base.meta, rel)) continue; + if (!bodyFile(base.meta, rel, base.dir)) continue; try { const text = await readIngredientText(base, rel); const parsed = parseSections(text, label(rel), base.ref); @@ -97,9 +97,9 @@ export async function planFrom( const files: PlanFile[] = []; for (const f of diff.files) { - // Pre-fill section names only when the file is admitted (substitutedFile) (spec 12 §4.2) + // Pre-fill section names only when the file is a body file (spec 12 §4.2, 0.8.2) let sectionNames: Array = []; - if (substitutedFile(base.meta, f.file)) { + if (bodyFile(base.meta, f.file, base.dir)) { try { const baseText = await readIngredientText(base, f.file); sectionNames = prefillSections({ @@ -365,14 +365,14 @@ export async function applyPlan( } } - // S12 first part: When the plan has at least one section hunk, every admitted file of the VARIANT + // S12 first part: When the plan has at least one section hunk, every body file of the VARIANT // (not only the files with runs) is checked for markers. const planHasSectionHunk = plan.files.some((pf) => pf.hunks?.some((h) => h.take === "section")); if (planHasSectionHunk) { const variantFiles = await listFiles(variant.dir); for (const rel of variantFiles) { if (rel === "ingredient.yaml") continue; - if (!substitutedFile(variant.meta, rel)) continue; + if (!bodyFile(variant.meta, rel, variant.dir)) continue; const variantText = await readIngredientText(variant, rel); const markerLine = firstMarkerLine(toLf(stripBom(variantText))); if (markerLine !== null) { @@ -383,7 +383,7 @@ export async function applyPlan( } } - // Build declaredElsewhere: section names declared in the base's OTHER admitted files. + // Build declaredElsewhere: section names declared in the base's OTHER body files. // This is needed for S6 (new section name already declared). const baseFiles = await listFiles(base.dir); const label = (rel: string) => ["ingredients", path.basename(path.dirname(base.dir)), path.basename(base.dir), rel].join("/"); @@ -426,7 +426,12 @@ export async function applyPlan( if (sectionOf.size > 0) { filesWithSectionHunks.add(pf.file); - // S2: the file must be substituted + // S2: the file must be emitted and substituted (0.8.2) + if (!emittedFile(base.meta, pf.file, base.dir)) { + throw new Error( + `unify plan: "${pf.file}" is not emitted by any target — a section there would never render`, + ); + } if (!substitutedFile(base.meta, pf.file)) { throw new Error( `unify plan: "${pf.file}" is copied without expansion by a target that emits it — a section marker there would be emitted literally`, @@ -437,7 +442,7 @@ export async function applyPlan( const declaredElsewhere = new Map(); for (const otherFile of baseFiles) { if (otherFile === "ingredient.yaml" || otherFile === pf.file) continue; - if (!substitutedFile(base.meta, otherFile)) continue; + if (!bodyFile(base.meta, otherFile, base.dir)) continue; const otherText = await readIngredientText(base, otherFile); const parsed = parseSections(otherText, label(otherFile), base.ref); for (const s of parsed.sections) { @@ -537,6 +542,10 @@ export async function applyPlan( // entirely rather than round-tripping the base through `splitLines`/`withEol` for nothing, // which would re-terminate a base with mixed line endings even though no decision moved it. if (paramOf.size) { + // P3: the file must be emitted and substituted (0.8.2) + if (!emittedFile(base.meta, pf.file, base.dir)) { + throw new Error(`unify plan: "${pf.file}" is not emitted by any target — a {{param}} there would never render`); + } if (!substitutedFile(base.meta, pf.file)) { throw new Error(`unify plan: "${pf.file}" is copied without substitution by a target that emits it — a {{param}} there would be emitted literally`); } @@ -680,7 +689,7 @@ async function checkMarkers( newNames: Map = new Map(), ): Promise { const label = (rel: string) => ["ingredients", path.basename(path.dirname(base.dir)), path.basename(base.dir), rel].join("/"); - const touched = [...Object.keys(write), ...remove].filter((rel) => substitutedFile(base.meta, rel)).sort(); + const touched = [...Object.keys(write), ...remove].filter((rel) => bodyFile(base.meta, rel, base.dir)).sort(); for (const rel of touched) { const abs = path.join(base.dir, rel); const before = (await exists(abs)) ? markerStructure(await fs.readFile(abs, "utf8"), label(rel)) : { names: [] }; diff --git a/src/importers/claude-code.ts b/src/importers/claude-code.ts index d39b8f8..4c5e79d 100644 --- a/src/importers/claude-code.ts +++ b/src/importers/claude-code.ts @@ -15,7 +15,7 @@ import { resolvedBy } from "../core/param-writes.js"; import { decide, forgeBefore, pin, sourceKeys, workspaceParams, workspaceSections, type RunContext } from "./decide.js"; import { renderMap } from "../core/template-import.js"; import { firstMarkerLine } from "../core/sections.js"; -import { substitutedFile } from "../core/extract.js"; +import { bodyFile } from "../core/extract.js"; import { deepMerge } from "../core/merge.js"; export interface ImportOptions { @@ -855,7 +855,7 @@ async function existingProfile( } /** - * I11 (spec 11 §4.5): the first column-0 section marker (or near miss) in an admitted file of a + * I11 (spec 11 §4.5): the first column-0 section marker (or near miss) in a body file of a * created or variant source, which sync would read as structure. The line counts from the top of * the workspace file, as the secret scan's does: an agent or command body starts after its frontmatter. */ @@ -863,7 +863,8 @@ function markerIn(meta: Ingredient, files: Record, sour const raw = "frontmatterRaw" in meta ? meta.frontmatterRaw : undefined; const offset = raw ? raw.split("\n").length + 2 : 0; for (const [rel, content] of Object.entries(files)) { - if (!substitutedFile(meta, rel)) continue; + // the importer builds meta and file keys itself, with canonical names + if (!bodyFile(meta, rel, null)) continue; const line = firstMarkerLine(toLf(stripBom(typeof content === "string" ? content : content.toString("utf8")))); if (line !== null) return { where: meta.type === "skill" && meta.layout === "dir" ? `${source}${rel}` : source, line: line + offset }; } diff --git a/src/importers/decide.ts b/src/importers/decide.ts index d28764f..5c55be0 100644 --- a/src/importers/decide.ts +++ b/src/importers/decide.ts @@ -1,7 +1,7 @@ import path from "node:path"; import YAML from "yaml"; import { z } from "zod"; -import { placeholders, reservedKey, substitutedFile } from "../core/extract.js"; +import { placeholders, reservedKey, bodyFile } from "../core/extract.js"; import { fingerprintOf } from "../core/fingerprint.js"; import { exists, listFiles, loadForge, readIngredientText, type Forge } from "../core/forge.js"; import { hashNormalized, stripBom, toLf } from "../core/text.js"; @@ -65,10 +65,11 @@ const norm = (s: string) => toLf(stripBom(s)); const textOf = (c: string | Buffer) => norm(typeof c === "string" ? c : c.toString("utf8")); const valueOf = (m: Record, k: string) => (Object.hasOwn(m, k) ? String(m[k]) : undefined); -/** The keys a source's admitted files cite literally. */ +/** The keys a source's body files cite literally. */ export function sourceKeys(meta: Ingredient, files: Record): Set { const out = new Set(); - for (const [rel, c] of Object.entries(files)) if (substitutedFile(meta, rel)) for (const k of placeholders(textOf(c))) out.add(k); + // the importer builds meta and file keys itself, with canonical names + for (const [rel, c] of Object.entries(files)) if (bodyFile(meta, rel, null)) for (const k of placeholders(textOf(c))) out.add(k); return out; } @@ -273,7 +274,7 @@ function inferSectionValues( } async function listAdmitted(ing: { dir: string; meta: Ingredient }): Promise { - return (await listFiles(ing.dir)).filter((rel) => rel !== "ingredient.yaml" && substitutedFile(ing.meta, rel)); + return (await listFiles(ing.dir)).filter((rel) => rel !== "ingredient.yaml" && bodyFile(ing.meta, rel, ing.dir)); } const OverridesParams = z.record(z.unknown()); diff --git a/test/extract.test.ts b/test/extract.test.ts new file mode 100644 index 0000000..d0fa0a3 --- /dev/null +++ b/test/extract.test.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { IngredientSchema } from "../src/schema/index.js"; +import { emittedFile } from "../src/core/extract.js"; + +describe("emittedFile resolves a declared file the way the emitters read it (0.8.2)", () => { + const dir = path.join(path.sep, "forge", "ingredients", "rules", "r"); + const rule = (file: string) => IngredientSchema.parse({ type: "rule", name: "r", file }); + it("accepts every spelling path.join resolves to the listed file, on every platform", () => { + for (const f of ["rule.md", "./rule.md", "/rule.md", ".//rule.md", "a/../rule.md", "../r/rule.md", "a/../../r/rule.md"]) { + expect(emittedFile(rule(f), "rule.md", dir), f).toBe(true); + } + expect(emittedFile(rule("rule.md"), "notes.md", dir)).toBe(false); + expect(emittedFile(rule("../other/rule.md"), "rule.md", dir)).toBe(false); + }); + it("treats a backslash as a separator only where path.join does", () => { + const win = process.platform === "win32"; + expect(emittedFile(rule(".\\rule.md"), "rule.md", dir)).toBe(win); + expect(emittedFile(rule("\\rule.md"), "rule.md", dir)).toBe(win); + expect(emittedFile(rule("sub\\rule.md"), "sub/rule.md", dir)).toBe(win); + }); + it("matches script files the same way", () => { + const s = IngredientSchema.parse({ type: "script", name: "s", files: ["./run.sh", "../s/x.sh"] }); + const sdir = path.join(path.sep, "forge", "ingredients", "scripts", "s"); + expect(emittedFile(s, "run.sh", sdir)).toBe(true); + expect(emittedFile(s, "x.sh", sdir)).toBe(true); + expect(emittedFile(s, "y.sh", sdir)).toBe(false); + }); +}); diff --git a/test/importer.test.ts b/test/importer.test.ts index 831d31b..cb67002 100644 --- a/test/importer.test.ts +++ b/test/importer.test.ts @@ -622,6 +622,33 @@ describe("template-aware import — decisions (spec 10 §6.1–§6.5)", () => { expect(r.params).toEqual([]); }); + it("refuses a profile change another Forge ingredient would feel (F9, §6.5 (b)) (./rule.md, 0.8.2)", async () => { + // Like the original F9 test but with file: ./rule.md in the other ingredient. This verifies that + // listAdmitted at decide.ts:277 uses the `dir` argument correctly. With dir, bodyFile compares + // path.join(dir, "rule.md") === path.join(dir, "./rule.md"), which works. Without dir, it compares + // "rule.md" === "./rule.md", which is false, so rule.md drops out of the F9 scan. + const t = await setup(); + await templated(t, { deploy }); + await writeFiles(path.join(t.forge, "ingredients/rules/deploy-notes"), { + "ingredient.yaml": "type: rule\nname: deploy-notes\nfile: ./rule.md\n", + "rule.md": "see {{deploy.api}}\n", + }); + await writeFiles(t.ws("b"), { ".claude/rules/deploy.md": "use initech-api here\n" }); + const r = await importInto(t.forge, t.ws("b"), "b"); + expect(r.variants[0].reason).toContain("setting deploy.api would change rule/deploy-notes"); + expect(r.params).toEqual([]); + }); + + it("does not hold a profile change back for a {{key}} in a file no target emits (F9, 0.8.2)", async () => { + const t = await setup(); + await templated(t, { deploy }); + await writeFiles(path.join(t.forge, "ingredients/rules/deploy-notes"), { "ingredient.yaml": "type: rule\nname: deploy-notes\n", "rule.md": "see the docs\n", "notes.md": "see {{deploy.api}}\n" }); + await writeFiles(t.ws("b"), { ".claude/rules/deploy.md": "use initech-api here\n" }); + const r = await importInto(t.forge, t.ws("b"), "b"); + expect(r.variants).toEqual([]); + expect(r.params.length).toBeGreaterThan(0); + }); + it("keeps a Forge base in the F9 scan when its workspace source is rejected for a secret (§6.5 (b))", async () => { const t = await setup(); await templated(t, { deploy }); @@ -1091,6 +1118,36 @@ describe("section-aware import — decisions (spec 11 §6.7–§6.10)", () => { expect(await snapshot(t.forge)).toEqual(before); }); + it("I12: a base with malformed markers is refused, the Forge byte-identical (./rule.md, 0.8.2)", async () => { + // This test checks that readBase at :53 uses the `dir` argument when parsing body files. + // With dir, bodyFile compares path.join(dir, "rule.md") against path.join(dir, "./rule.md"), which works. + // Without dir, it compares "rule.md" === "./rule.md", which is false, so rule.md is not read as a body file. + const t = await setup(); + await marked(t, `${HEAD}${OPEN("flavors")}${ACME}${TAIL}`); + // Add file: ./rule.md to the ingredient metadata + const metaPath = path.join(t.forge, "ingredients/rules/review-posture/ingredient.yaml"); + const meta = YAML.parse(await fs.readFile(metaPath, "utf8")); + meta.file = "./rule.md"; + await fs.writeFile(metaPath, YAML.stringify(meta)); + const before = await snapshot(t.forge); + const e = await fail(importInto(t.forge, t.ws("acme"), "acme")); + expect(e?.message).toContain("import: ingredients/rules/review-posture/rule.md:5: section flavors is never closed — fix the Forge and re-run"); + expect(e?.message).toContain("The Forge was left untouched."); + expect(await snapshot(t.forge)).toEqual(before); + }); + + it("a malformed marker in a file no target emits does not refuse the import (0.8.2)", async () => { + const t = await setup(); + await marked(t); + // Add a notes.md with a malformed marker to the Forge ingredient + await fs.writeFile(path.join(t.forge, "ingredients/rules/review-posture/notes.md"), `${OPEN("x")}no closer\n`); + // The import should not fail due to the malformed marker in notes.md (a file no target emits) + // The source will become a variant since the file set differs, but that's fine + const r = await importInto(t.forge, t.ws("acme"), "acme"); + // Verify the import succeeded (created a variant because file sets differ) + expect(r.variants.map((v) => v.name)).toEqual(["rule/review-posture--acme"]); + }); + it("param inference is preferred for a {{k}} inside a default (Ruling 16); a source that differs in both is a variant", async () => { const t = await setup(); await marked(t, `Deploy {{deploy.api}}.\n${OPEN("rows")}| {{owner}} |\n${CLOSE}`, "Deploy globex-api.\n| ann |\n"); diff --git a/test/param-writes.test.ts b/test/param-writes.test.ts index f79742f..69bb45a 100644 --- a/test/param-writes.test.ts +++ b/test/param-writes.test.ts @@ -99,6 +99,11 @@ describe("checkParamWrites — Forge-level refusals (spec 09 §6.3)", () => { expect(await err(check(v, [ext("k", "globex-api", "acme-api")]))).toContain("rule/deploy--acme already uses {{k}}"); }); + it("P14 ignores a {{key}} in a file no target emits (0.8.2)", async () => { + const b = await forgeOf(spec({ ingredients: [] }), { "ingredients/rules/deploy/notes.md": "see {{k}}\n" }); + expect(await err(check(b, [ext("k", "globex-api", "acme-api")]))).toBe("no error"); + }); + it("P15, P16: a recipe or another profile sets the key to another value", async () => { const r = await forgeOf(spec({ recipes: [recipe("base", ["rule/deploy"], { params: { k: { default: "x" } } }), recipe("base--acme", ["rule/deploy--acme"])] })); expect(await err(check(r, [ext("k", "globex-api", "acme-api")]))).toContain("recipe base declares k"); @@ -116,6 +121,12 @@ describe("checkParamWrites — Forge-level refusals (spec 09 §6.3)", () => { expect(await err(check(forge, [ext("k", "globex-api", "acme-api")]))).toContain("rule/other also uses {{k}}"); }); + it("the citation scan reads a body file declared ./rule.md (0.8.2)", async () => { + // The other ingredient declares file: ./rule.md — the non-canonical spelling + const forge = await forgeOf(spec({ ingredients: [] }), { "ingredients/rules/other/ingredient.yaml": "type: rule\nname: other\nfile: ./rule.md\n", "ingredients/rules/other/rule.md": "see {{k}}\n" }); + expect(await err(check(forge, [ext("k", "globex-api", "acme-api")]))).toContain("rule/other also uses {{k}}"); + }); + it("P19: a hand-formatted file, or params behind an alias", async () => { const aligned = await forgeOf(spec(), { "profiles/acme/profile.yaml": "name: acme # the client\nrecipes:\n - base--acme\n" }); expect(await err(check(aligned, [ext("k", "globex-api", "acme-api")]))).toContain("does not round-trip"); diff --git a/test/plan.test.ts b/test/plan.test.ts index 3dbb4e6..43a3891 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -137,6 +137,20 @@ describe("sections — plan warnings (Ruling 12)", () => { expect(p.warnings).toContain("profile acme sets section x of skill/tool, which has no such marker"); }); + it("markers in a file not every target renders are copied verbatim by both, and warned once (Ruling 17) (./tool.bin, 0.8.2)", async () => { + // Like the Ruling 17 test but using a script with files: ["./tool.bin"]. This verifies that + // emittedFile at sync.ts:118 uses the `dir` argument correctly. With dir, emittedFile compares + // path.join(dir, "./tool.bin") in the files array. Without dir, it compares "./tool.bin" against + // the file list literally, which would not match if the file on disk is "tool.bin". + const body = ["#!/bin/sh", OPEN("x"), "echo hi", CLOSE, ""].join("\n"); + const p = await sectionPlan({ + ingredients: [{ meta: { type: "script", name: "tool", files: ["./tool.bin"] }, files: { "tool.bin": body } }], + targets: ["claude-code"], + }); + expect(p.warnings.filter((w) => w.includes("tool.bin"))).toEqual(["script/tool tool.bin: section markers are read only in files every target renders as text — copied with them"]); + expect(out(p, ".claude/scripts/./tool.bin")).toBe(body); + }); + it("warns on markers in a copied file whatever its extension (an .html in a skill dir)", async () => { const page = [OPEN("x"), "

hi

", CLOSE, ""].join("\n"); const p = await sectionPlan({ @@ -148,6 +162,101 @@ describe("sections — plan warnings (Ruling 12)", () => { }); }); +describe("0.8.2 — files no target emits are not read for sections", () => { + it("a near-miss marker in a file no target emits does not fail the plan", async () => { + const r: IngredientSpec = { meta: { type: "rule", name: "r" }, files: { "rule.md": "# R\n", "notes.md": "\n" } }; + const p = await sectionPlan({ ingredients: [r] }); + expect(out(p, ".claude/rules/r.md")).toBe("# R\n"); + expect(p.warnings.filter((w) => /section/.test(w))).toEqual([]); + }); + + it("a marker in a file no target emits neither trips the schema: 1 gate nor declares a section", async () => { + const r: IngredientSpec = { meta: { type: "rule", name: "r" }, files: { "rule.md": "# R\n", "notes.md": [OPEN("x"), "y", CLOSE, ""].join("\n") } }; + const p = await sectionPlan({ ingredients: [r], schema: 1, profileExtra: { sections: { "rule/r": { x: "z" } } } }); + expect(out(p, ".claude/rules/r.md")).toBe("# R\n"); + expect((p.sections.get("rule/r") ?? []).length).toBe(0); + expect(p.warnings).toContain("profile acme sets section x of rule/r, which has no such marker"); + }); + + it("a dir skill still expands sections in every text file it emits", async () => { + const s: IngredientSpec = { meta: { type: "skill", name: "tool" }, files: { "SKILL.md": "# Tool\n", "ref.md": [OPEN("x"), "default", CLOSE, ""].join("\n") } }; + const p = await sectionPlan({ ingredients: [s], profileExtra: { sections: { "skill/tool": { x: "mine" } } } }); + expect(out(p, ".claude/skills/tool/ref.md")).toBe("mine\n"); + }); + + it("an ingredient whose file is spelled ./rule.md still syncs (0.8.2 regression)", async () => { + const r: IngredientSpec = { meta: { type: "rule", name: "r", file: "./rule.md" }, files: { "rule.md": "# R v\n" } }; + const p = await sectionPlan({ ingredients: [r] }); + expect(out(p, ".claude/rules/r.md")).toBe("# R v\n"); + }); + + it("every rule and script file spelling measured on 0.8.1 still syncs with the same bytes (0.8.2)", async () => { + const ruleOut = async (file: string) => { + const p = await sectionPlan({ ingredients: [{ meta: { type: "rule", name: "r", file }, files: { "rule.md": "# R v\n" } }], schema: 1 }); + return p.files.map((f) => [f.path, f.content.toString("utf8")]); + }; + const reference = await ruleOut("rule.md"); + expect(reference.find(([p]) => p === ".claude/rules/r.md")?.[1]).toBe("# R v\n"); + for (const file of ["./rule.md", "/rule.md", ".//rule.md", "//rule.md", "a/../rule.md", "../r/rule.md", "a/../../r/rule.md", "./../r/rule.md"]) { + expect(await ruleOut(file), file).toEqual(reference); + } + const scriptOut = async (file: string) => { + const p = await sectionPlan({ ingredients: [{ meta: { type: "script", name: "s", files: [file] }, files: { "run.sh": "echo hi\n" } }], targets: ["claude-code"], schema: 1 }); + return p.files.map((f) => [f.path, f.content.toString("utf8")]); + }; + expect(await scriptOut("run.sh")).toEqual([[".claude/scripts/run.sh", "echo hi\n"]]); + expect(await scriptOut("./run.sh")).toEqual([[".claude/scripts/./run.sh", "echo hi\n"]]); + expect(await scriptOut("/run.sh")).toEqual([[".claude/scripts//run.sh", "echo hi\n"]]); + expect(await scriptOut("a/../run.sh")).toEqual([[".claude/scripts/a/../run.sh", "echo hi\n"]]); + expect(await scriptOut("../s/run.sh")).toEqual([[".claude/scripts/../s/run.sh", "echo hi\n"]]); + }); + + it("a file spelling 0.8.1 could not read still fails, and never as an internal error (0.8.2)", async () => { + const plan = (file: string) => sectionPlan({ ingredients: [{ meta: { type: "rule", name: "r", file }, files: { "rule.md": "# R v\n" } }], schema: 1 }); + const bad = ["rule.md/", ...(process.platform === "win32" ? [] : [".\\rule.md", "\\rule.md"])]; + for (const file of bad) { + const e = await plan(file).then(() => null, (x: Error) => x); + expect(e, file).not.toBeNull(); + expect(e!.message, file).not.toContain("internal:"); + } + }); + + it("a body file outside the ingredient directory is a user error, not an internal one (0.8.2)", async () => { + // Create the scenario first, then add the file outside the ingredient directory + const refs = ["rule/r"]; + const s = await scenario( + { ingredients: [{ meta: { type: "rule", name: "r", file: "../outside.md" }, files: { "rule.md": "# R v\n" } }], recipes: [recipe("base", refs)], profiles: [profile("acme", ["base"], ["claude-code", "kiro", "agents-md"], {})] }, + { config: { profile: "acme" } }, + ); + cleanups.push(s.cleanup); + await writeFiles(s.forgeRoot, { "craftar.forge.yaml": "name: test-forge\nschema: 1\n" }); + // Create the file outside the ingredient directory (at ingredients/rules/outside.md) + await writeFiles(s.forgeRoot, { "ingredients/rules/outside.md": "# Outside\n" }); + const e = await plan(await loadWorkspace(s.wsRoot)).then(() => null, (x: Error) => x); + expect(e).not.toBeNull(); + expect(e!.message).not.toContain("internal:"); + expect(e!.message).toContain("outside its directory"); + }); + + it.skipIf(process.platform === "win32")("a body file behind a symlinked directory is a user error, not an internal one (0.8.2)", async () => { + // Create a symlink to a subdirectory and declare a file through it + const refs = ["rule/r"]; + const s = await scenario( + { ingredients: [{ meta: { type: "rule", name: "r", file: "link/rule.md" }, files: { "rule.md": "# R v\n" } }], recipes: [recipe("base", refs)], profiles: [profile("acme", ["base"], ["claude-code", "kiro", "agents-md"], {})] }, + { config: { profile: "acme" } }, + ); + cleanups.push(s.cleanup); + await writeFiles(s.forgeRoot, { "craftar.forge.yaml": "name: test-forge\nschema: 1\n" }); + // Create sub/rule.md and symlink link -> sub in the ingredient directory + const ingDir = path.join(s.forgeRoot, "ingredients/rules/r"); + await writeFiles(ingDir, { "sub/rule.md": "# R v\n" }); + await fs.symlink(path.join(ingDir, "sub"), path.join(ingDir, "link")); + const e = await plan(await loadWorkspace(s.wsRoot)).then(() => null, (x: Error) => x); + expect(e).not.toBeNull(); + expect(e!.message).not.toContain("internal:"); + }); +}); + describe("sections — parse errors and the output guard", () => { it("a malformed marker fails the plan naming the Forge file and line, whichever targets resolve (edge case 20)", async () => { const bad = rule("r", ["# R", OPEN("flavors"), "x", ""].join("\n"), { targets: ["kiro"] }); diff --git a/test/unify.test.ts b/test/unify.test.ts index 69b536e..3af44e8 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -150,6 +150,42 @@ describe("planFrom", () => { // take should still be "keep" expect(paired.hunks![0].take).toBe("keep"); }); + + it("pre-fills a section name from a heading for a block hunk (spec 12 §4.2) (./rule.md, 0.8.2)", async () => { + // This test checks that planFrom at :87 uses the `dir` argument when building the `declared` set. + // The base already has a section named "reviewer-table" (the slug of "## Reviewer Table"), so the + // pre-fill should produce "reviewer-table-2". With dir, bodyFile compares path.join(dir, "rule.md") + // against path.join(dir, "./rule.md"), which works. Without dir, it compares "rule.md" against + // "./rule.md", which is false, so the file is not read, `declared` is empty, and pre-fill uses + // "reviewer-table" instead of "reviewer-table-2". + const baseDir = await tmpDir(); + cleanups.push(() => fs.rm(baseDir, { recursive: true, force: true })); + await writeFiles(baseDir, { + "ingredient.yaml": "type: rule\nname: review-posture\nfile: ./rule.md\n", + // Base has an existing section named "reviewer-table" and a SEPARATE table below another heading with the same slug + "rule.md": "# Rules\n\n\nOld\n\n\n## Reviewer Table\n| a |\n", + }); + + const variantDir = await tmpDir(); + cleanups.push(() => fs.rm(variantDir, { recursive: true, force: true })); + await writeFiles(variantDir, { + "ingredient.yaml": "type: rule\nname: review-posture--acme\nas: review-posture\nfile: ./rule.md\n", + // Variant keeps the existing section content but adds rows to the table below + "rule.md": "# Rules\n\n\nOld\n\n\n## Reviewer Table\n| a |\n| b |\n", + }); + + const base = { ref: "rule/review-posture", dir: baseDir, meta: { type: "rule", name: "review-posture", file: "./rule.md" } } as never; + const variant = { ref: "rule/review-posture--acme", dir: variantDir, meta: { type: "rule", name: "review-posture--acme", as: "review-posture", file: "./rule.md" } } as never; + const diff = await diffIngredients(base, variant); + + const plan = await planFrom(base, variant, diff, "acme"); + // The only hunk should be the block hunk for "| b |" below the "## Reviewer Table" heading + const paired = plan.files.find((f) => f.file === "rule.md")!; + expect(paired.hunks).toHaveLength(1); + expect(paired.hunks![0].suggestion?.class).toBe("block"); + // Since "reviewer-table" is already declared in the existing section, the pre-fill should be "reviewer-table-2" + expect(paired.hunks![0].section).toEqual({ name: "reviewer-table-2" }); + }); }); /** Two temp ingredient directories (base + variant--profile), loaded and diffed for real. */ @@ -736,6 +772,26 @@ describe("take: param — the engine (spec 09 §6.1, §6.2)", () => { (variant as any).meta = { ...(variant as any).meta, params: { k: { default: "y" } } }; expect(metaDifferences(base, variant)).toEqual(["params"]); }); + + it("take: param works on a body file declared ./rule.md (0.8.2)", async () => { + // Both base and variant declare file: "./rule.md" — the non-canonical spelling + const baseDir = await tmpDir(); + const variantDir = await tmpDir(); + cleanups.push(() => fs.rm(baseDir, { recursive: true, force: true }), () => fs.rm(variantDir, { recursive: true, force: true })); + await writeFiles(baseDir, { "ingredient.yaml": "type: rule\nname: workflow\nfile: ./rule.md\n", "rule.md": "use globex-api\n" }); + await writeFiles(variantDir, { "ingredient.yaml": "type: rule\nname: workflow--acme\nas: workflow\nfile: ./rule.md\n", "rule.md": "use acme-api\n" }); + const base = { ref: "rule/workflow", dir: baseDir, meta: { type: "rule", name: "workflow", file: "./rule.md" } } as never; + const variant = { ref: "rule/workflow--acme", dir: variantDir, meta: { type: "rule", name: "workflow--acme", as: "workflow", file: "./rule.md" } } as never; + const diff = await diffIngredients(base, variant); + const plan = await planFrom(base, variant, diff, "acme"); + Object.assign(plan.files[0].hunks![0], { take: "param", params: [{ token: "globex-api", key: "deploy.api" }] }); + const r = await applyPlan(base, variant, diff, plan); + expect(r.write["rule.md"]).toBe("use {{deploy.api}}\n"); + expect(r.params).toEqual([ + { key: "deploy.api", default: "globex-api", value: "acme-api", reused: false, sites: [{ file: "rule.md", hunk: 1 }] }, + ]); + expect(r.resolved).toBe(true); + }); }); describe("take: param — the remaining engine rows (spec 09 AC 9)", () => { @@ -845,14 +901,77 @@ describe("U1 — a merge never changes section markers (spec 11 §6.12, Ruling 8 it("refuses a variant-only file that would bring markers in, and a base-only file removed with its sections", async () => { const e = (p: Promise) => p.then(() => "no error", (x: Error) => x.message); - const add = await scenario({ "rule.md": "a\n" }, { "rule.md": "a\n", "notes.md": "\nx\n\n" }); - const addPlan = await planFrom(add.base, add.variant, add.diff, "acme"); - addPlan.files[0].take = "variant"; - expect(await e(applyPlan(add.base, add.variant, add.diff, addPlan))).toContain("sections none would become n"); - const rm = await scenario({ "rule.md": "a\n", "notes.md": "\nx\n\n" }, { "rule.md": "a\n" }); - const rmPlan = await planFrom(rm.base, rm.variant, rm.diff, "acme"); - rmPlan.files[0].take = "variant"; - expect(await e(applyPlan(rm.base, rm.variant, rm.diff, rmPlan))).toContain("the file would be removed with sections n"); + // 0.8.2: notes.md beside rule.md in a rule is not emitted by any target, so markers are ignored. + // For a file that IS emitted (a skill dir's text file), markers still matter. + const skill = (name: string, body: Record, extra: Record = {}): IngredientSpec => ({ + meta: { type: "skill", name, layout: "dir", ...extra }, + files: body, + }); + const addRoot = await tmpDir("craftar-u1-add-"); + cleanups.push(() => fs.rm(addRoot, { recursive: true, force: true })); + await makeForge(addRoot, { + ingredients: [ + skill("tool", { "SKILL.md": "a\n" }), + skill("tool--acme", { "SKILL.md": "a\n", "notes.md": "\nx\n\n" }, { as: "tool" }), + ], + recipes: [recipe("base", ["skill/tool"]), recipe("base--acme", ["skill/tool--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const addForge = await loadForge(addRoot); + const addBase = addForge.ingredients.get("skill/tool")!; + const addVariant = addForge.ingredients.get("skill/tool--acme")!; + const addDiff = await diffIngredients(addBase, addVariant); + const addPlan = await planFrom(addBase, addVariant, addDiff, "acme"); + // notes.md is one-sided in variant + const notesPlan = addPlan.files.find((f) => f.file === "notes.md"); + expect(notesPlan).toBeDefined(); + notesPlan!.take = "variant"; + expect(await e(applyPlan(addBase, addVariant, addDiff, addPlan))).toContain("sections none would become n"); + + const rmRoot = await tmpDir("craftar-u1-rm-"); + cleanups.push(() => fs.rm(rmRoot, { recursive: true, force: true })); + await makeForge(rmRoot, { + ingredients: [ + skill("tool", { "SKILL.md": "a\n", "notes.md": "\nx\n\n" }), + skill("tool--acme", { "SKILL.md": "a\n" }, { as: "tool" }), + ], + recipes: [recipe("base", ["skill/tool"]), recipe("base--acme", ["skill/tool--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const rmForge = await loadForge(rmRoot); + const rmBase = rmForge.ingredients.get("skill/tool")!; + const rmVariant = rmForge.ingredients.get("skill/tool--acme")!; + const rmDiff = await diffIngredients(rmBase, rmVariant); + const rmPlan = await planFrom(rmBase, rmVariant, rmDiff, "acme"); + // notes.md is one-sided in base + const rmNotesPlan = rmPlan.files.find((f) => f.file === "notes.md"); + expect(rmNotesPlan).toBeDefined(); + rmNotesPlan!.take = "variant"; + expect(await e(applyPlan(rmBase, rmVariant, rmDiff, rmPlan))).toContain("the file would be removed with sections n"); + }); + + it("carries a rule's notes.md with markers over by take: variant — no target emits it (0.8.2)", async () => { + const root = await tmpDir("craftar-u1-notes-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const notes = "\nx\n\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "w" }, files: { "rule.md": "a\n" } }, + { meta: { type: "rule", name: "w--acme", as: "w" }, files: { "rule.md": "a\n", "notes.md": notes } }, + ], + recipes: [recipe("base", ["rule/w"]), recipe("base--acme", ["rule/w--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/w")!; + const variant = forge.ingredients.get("rule/w--acme")!; + const diff = await diffIngredients(base, variant); + const plan = await planFrom(base, variant, diff, "acme"); + const entry = plan.files.find((f) => f.file === "notes.md"); + expect(entry).toBeDefined(); + entry!.take = "variant"; + const result = await applyPlan(base, variant, diff, plan); + expect(result.write["notes.md"]).toBe(notes); }); }); @@ -1162,6 +1281,54 @@ Done. ); }); + it("a malformed marker in a file no target emits does not block take: section (0.8.2)", async () => { + const root = await tmpDir("craftar-section-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const notes = "\nno closer\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "review-posture" }, files: { "rule.md": "# R\n\n| a |\n\nEnd.\n", "notes.md": notes } }, + { meta: { type: "rule", name: "review-posture--acme", as: "review-posture" }, files: { "rule.md": "# R\n\n| a |\n| b |\n\nEnd.\n", "notes.md": notes } }, + ], + recipes: [recipe("base", ["rule/review-posture"]), recipe("base--acme", ["rule/review-posture--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/review-posture")!; + const variant = forge.ingredients.get("rule/review-posture--acme")!; + const diff = await diffIngredients(base, variant); + const planObj = await planFrom(base, variant, diff, "acme"); + planObj.files[0].hunks![0].take = "section"; + (planObj.files[0].hunks![0] as Record).section = { name: "flavors" }; + const result = await applyPlan(base, variant, diff, planObj); + expect(result.resolved).toBe(true); + expect(result.sections.map((s) => s.name)).toEqual(["flavors"]); + }); + + it("take: section or take: param on a file no target emits is refused with its own message (0.8.2)", async () => { + const root = await tmpDir("craftar-section-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "w" }, files: { "rule.md": "# W\n", "notes.md": "use globex-api\n" } }, + { meta: { type: "rule", name: "w--acme", as: "w" }, files: { "rule.md": "# W\n", "notes.md": "use acme-api\n" } }, + ], + recipes: [recipe("base", ["rule/w"]), recipe("base--acme", ["rule/w--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/w")!; + const variant = forge.ingredients.get("rule/w--acme")!; + const diff = await diffIngredients(base, variant); + const err = (p: Promise) => p.then(() => "no error", (e: Error) => e.message); + const asParam = await planFrom(base, variant, diff, "acme"); + Object.assign(asParam.files[0].hunks![0], { take: "param", params: [{ token: "globex-api", key: "k" }] }); + expect(await err(applyPlan(base, variant, diff, asParam))).toContain('unify plan: "notes.md" is not emitted by any target'); + const asSection = await planFrom(base, variant, diff, "acme"); + Object.assign(asSection.files[0].hunks![0], { take: "section", section: { name: "s" } }); + expect(await err(applyPlan(base, variant, diff, asSection))).toContain('unify plan: "notes.md" is not emitted by any target'); + }); + it("U1: --take variant over a marked base is still refused", async () => { // This test ensures that taking variant on a file with markers is still refused const baseBody = "# Review\n\n\n| a |\n\n\nDone.\n"; @@ -1178,6 +1345,37 @@ Done. ); }); + it("U1: --take variant over a marked base is still refused (./rule.md, 0.8.2)", async () => { + // This test checks that checkMarkers at :692 uses the `dir` argument correctly. With dir, bodyFile + // compares path.join(dir, "rule.md") === path.join(dir, "./rule.md"), which works. Without dir, it + // compares "rule.md" === "./rule.md", which is false, so rule.md drops out of the U1 check. + const root = await tmpDir("craftar-u1-dotslash-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const baseBody = "# Review\n\n\n| a |\n\n\nDone.\n"; + const variantBody = "# Review\n\n| b |\n\nDone.\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "review-posture", file: "./rule.md" }, files: { "rule.md": baseBody } }, + { meta: { type: "rule", name: "review-posture--acme", as: "review-posture", file: "./rule.md" }, files: { "rule.md": variantBody } }, + ], + recipes: [recipe("base", ["rule/review-posture"]), recipe("base--acme", ["rule/review-posture--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/review-posture")!; + const variant = forge.ingredients.get("rule/review-posture--acme")!; + const diffResult = await diffIngredients(base, variant); + const planObj = await planFrom(base, variant, diffResult, "acme"); + // Take variant on all hunks (no section declaration) + for (const h of planObj.files[0].hunks!) { + h.take = "variant"; + } + + await expect(applyPlan(base, variant, diffResult, planObj)).rejects.toThrow( + /would lose or change section markers.*take base for the marker lines, or take: section to fill the section/, + ); + }); + it("U1: plan with section hunk that also takes variant on an existing marker hunk is refused", async () => { // Base has two sections const baseBody = "# Review\n\n\na\n\n\n\nb\n\n"; @@ -1333,4 +1531,142 @@ d `; expect(merged).toBe(expected); }); + + it("--save-plan pre-fills no section name in a file no target emits, and ignores names declared there (0.8.2)", async () => { + const root = await tmpDir("craftar-prefill-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "w" }, files: { "rule.md": "# W\n\n## Rows\n\n| a |\n", "notes.md": "## Rows\n\n\nx\n\n" } }, + { meta: { type: "rule", name: "w--acme", as: "w" }, files: { "rule.md": "# W\n\n## Rows\n\n| a |\n| b |\n", "notes.md": "## Rows\n\n\nx\n\n| c |\n" } }, + ], + recipes: [recipe("base", ["rule/w"]), recipe("base--acme", ["rule/w--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/w")!; + const variant = forge.ingredients.get("rule/w--acme")!; + const plan = await planFrom(base, variant, await diffIngredients(base, variant), "acme"); + const notes = plan.files.find((f) => f.file === "notes.md"); + const rule = plan.files.find((f) => f.file === "rule.md"); + expect(notes).toBeDefined(); + expect(rule).toBeDefined(); + expect(notes!.hunks!.every((h) => h.section === undefined)).toBe(true); + expect(rule!.hunks![0].section).toEqual({ name: "rows" }); + }); + + it("a body file declared ./rule.md reaches every unify and import check (./rule.md, 0.8.2)", async () => { + // This test declares file: "./rule.md" in the metadata to ensure that bodyFile/emittedFile calls + // pass the `dir` argument correctly. With `dir`, path.join(dir, "./rule.md") === path.join(dir, "rule.md"), + // but without it, "./rule.md" !== "rule.md" fails the comparison. + const root = await tmpDir("craftar-dot-slash-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const baseBody = "# Review\n\n| Repo |\n|---|\n| a |\n\nDone.\n"; + const variantBody = "# Review\n\n| Repo |\n|---|\n| a |\n| b |\n\nDone.\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "review-posture", file: "./rule.md" }, files: { "rule.md": baseBody } }, + { meta: { type: "rule", name: "review-posture--acme", as: "review-posture", file: "./rule.md" }, files: { "rule.md": variantBody } }, + ], + recipes: [recipe("base", ["rule/review-posture"]), recipe("base--acme", ["rule/review-posture--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/review-posture")!; + const variant = forge.ingredients.get("rule/review-posture--acme")!; + const diff = await diffIngredients(base, variant); + + // planFrom: section name pre-fill should work (checks :87, :102) + const plan = await planFrom(base, variant, diff, "acme"); + expect(plan.files[0].hunks![0].section).toEqual({ name: "review" }); + + // applyPlan with take: section should work (checks :430, :445, :692) + plan.files[0].hunks![0].take = "section"; + (plan.files[0].hunks![0] as Record).section = { name: "flavors", lines: "3-5" }; + const result = await applyPlan(base, variant, diff, plan); + expect(result.resolved).toBe(true); + expect(result.sections).toHaveLength(1); + expect(result.sections[0].name).toBe("flavors"); + const merged = result.write["rule.md"] as string; + expect(merged).toContain(""); + }); + + it("a variant with ./rule.md holding a marker is refused (./rule.md, 0.8.2)", async () => { + // This test checks that bodyFile with `dir` at :375 correctly identifies a body file + // spelled as "./rule.md" when scanning the variant for markers. + const root = await tmpDir("craftar-s12-dot-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const baseBody = "# R\n\na\n"; + const variantBody = "# R\n\n\nb\n\n"; + await makeForge(root, { + ingredients: [ + { meta: { type: "rule", name: "w", file: "./rule.md" }, files: { "rule.md": baseBody } }, + { meta: { type: "rule", name: "w--acme", as: "w", file: "./rule.md" }, files: { "rule.md": variantBody } }, + ], + recipes: [recipe("base", ["rule/w"]), recipe("base--acme", ["rule/w--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("rule/w")!; + const variant = forge.ingredients.get("rule/w--acme")!; + const diff = await diffIngredients(base, variant); + const plan = await planFrom(base, variant, diff, "acme"); + plan.files[0].hunks![0].take = "section"; + (plan.files[0].hunks![0] as Record).section = { name: "s" }; + + await expect(applyPlan(base, variant, diff, plan)).rejects.toThrow( + /rule\/w--acme holds a section marker on rule.md:3/, + ); + }); + + it("refuses a section name another body file declares, with files spelled ./ (unify.ts S6, 0.8.2)", async () => { + // This test checks that bodyFile at :445 uses the `dir` argument when building declaredElsewhere. + // With dir, bodyFile compares path.join(dir, "b.sh") against path.join(dir, "./b.sh"), which works. + // Without dir, it compares "b.sh" === "./b.sh", which is false, so b.sh is not seen as a body file + // and its section marker is not collected — allowing an invalid duplicate name. + const root = await tmpDir("craftar-s6-dotslash-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + + // Script ingredient with files spelled ./ + const script = (name: string, body: Record, extra: Record = {}): IngredientSpec => ({ + meta: { type: "script", name, files: ["./a.sh", "./b.sh"], ...extra }, + files: body, + }); + + // Base b.sh declares section `flavors` at line 2 (opener on line 1) + const baseBsh = "#!/bin/bash\n\necho base\n\n"; + // Variant b.sh has no markers — its hunks take base + const variantBsh = "#!/bin/bash\necho base\n"; + + // a.sh differs between base and variant + const baseAsh = "#!/bin/bash\necho a\n"; + const variantAsh = "#!/bin/bash\necho variant-a\n"; + + await makeForge(root, { + ingredients: [ + script("tool", { "a.sh": baseAsh, "b.sh": baseBsh }), + script("tool--acme", { "a.sh": variantAsh, "b.sh": variantBsh }, { as: "tool" }), + ], + recipes: [recipe("base", ["script/tool"]), recipe("base--acme", ["script/tool--acme"])], + profiles: [profile("acme", ["base--acme"])], + }); + const forge = await loadForge(root); + const base = forge.ingredients.get("script/tool")!; + const variant = forge.ingredients.get("script/tool--acme")!; + const diffResult = await diffIngredients(base, variant); + const planObj = await planFrom(base, variant, diffResult, "acme"); + + // Take base on b.sh hunks + const bshFile = planObj.files.find((f) => f.file === "b.sh"); + for (const h of bshFile?.hunks ?? []) h.take = "base"; + + // Take section with name "flavors" on a.sh (already declared in b.sh) + const ashFile = planObj.files.find((f) => f.file === "a.sh"); + ashFile!.hunks![0].take = "section"; + (ashFile!.hunks![0] as Record).section = { name: "flavors" }; + + await expect(applyPlan(base, variant, diffResult, planObj)).rejects.toThrow( + /already declared in script\/tool \(b\.sh:2\)/, + ); + }); });