Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
| `craftar diff [path]` | Line diff between disk and what the Forge would generate. |
| `craftar explain <path>` | Which ingredient, recipe chain, target and origin produced a file. |
| `craftar ls` | Recipes and ingredients resolved for this workspace. |
| `craftar forge variants [--forge <dir> \| --workspace <dir>] [--json]` | Lists ingredients that have variants, nearest first, with the profile each came from and its distance to the base, then any variant whose base is missing from the Forge. Read-only; exits 0 either way. `--json` prints `{groups, orphans}`. |
| `craftar forge diff <type/name> [--against <profile>] [--forge <dir> \| --workspace <dir>] [--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, then the files that exist on only one side. Read-only. `--json` prints an array of `{ref, profile, distance, diff}`. |
| `craftar forge unify <type/name> --profile <p> (--take base\|variant \| --plan <file> \| --save-plan <file>) [--forge <dir> \| --workspace <dir>] [--json]` | Resolves one variant back into its base, hunk by hunk. `--save-plan` writes a reviewable plan with every decision set to `keep` and writes nothing else; 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. `--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 a profile or an `extends`: a `<recipe>--<profile>` left identical to `<recipe>` 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 `<recipe>` 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. `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) 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, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. |
| `craftar forge variants [--forge <dir> \| --workspace <dir>] [--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 <type/name> [--against <profile>] [--forge <dir> \| --workspace <dir>] [--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.<slug>`) or `block` (lines only one side has) — then the files that exist on only one side. A suggestion never decides anything. 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 <type/name> --profile <p> (--take base\|variant \| --plan <file> \| --save-plan <file>) [--forge <dir> \| --workspace <dir>] [--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 writes nothing else; 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. `--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 a profile or an `extends`: a `<recipe>--<profile>` left identical to `<recipe>` 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 `<recipe>` 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. `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) 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, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. |

All commands take `--workspace <dir>` (default: current directory); the `forge` commands also take `--forge <dir>` as an alternative to it.

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

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "craftar",
"version": "0.3.0",
"version": "0.4.0",
"description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.",
"license": "MIT",
"type": "module",
Expand Down
26 changes: 19 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ import {
type RecipeCascadeResult,
type WriteJournal,
} from "./core/unify.js";
import { UnifyPlanSchema, type IngredientRef, type Take, type UnifyPlan } from "./schema/index.js";
import { HUNK_CLASSES, UnifyPlanSchema, type HunkClass, type HunkSuggestion, type IngredientRef, type Take, type UnifyPlan } from "./schema/index.js";

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.3.0");
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.4.0");

/* ---------------------------------------------------------------- import */
program
Expand Down Expand Up @@ -164,7 +164,7 @@ const forge = program.command("forge").description("Operate on the Forge itself

forge
.command("variants")
.description("List ingredients that have variants, nearest first, and variants whose base is missing. Read-only")
.description("List ingredients that have variants, nearest first, with hunks counted by suggested class, and variants whose base is missing. Read-only")
.option("--forge <dir>", "Forge directory (instead of --workspace)")
.option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
.option("--json", "machine-readable output", false)
Expand All @@ -177,7 +177,7 @@ forge
if (!groups.length && !orphans.length) return console.log(" no variants");
for (const g of groups) {
const count = `${g.variants.length} variant${g.variants.length > 1 ? "s" : ""}`;
const detail = g.variants.map((v) => `${v.profile} (${describeDistance(v.distance)})`).join(", ");
const detail = g.variants.map((v) => `${v.profile} (${describeDistance(v.distance)})${describeClasses(v.classes)}`).join(", ");
console.log(` ${g.base.padEnd(24)} ${count.padEnd(11)} ${detail}`);
}
for (const orphan of orphans) {
Expand All @@ -192,7 +192,7 @@ forge

forge
.command("diff")
.description("Show the distance and the differences between a base ingredient and each of its variants. Read-only")
.description("Show the distance and the differences between a base ingredient and each of its variants, each hunk with a suggested class (evolution, value, block) that never decides anything. Read-only")
.argument("<type/name>", "base ingredient (rule/workflow)")
.option("--against <profile>", "only this profile's variant")
.option("--forge <dir>", "Forge directory (instead of --workspace)")
Expand Down Expand Up @@ -226,7 +226,7 @@ forge
for (const file of r.diff.files) {
console.log(` ${file.file}`);
file.hunks.forEach((h, k) => {
console.log(` hunk ${k + 1} [${h.kind}] ${hunkAt(h)}`);
console.log(` hunk ${k + 1} [${h.kind}] ${hunkAt(h)} ${describeSuggestion(h.suggestion)}`);
for (const line of h.a.lines) console.log(pc.red(` - ${line}`));
if (h.a.noEofNewline) console.log(pc.red(` ${NO_EOF_NEWLINE_MARKER}`));
for (const line of h.b.lines) console.log(pc.green(` + ${line}`));
Expand All @@ -245,7 +245,7 @@ forge
.requiredOption("--profile <p>", "which variant to resolve")
.option("--take <side>", "resolve every decision to base or variant")
.option("--plan <file>", "apply the decisions in this plan file")
.option("--save-plan <file>", "write a plan with every decision deferred to a new file outside the Forge, and stop")
.option("--save-plan <file>", "write a plan with every decision deferred, each hunk annotated with its suggested class (which --plan ignores), to a new file outside the Forge, and stop")
.option("--forge <dir>", "Forge directory (instead of --workspace)")
.option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
.option("--json", "machine-readable output", false)
Expand Down Expand Up @@ -590,6 +590,18 @@ function lateFailure(e: unknown, root: string, journal: WriteJournal): string {
return lines.join("\n");
}

/** ` [1 evolution · 2 block]` in the fixed class order, zero counts omitted; empty for a variant without hunks (spec 08 §4.2). */
function describeClasses(classes: Record<HunkClass, number>): string {
const parts = HUNK_CLASSES.filter((c) => classes[c] > 0).map((c) => `${classes[c]} ${c}`);
return parts.length ? ` [${parts.join(" · ")}]` : "";
}

/** `<class>: <reason>`, and ` → <params>` for a value (spec 08 §4.1). */
function describeSuggestion(s: HunkSuggestion): string {
const params = s.tokens?.length ? ` → ${s.tokens.map((t) => t.param).join(", ")}` : "";
return `${s.class}: ${s.reason}${params}`;
}

function describeDistance(d: Distance): string {
if (d.identicalAfterNormalization) return "identical after normalization";
if (d.sameBodyDifferentMeta) return "meta only";
Expand Down
123 changes: 123 additions & 0 deletions src/core/classify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { HunkSuggestion } from "../schema/index.js";
import { lcsOps, type Hunk } from "./diff.js";

/**
* Suggests a class for one hunk (spec 08 §6): `evolution` (one side is a newer text), `value`
* (a short client token swapped inside shared prose) or `block` (lines only one side has).
* Pure and deterministic. A suggestion never decides anything; `forge unify` still asks.
*/

export type ClassifiedHunk = Hunk & { suggestion: HunkSuggestion };

/** A token longer than this is not a client identifier (spec 08 §6.2). */
const MAX_TOKEN_CHARS = 40;
/** Bounds the O(n·m) token LCS; no real rule line comes near it. */
const MAX_LINE_TOKENS = 400;
/** Integers this short are step numbers and list markers, not client data. */
const NUMBERING = /^[0-9]{1,2}$/;

const TOKEN = /\s+|[\p{L}\p{N}_@-]+(?:[./:]+[\p{L}\p{N}_@-]+)*|\S/gu;
const WORD = /^[\p{L}\p{N}_@-]+(?:[./:]+[\p{L}\p{N}_@-]+)*$/u;
const WHITESPACE = /^\s+$/;

/** A line split into whitespace runs, words (which may hold `.`, `/`, `:` between word characters) and single other characters. */
export function tokenize(line: string): string[] {
return line.match(TOKEN) ?? [];
}

/** A word short enough, not a step number, and shaped like a name, path, version or port (spec 08 §6.2). */
export function isIdentifierLike(token: string): boolean {
if (!WORD.test(token) || token.length > MAX_TOKEN_CHARS || NUMBERING.test(token)) return false;
return /[-./@:]/.test(token) || /[0-9]/.test(token) || /[\p{L}\p{N}]_[\p{L}\p{N}]/u.test(token) || /\p{Ll}\p{Lu}/u.test(token);
}

/** `param.<slug>` of a token's base-side text; `_`-separated because `substitute` keys are `[A-Za-z0-9_.]` (spec 08 §6.4). */
export function paramSlug(text: string): string {
let slug = text
.normalize("NFD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
slug = slug.slice(0, MAX_TOKEN_CHARS).replace(/_+$/, "");
return `param.${slug || "value"}`;
}

const evolution = (reason: string): HunkSuggestion => ({ class: "evolution", reason });

interface Region {
a: string[];
b: string[];
}

/** Maximal runs of changed tokens between two lines; whitespace runs compare equal whatever their length. */
function regions(a: string[], b: string[]): Region[] {
const key = (t: string) => (WHITESPACE.test(t) ? " " : t);
const out: Region[] = [];
let current: Region | null = null;
let i = 0;
let j = 0;
for (const op of lcsOps(a.map(key), b.map(key))) {
if (op.kind === "same") {
current = null;
i++;
j++;
continue;
}
if (!current) out.push((current = { a: [], b: [] }));
if (op.kind === "del") current.a.push(a[i++]);
else current.b.push(b[j++]);
}
return out;
}

const text = (tokens: string[]) => tokens.join("").replace(/\s+/g, " ").trim();
const words = (tokens: string[]) => tokens.filter((t) => !WHITESPACE.test(t));

export function classifyHunk(h: Hunk): HunkSuggestion {
// 1. Drop equal lines at both ends: the context line `forceTrailingHunk` adds, nothing else.
let a = h.a.lines;
let b = h.b.lines;
while (a.length && b.length && a[0] === b[0]) [a, b] = [a.slice(1), b.slice(1)];
while (a.length && b.length && a[a.length - 1] === b[b.length - 1]) [a, b] = [a.slice(0, -1), b.slice(0, -1)];

if (!a.length && !b.length) return evolution("final newline only");
if (!a.length) return { class: "block", reason: "only in the variant" };
if (!b.length) return { class: "block", reason: "only in the base" };
if (Boolean(h.a.noEofNewline) !== Boolean(h.b.noEofNewline)) return evolution("final newline differs");
if (a.length !== b.length) return evolution("line counts differ");

const changed: Array<{ a: string; b: string }> = [];
for (let k = 0; k < a.length; k++) {
const ta = tokenize(a[k]);
const tb = tokenize(b[k]);
if (ta.length > MAX_LINE_TOKENS || tb.length > MAX_LINE_TOKENS) return evolution("line too long to compare");
for (const r of regions(ta, tb)) {
const wa = words(r.a);
const wb = words(r.b);
if (!wa.length && !wb.length) continue; // whitespace only
if (!wa.length || !wb.length) return evolution("words added or removed");
const odd = [...wa, ...wb].filter((t) => !isIdentifierLike(t));
if (odd.length) return evolution(odd.every((t) => NUMBERING.test(t)) ? "numbering differs" : "prose differs");
changed.push({ a: text(r.a), b: text(r.b) });
}
}
if (!changed.length) return evolution("whitespace only");

const seen = new Set<string>();
const used = new Set<string>();
const tokens: NonNullable<HunkSuggestion["tokens"]> = [];
for (const c of changed) {
const id = JSON.stringify([c.a, c.b]);
if (seen.has(id)) continue;
seen.add(id);
// A suffixed name can equal another token's natural slug (`acme_api` → `_2` vs `acme_api_2`),
// so the suffix grows until the name is free: every distinct pair gets its own parameter.
const slug = paramSlug(c.a);
let param = slug;
for (let n = 2; used.has(param); n++) param = `${slug}_${n}`;
used.add(param);
tokens.push({ a: c.a, b: c.b, param });
}
return { class: "value", reason: tokens.length === 1 ? "1 token differs" : `${tokens.length} tokens differ`, tokens };
}
7 changes: 5 additions & 2 deletions src/core/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ export function splitLines(s: string): Split {
* that is only line endings or a BOM produces no ops other than "same".
*/
export function diffOps(a: string, b: string): DiffOp[] {
const A = splitLines(a).lines;
const B = splitLines(b).lines;
return lcsOps(splitLines(a).lines, splitLines(b).lines);
}

/** LCS over two lists of strings, compared exactly; shared by the line diff and the word diff (spec 08 §6.2). */
export function lcsOps(A: string[], B: string[]): DiffOp[] {
const n = A.length;
const m = B.length;
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
Expand Down
2 changes: 1 addition & 1 deletion src/core/unify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function planFrom(
await assertTextMergeable(base, variant);
const files: PlanFile[] = [];
for (const f of diff.files) {
files.push({ file: f.file, hunks: f.hunks.map((h, i) => ({ hunk: i + 1, at: hunkAt(h), take: "keep" as const })) });
files.push({ file: f.file, hunks: f.hunks.map((h, i) => ({ hunk: i + 1, at: hunkAt(h), take: "keep" as const, suggestion: h.suggestion })) });
}
for (const file of diff.onlyInBase) files.push({ file, onlyIn: "base", take: "keep" });
for (const file of diff.onlyInVariant) files.push({ file, onlyIn: "variant", take: "keep" });
Expand Down
Loading
Loading