diff --git a/README.md b/README.md index 29fde31..0fa8e69 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ craftar import --from claude-code \ # 2. See what sync would do. On a freshly imported workspace everything is "adopt": # the files already on disk are exactly what the Forge produces, so they just become managed. +# (A JSON file that differs only in whitespace is also "adopt"; the first sync rewrites it.) craftar status --workspace C:/Projects/acme-portal-workspace # 3. Generate + lock @@ -52,7 +53,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | Command | What it does | |---|---| | `craftar import --from claude-code --forge --profile [--workspace .] [--write-config]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml`. A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created|edited|unchanged` lines. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. | -| `craftar status` | Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in a file not every target renders as text (copied as they are). | +| `craftar status` | Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in 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 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. | @@ -240,6 +241,12 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ## Upgrading +### to 0.8.1 + +- **No behaviour change.** This is a documentation and test-only release. +- **The README now states that adopting a JSON file rewrites it on the first sync** — by design. `claude-code`'s `.mcp.json` becomes two-space JSON and keeps its BOM and line endings; Kiro's JSON files become CRLF without a BOM. Only differences `JSON.parse` erases are adopted; a different key order is a `collision`. +- **Test-only CI fix**: the test suite no longer reads `.git/` in its Forge snapshots, and its test repositories turn git's automatic maintenance off (`maintenance.auto`, `gc.auto`), ending a flake where that background maintenance raced the snapshots. + ### to 0.8.0 - **`forge unify` can write `sections` into a profile and set `schema: 2` in `craftar.forge.yaml`.** craftar 0.6.2 and older then refuse that Forge, as after an import that relies on markers. diff --git a/package-lock.json b/package-lock.json index 3f9c73a..73a190e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.0", + "version": "0.8.1", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index c4ef842..6b16438 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.8.0", + "version": "0.8.1", "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 c0c0216..b0e56da 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.0"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.1"); /* ---------------------------------------------------------------- import */ program diff --git a/test/cli.test.ts b/test/cli.test.ts index 6907c51..aebda2e 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -15,7 +15,7 @@ afterEach(async () => { /** Every file under root with its content, so a before/after comparison catches an in-place edit. */ async function snapshot(root: string): Promise> { - const files = await listFiles(root); + const files = (await listFiles(root)).filter((f) => f !== ".git" && !f.startsWith(".git/")); return Object.fromEntries(await Promise.all(files.map(async (f) => [f, await fs.readFile(path.join(root, f), "utf8")] as const))); } @@ -32,6 +32,8 @@ function gitEnv(): NodeJS.ProcessEnv { function gitInit(dir: string): void { execFileSync("git", ["init", "-q", dir]); + execFileSync("git", ["-C", dir, "config", "maintenance.auto", "false"]); + execFileSync("git", ["-C", dir, "config", "gc.auto", "0"]); } function gitCommitAll(dir: string, message: string): void { @@ -40,6 +42,17 @@ function gitCommitAll(dir: string, message: string): void { } describe("cli", () => { + it("snapshot() never reads .git/ — git's background maintenance races it (0.8.1)", async () => { + const root = await tmpDir("craftar-cli-forge-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + await fs.writeFile(path.join(root, "a.txt"), "a\n"); + gitInit(root); + gitCommitAll(root, "init"); + const keys = Object.keys(await snapshot(root)); + expect(keys).toContain("a.txt"); + expect(keys.filter((k) => k === ".git" || k.startsWith(".git/"))).toEqual([]); + }); + it("status prints the unresolved-param warning and exits 0; sync --check still passes", async () => { const s = await scenario( { ingredients: [rule("a", "Org: {{missing}}\n")], recipes: [recipe("base", ["rule/a"])], profiles: [profile("acme", ["base"])] }, @@ -1525,7 +1538,7 @@ describe("cli — suggested hunk classes (spec 08)", () => { const r = runCli(["forge", "unify", "rule/wf", "--profile", "acme", "--plan", file, "--forge", forgeRoot]); expect(r.code, `${name}: ${r.stderr}`).toBe(0); const snap = await snapshot(forgeRoot); - results[name] = Object.fromEntries(Object.entries(snap).filter(([k]) => !k.startsWith(".git/"))); + results[name] = snap; } expect(results.mangled).toEqual(results.edited); expect(results.removed).toEqual(results.edited); diff --git a/test/golden-param.test.ts b/test/golden-param.test.ts index d81d60f..0429da4 100644 --- a/test/golden-param.test.ts +++ b/test/golden-param.test.ts @@ -55,6 +55,8 @@ async function freshForge(): Promise<{ tmp: string; forge: string }> { const forge = path.join(tmp, "forge"); await copyTree(INPUT, forge); execFileSync("git", ["init", "-q", forge]); + execFileSync("git", ["-C", forge, "config", "maintenance.auto", "false"]); + execFileSync("git", ["-C", forge, "config", "gc.auto", "0"]); gitCommitAll(forge, "init"); return { tmp, forge }; } diff --git a/test/golden-sections.test.ts b/test/golden-sections.test.ts index d412906..ef09747 100644 --- a/test/golden-sections.test.ts +++ b/test/golden-sections.test.ts @@ -95,6 +95,8 @@ async function roundTrip(): Promise { await edit(path.join(ws[p], "craftar.yaml"), addAgentsMd); } execFileSync("git", ["init", "-q", forge]); + execFileSync("git", ["-C", forge, "config", "maintenance.auto", "false"]); + execFileSync("git", ["-C", forge, "config", "gc.auto", "0"]); gitCommitAll(forge, "import acme and globex"); // 2. Sync both: the workspace's own files adopt, AGENTS.md is new. diff --git a/test/golden-take-section.test.ts b/test/golden-take-section.test.ts index 4ac4ec6..228e575 100644 --- a/test/golden-take-section.test.ts +++ b/test/golden-take-section.test.ts @@ -127,6 +127,8 @@ async function step1_importAndSync(): Promise { } execFileSync("git", ["init", "-q", forge]); + execFileSync("git", ["-C", forge, "config", "maintenance.auto", "false"]); + execFileSync("git", ["-C", forge, "config", "gc.auto", "0"]); gitCommitAll(forge, "import acme, globex and initech"); // Sync and snapshot all three. diff --git a/test/golden-unify.test.ts b/test/golden-unify.test.ts index 81c906e..7e44c1e 100644 --- a/test/golden-unify.test.ts +++ b/test/golden-unify.test.ts @@ -62,6 +62,8 @@ function gitEnv(): NodeJS.ProcessEnv { function gitInit(dir: string): void { execFileSync("git", ["init", "-q", dir]); + execFileSync("git", ["-C", dir, "config", "maintenance.auto", "false"]); + execFileSync("git", ["-C", dir, "config", "gc.auto", "0"]); } function gitCommitAll(dir: string, message: string): void { diff --git a/test/status.test.ts b/test/status.test.ts index eea8bde..d654f40 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -89,6 +89,28 @@ describe("status", () => { expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("adopt"); }); + it("adopt rewrites a JSON file into the emitter's layout on the first sync — by design (0.8.1)", async () => { + const mcp: IngredientSpec = { meta: { type: "mcp", name: "pw", server: { command: "npx", args: ["-y", "pw"] } } }; + const s = await oneRule({ ".mcp.json": '{"mcpServers":{"pw":{"command":"npx","args":["-y","pw"]}}}\n' }, [mcp], ["mcp/pw"]); + expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("adopt"); + await sync(s.wsRoot); + expect(await fs.readFile(path.join(s.wsRoot, ".mcp.json"), "utf8")).toBe( + '{\n "mcpServers": {\n "pw": {\n "command": "npx",\n "args": [\n "-y",\n "pw"\n ]\n }\n }\n}\n', + ); + expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("unchanged"); + }); + + it("adopt keeps the BOM and CRLF of the JSON file it rewrites — by design (0.8.1)", async () => { + const mcp: IngredientSpec = { meta: { type: "mcp", name: "pw", server: { command: "npx", args: ["-y", "pw"] } } }; + const s = await oneRule({ ".mcp.json": '{"mcpServers":{"pw":{"command":"npx","args":["-y","pw"]}}}\r\n' }, [mcp], ["mcp/pw"]); + expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("adopt"); + await sync(s.wsRoot); + expect(await fs.readFile(path.join(s.wsRoot, ".mcp.json"), "utf8")).toBe( + '{\r\n "mcpServers": {\r\n "pw": {\r\n "command": "npx",\r\n "args": [\r\n "-y",\r\n "pw"\r\n ]\r\n }\r\n }\r\n}\r\n', + ); + expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("unchanged"); + }); + it("a CRLF + BOM copy of a synced LF file is unchanged, not drift (FM-2)", async () => { const s = await oneRule(); await sync(s.wsRoot); diff --git a/test/unify.test.ts b/test/unify.test.ts index 834db44..69b536e 100644 --- a/test/unify.test.ts +++ b/test/unify.test.ts @@ -60,6 +60,8 @@ describe("gitDirty", () => { cleanups.push(() => fs.rm(dir, { recursive: true, force: true })); await fs.writeFile(path.join(dir, "a.txt"), "one\n"); await execFileP("git", ["-C", dir, "init", "-q"]); + await execFileP("git", ["-C", dir, "config", "maintenance.auto", "false"]); + await execFileP("git", ["-C", dir, "config", "gc.auto", "0"]); await execFileP("git", ["-C", dir, "add", "-A"]); await execFileP("git", ["-C", dir, "-c", "user.email=t@e", "-c", "user.name=t", "commit", "-qm", "init"]); expect(await gitDirty(dir)).toBe(false); @@ -789,6 +791,8 @@ describe("U1 — a merge never changes section markers (spec 11 §6.12, Ruling 8 }); await fs.writeFile(path.join(root, "craftar.forge.yaml"), "name: test-forge\nschema: 2\n"); await execFileP("git", ["-C", root, "init", "-q"]); + await execFileP("git", ["-C", root, "config", "maintenance.auto", "false"]); + await execFileP("git", ["-C", root, "config", "gc.auto", "0"]); await execFileP("git", ["-C", root, "add", "-A"]); await execFileP("git", ["-C", root, "commit", "-qm", "init"], { env: gitEnv }); return root;