diff --git a/CHANGELOG.md b/CHANGELOG.md index ea25d15..ed2294a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] ### Added +- `skillforge diff `: structural comparison of two SKILL.md files. Reports per-field frontmatter changes (added / removed / changed with before/after), `##` and `###` heading-level changes (added / removed / reordered with `from`/`to` positions, computed over the intersection so a single insert doesn't cascade as N moves), and a coarse body line-count delta. Refuses to diff either file if its frontmatter fails schema validation — fix the file first. Human-readable mode prints a tidy multi-section report with kleur colour (green adds, red removes, yellow changes); `--json` emits the full `DiffResult` for CI. Exits 0 when structurally identical, 1 when they differ (matches `mcp-devtools diff`), 2 on validation/IO error. Side-effect free. - `skillforge inspect `: one-shot diagnostic report that rolls validation, linting, frontmatter parsing, body stats (lines / words / characters / `## headings` extracted in document order, code-fence aware), and (for directory inputs) the attached-file inventory into one structured `InspectResult`. Reuses `pack`'s exclusion rules so the file list matches exactly what `pack` would bundle — no duplicated walker. Human-readable mode prints a tidy multi-section report with a frontmatter table, right-aligned body-stat numerals, and grouped validation / lint / attached-file lists; `--json` emits the full result for CI. Exits 0 when validation passes and there are no lint errors, 1 otherwise. Composed entirely on top of the existing `validateSkill` / `lintSkill` APIs. - `skillforge format `: reformat a SKILL.md to canonical shape — frontmatter keys reordered into the schema's declared order (`name`, `description`, `version`, `tags`, `author`, `homepage`) with passthrough fields alphabetized at the end, stringy primitives like `tags: "[]"` coerced back to YAML, trailing whitespace stripped per line, runs of 3+ blank lines collapsed to 2, exactly one trailing newline. The body envelope is normalized but prose is not reflowed and fenced code blocks (` ``` `) are preserved verbatim. The formatted output is validated against the schema before writing; an already-canonical file is a no-op. `--dry-run` returns the diff without writing; `--write=false` prints to stdout; `--check` exits 1 if anything would change (CI mode). Idempotent — running twice produces byte-identical output the second time. Style fixes that pair with `lint`'s style checks. - `skillforge update `: bump the `version:` field of a SKILL.md in one shot — `--bump ` or `--new-version ` (mutually exclusive, exactly one required). Pre-release tags (`-beta`, `-rc.1`, …) are dropped on any bump, matching `npm version`. A missing `version:` field is treated as the schema default of `0.0.1`. Validates the proposed frontmatter against the schema before touching disk and uses a line-surgical write so the body bytes and other YAML formatting are preserved byte-for-byte. `--dry-run` reports the new version without writing. diff --git a/README.md b/README.md index 137048a..260b703 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Scaffold, validate, and lint `SKILL.md` files for the agent ecosystem. --- -> **Status — v0.0.2, early days.** `init`, `validate`, `lint`, `pack`, `install`, `update`, `format`, and `inspect` work today. Registry, publish, and eval flows land in v0.1. +> **Status — v0.0.2, early days.** `init`, `validate`, `lint`, `pack`, `install`, `update`, `format`, `inspect`, and `diff` work today. Registry, publish, and eval flows land in v0.1. ## Install @@ -166,6 +166,28 @@ skillforge inspect ./code-review Pass `--json` for machine-readable output (CI-friendly — every field is JSON-stable). Exit `0` if validation passes and there are no lint errors, `1` otherwise. Reuses `pack`'s exclusion rules so the attached-file list matches exactly what `pack` would bundle. +### `skillforge diff ` + +Structural comparison of two `SKILL.md` files. A plain `diff` is noisy — a reordered heading or a one-word frontmatter change drowns in re-wrapped paragraphs. `skillforge diff` pulls the structural signal out: which frontmatter fields changed, which `##` / `###` sections were added, removed, or reordered, and a coarse body line-count delta. + +```bash +skillforge diff ./code-review-v1/SKILL.md ./code-review-v2/SKILL.md +# diff …v1/SKILL.md → …v2/SKILL.md +# frontmatter: 1 headings: 2 body lines: +6 -3 +# +# FRONTMATTER +# ~ version: 0.1.0 → 0.2.0 +# +# HEADINGS +# + Examples +# ~ When to use it (position 1 → 2) +# +# BODY +# +6 -3 lines (coarse) +``` + +Pass `--json` for machine-readable output. Exit `0` if the files are structurally identical, `1` if they differ (mirrors `mcp-devtools diff`), `2` on validation or IO error. Both files must validate against the schema — broken frontmatter is refused so the structural view stays trustworthy. + ## Schema ```yaml diff --git a/src/cli.ts b/src/cli.ts index cdf70cc..f1ab98f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,9 +10,11 @@ * update bump the version field of a SKILL.md * format reformat a SKILL.md to canonical shape * inspect one-shot report: validation + lint + frontmatter + body + * diff structural comparison of two SKILL.md files */ import { cac } from "cac"; import kleur from "kleur"; +import { type DiffResult, diffSkills } from "./diff.js"; import { formatSkill } from "./format.js"; import { initSkill } from "./init.js"; import { type InspectResult, inspectSkill } from "./inspect.js"; @@ -346,6 +348,112 @@ function truncate(s: string, max: number): string { return `${s.slice(0, max - 1)}…`; } +cli + .command("diff ", "Structural diff of two SKILL.md files") + .option("--json", "Emit the full diff result as JSON (machine-readable)") + .action(async (a: string, b: string, opts) => { + try { + const result = await diffSkills(a, b); + if (opts.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exit(result.identical ? 0 : 1); + } + printDiffReport(result); + process.exit(result.identical ? 0 : 1); + } catch (err) { + process.stderr.write(`${kleur.red("error:")} ${(err as Error).message}\n`); + // Exit 2 for validation/IO failure, distinct from the "files differ" + // exit 1 — mirrors `mcp-devtools diff` convention so CI scripts can + // tell "noisy" from "broken". + process.exit(2); + } + }); + +/** + * Human-readable `diff` report. A short summary header, then one section + * per kind of change (frontmatter, headings, body). Prose-oriented: + * paragraphs where the comparison is best read as a sentence, lists only + * where the items are genuinely parallel. Additions in green, removals in + * red, changes in yellow. + */ +function printDiffReport(r: DiffResult): void { + const out = process.stdout; + const heading = (label: string) => out.write(`\n${kleur.bold(kleur.dim(label.toUpperCase()))}\n`); + + if (r.identical) { + out.write( + `${kleur.green("✓")} ${r.pathA} ${kleur.dim("≡")} ${r.pathB} ${kleur.dim("— structurally identical")}\n`, + ); + return; + } + + // Summary line — counts at a glance, full breakdown follows. + const fm = r.frontmatter; + const fmCount = Object.keys(fm.added).length + Object.keys(fm.removed).length + fm.changed.length; + const hd = r.bodyHeadings; + const hdCount = hd.added.length + hd.removed.length + hd.reordered.length; + out.write( + `${kleur.bold("diff")} ${kleur.dim(r.pathA)} ${kleur.dim("→")} ${kleur.dim(r.pathB)}\n`, + ); + out.write( + `${kleur.dim(" frontmatter:")} ${fmCount} ${kleur.dim("headings:")} ${hdCount} ${kleur.dim("body lines:")} ${kleur.green(`+${r.bodyLinesDelta.added}`)} ${kleur.red(`-${r.bodyLinesDelta.removed}`)}\n`, + ); + + if (fmCount > 0) { + heading("Frontmatter"); + for (const [k, v] of Object.entries(fm.added)) { + out.write(` ${kleur.green("+")} ${k}: ${formatValue(v)}\n`); + } + for (const [k, v] of Object.entries(fm.removed)) { + out.write(` ${kleur.red("-")} ${k}: ${formatValue(v)}\n`); + } + for (const c of fm.changed) { + out.write( + ` ${kleur.yellow("~")} ${c.key}: ${kleur.red(formatValue(c.before))} ${kleur.dim("→")} ${kleur.green(formatValue(c.after))}\n`, + ); + } + } + + if (hdCount > 0) { + heading("Headings"); + for (const h of hd.added) { + out.write(` ${kleur.green("+")} ${h}\n`); + } + for (const h of hd.removed) { + out.write(` ${kleur.red("-")} ${h}\n`); + } + for (const m of hd.reordered) { + out.write( + ` ${kleur.yellow("~")} ${m.heading} ${kleur.dim(`(position ${m.from} → ${m.to})`)}\n`, + ); + } + } + + if (r.bodyLinesDelta.added > 0 || r.bodyLinesDelta.removed > 0) { + heading("Body"); + out.write( + ` ${kleur.green(`+${r.bodyLinesDelta.added}`)} ${kleur.red(`-${r.bodyLinesDelta.removed}`)} ${kleur.dim("lines (coarse)")}\n`, + ); + } +} + +/** + * Render a frontmatter value as a short inline string. Arrays show as + * `[a, b]`; objects as JSON; long strings get truncated with an ellipsis + * so the diff stays scannable even with multi-line `description` fields. + */ +function formatValue(v: unknown): string { + if (v === undefined) return kleur.dim("∅"); + if (v === null) return "null"; + if (Array.isArray(v)) return `[${v.map((x) => formatValue(x)).join(", ")}]`; + if (typeof v === "string") { + const single = v.replace(/\s+/g, " "); + return single.length > 64 ? `${single.slice(0, 63)}…` : single; + } + if (typeof v === "object") return JSON.stringify(v); + return String(v); +} + cli.help(); cli.version(VERSION); cli.parse(); diff --git a/src/diff.test.ts b/src/diff.test.ts new file mode 100644 index 0000000..17cf0f4 --- /dev/null +++ b/src/diff.test.ts @@ -0,0 +1,349 @@ +import { realpathSync } from "node:fs"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { diffSkills } from "./diff.js"; + +let workDir: string; + +beforeEach(async () => { + // realpath because macOS `tmpdir()` returns `/tmp` while resolved paths + // come back as `/private/tmp`. Same trick as the sibling test files. + workDir = realpathSync(await mkdtemp(join(tmpdir(), "skillforge-diff-"))); +}); + +const VALID_DESC = "Use this when the user asks for the demo skill described in this file."; + +const HEALTHY_BODY = `# my-skill + +## What this skill does + +Real prose. Real prose. Real prose. Real prose. + +## When to use it + +When the agent wants to demonstrate something. + +## Examples + +\`\`\` +example +\`\`\` +`; + +function buildSkillSource( + opts: { + name?: string; + description?: string; + version?: string; + tags?: string; + author?: string; + body?: string; + extra?: Record; + } = {}, +): string { + const name = opts.name ?? "my-skill"; + const description = opts.description ?? VALID_DESC; + const version = opts.version ?? "0.1.0"; + const tags = opts.tags ?? "[a, b]"; + const lines = [ + "---", + `name: ${name}`, + `description: ${description}`, + `version: ${version}`, + `tags: ${tags}`, + ]; + // Quote the author value — handles values starting with `@`, which YAML + // would otherwise read as a reserved indicator. + if (opts.author !== undefined) lines.push(`author: "${opts.author}"`); + if (opts.extra) { + for (const [k, v] of Object.entries(opts.extra)) { + lines.push(`${k}: ${v}`); + } + } + lines.push("---", "", opts.body ?? HEALTHY_BODY); + return lines.join("\n"); +} + +async function writeSkill(content: string, name: string): Promise { + const file = join(workDir, name); + await writeFile(file, content); + return file; +} + +describe("diffSkills — identical files", () => { + it("reports identical: true when both files have the same frontmatter and body", async () => { + const a = await writeSkill(buildSkillSource(), "a.md"); + const b = await writeSkill(buildSkillSource(), "b.md"); + const r = await diffSkills(a, b); + expect(r.identical).toBe(true); + expect(r.frontmatter.added).toEqual({}); + expect(r.frontmatter.removed).toEqual({}); + expect(r.frontmatter.changed).toEqual([]); + expect(r.bodyHeadings.added).toEqual([]); + expect(r.bodyHeadings.removed).toEqual([]); + expect(r.bodyHeadings.reordered).toEqual([]); + expect(r.bodyLinesDelta).toEqual({ added: 0, removed: 0 }); + }); +}); + +describe("diffSkills — frontmatter changes", () => { + it("flags an added optional field as `added`", async () => { + const a = await writeSkill(buildSkillSource(), "a.md"); + const b = await writeSkill(buildSkillSource({ author: "@adityachilka1" }), "b.md"); + const r = await diffSkills(a, b); + expect(r.frontmatter.added).toEqual({ author: "@adityachilka1" }); + expect(r.frontmatter.removed).toEqual({}); + expect(r.frontmatter.changed).toEqual([]); + expect(r.identical).toBe(false); + }); + + it("flags a removed optional field as `removed`", async () => { + const a = await writeSkill(buildSkillSource({ author: "@adityachilka1" }), "a.md"); + const b = await writeSkill(buildSkillSource(), "b.md"); + const r = await diffSkills(a, b); + expect(r.frontmatter.removed).toEqual({ author: "@adityachilka1" }); + expect(r.frontmatter.added).toEqual({}); + expect(r.identical).toBe(false); + }); + + it("flags a changed value (version bump) under `changed` with before/after", async () => { + const a = await writeSkill(buildSkillSource({ version: "0.1.0" }), "a.md"); + const b = await writeSkill(buildSkillSource({ version: "0.2.0" }), "b.md"); + const r = await diffSkills(a, b); + expect(r.frontmatter.changed).toEqual([{ key: "version", before: "0.1.0", after: "0.2.0" }]); + expect(r.frontmatter.added).toEqual({}); + expect(r.frontmatter.removed).toEqual({}); + }); + + it("treats tags array reorder as a change (deep array comparison)", async () => { + const a = await writeSkill(buildSkillSource({ tags: "[a, b]" }), "a.md"); + const b = await writeSkill(buildSkillSource({ tags: "[b, a]" }), "b.md"); + const r = await diffSkills(a, b); + expect(r.frontmatter.changed).toHaveLength(1); + expect(r.frontmatter.changed[0].key).toBe("tags"); + }); + + it("recognizes equal tag arrays as unchanged", async () => { + const a = await writeSkill(buildSkillSource({ tags: "[a, b]" }), "a.md"); + const b = await writeSkill(buildSkillSource({ tags: "[a, b]" }), "b.md"); + const r = await diffSkills(a, b); + expect(r.frontmatter.changed).toEqual([]); + }); +}); + +describe("diffSkills — body headings", () => { + it("flags an added H2 section", async () => { + const body = `# t + +## Alpha + +prose +`; + const bBody = `# t + +## Alpha + +prose + +## Bravo + +more prose +`; + const a = await writeSkill(buildSkillSource({ body }), "a.md"); + const b = await writeSkill(buildSkillSource({ body: bBody }), "b.md"); + const r = await diffSkills(a, b); + expect(r.bodyHeadings.added).toEqual(["Bravo"]); + expect(r.bodyHeadings.removed).toEqual([]); + expect(r.bodyHeadings.reordered).toEqual([]); + }); + + it("flags a removed H3 section", async () => { + const aBody = `# t + +## Alpha + +### deep-dive + +prose +`; + const bBody = `# t + +## Alpha + +prose +`; + const a = await writeSkill(buildSkillSource({ body: aBody }), "a.md"); + const b = await writeSkill(buildSkillSource({ body: bBody }), "b.md"); + const r = await diffSkills(a, b); + expect(r.bodyHeadings.removed).toEqual(["deep-dive"]); + expect(r.bodyHeadings.added).toEqual([]); + }); + + it("flags a reorder when Examples moves above When to use it", async () => { + // Common pattern: an author promotes Examples ahead of When-to-use after + // realising the example is the trigger for the agent. + const aBody = `# t + +## What this skill does + +x + +## When to use it + +y + +## Examples + +z +`; + const bBody = `# t + +## What this skill does + +x + +## Examples + +z + +## When to use it + +y +`; + const a = await writeSkill(buildSkillSource({ body: aBody }), "a.md"); + const b = await writeSkill(buildSkillSource({ body: bBody }), "b.md"); + const r = await diffSkills(a, b); + expect(r.bodyHeadings.added).toEqual([]); + expect(r.bodyHeadings.removed).toEqual([]); + // Two headings swap positions — both report as moved. + const headings = r.bodyHeadings.reordered.map((m) => m.heading).sort(); + expect(headings).toEqual(["Examples", "When to use it"]); + const examples = r.bodyHeadings.reordered.find((m) => m.heading === "Examples"); + expect(examples?.from).toBe(2); + expect(examples?.to).toBe(1); + }); + + it("ignores `## headings` inside fenced code blocks", async () => { + const body = `# t + +## Real + +\`\`\`md +## Not A Heading +\`\`\` + +## Also Real +`; + const a = await writeSkill(buildSkillSource({ body }), "a.md"); + const b = await writeSkill(buildSkillSource({ body }), "b.md"); + const r = await diffSkills(a, b); + expect(r.bodyHeadings.added).toEqual([]); + expect(r.bodyHeadings.removed).toEqual([]); + expect(r.identical).toBe(true); + }); +}); + +describe("diffSkills — body line delta", () => { + it("counts coarse adds/removes on prose-only changes", async () => { + const aBody = `# t + +## Section + +one +two +three +`; + const bBody = `# t + +## Section + +one +two +three +four +five +`; + const a = await writeSkill(buildSkillSource({ body: aBody }), "a.md"); + const b = await writeSkill(buildSkillSource({ body: bBody }), "b.md"); + const r = await diffSkills(a, b); + // Two new lines, none removed. + expect(r.bodyLinesDelta.added).toBe(2); + expect(r.bodyLinesDelta.removed).toBe(0); + expect(r.identical).toBe(false); + }); + + it("does not count trailing-newline differences as a delta", async () => { + // Same body, one with and one without a trailing blank line — the + // multiset normaliser strips the trailing empty so the diff is zero. + const body = `# t + +## Section + +prose +`; + const a = await writeSkill(buildSkillSource({ body }), "a.md"); + const b = await writeSkill(buildSkillSource({ body: `${body}\n` }), "b.md"); + const r = await diffSkills(a, b); + expect(r.bodyLinesDelta).toEqual({ added: 0, removed: 0 }); + }); +}); + +describe("diffSkills — refusal on invalid frontmatter", () => { + it("throws when file A has invalid frontmatter (description too short)", async () => { + const a = await writeSkill(buildSkillSource({ description: "too short" }), "a.md"); + const b = await writeSkill(buildSkillSource(), "b.md"); + await expect(diffSkills(a, b)).rejects.toThrow(/invalid frontmatter/); + }); + + it("throws when file B has invalid frontmatter (bad version)", async () => { + const a = await writeSkill(buildSkillSource(), "a.md"); + const b = await writeSkill(buildSkillSource({ version: "not-a-version" }), "b.md"); + await expect(diffSkills(a, b)).rejects.toThrow(/invalid frontmatter/); + }); +}); + +describe("diffSkills — error paths", () => { + it("throws when file A does not exist", async () => { + const b = await writeSkill(buildSkillSource(), "b.md"); + await expect(diffSkills(join(workDir, "missing.md"), b)).rejects.toThrow(/does not exist/); + }); + + it("throws when file B does not exist", async () => { + const a = await writeSkill(buildSkillSource(), "a.md"); + await expect(diffSkills(a, join(workDir, "missing.md"))).rejects.toThrow(/does not exist/); + }); +}); + +describe("diffSkills — combined changes", () => { + it("produces a coherent report across frontmatter + headings + body lines", async () => { + const aBody = `# t + +## Old Heading + +content +line two +`; + const bBody = `# t + +## New Heading + +content +line two +extra line +`; + const a = await writeSkill(buildSkillSource({ version: "0.1.0", body: aBody }), "a.md"); + const b = await writeSkill( + buildSkillSource({ version: "0.2.0", body: bBody, author: "@x" }), + "b.md", + ); + const r = await diffSkills(a, b); + expect(r.frontmatter.added).toEqual({ author: "@x" }); + expect(r.frontmatter.changed).toEqual([{ key: "version", before: "0.1.0", after: "0.2.0" }]); + expect(r.bodyHeadings.added).toEqual(["New Heading"]); + expect(r.bodyHeadings.removed).toEqual(["Old Heading"]); + expect(r.bodyLinesDelta.added).toBeGreaterThan(0); + expect(r.identical).toBe(false); + }); +}); diff --git a/src/diff.ts b/src/diff.ts new file mode 100644 index 0000000..a402b4e --- /dev/null +++ b/src/diff.ts @@ -0,0 +1,325 @@ +/** + * `skillforge diff ` — structural comparison of two SKILL.md files. + * + * Ninth piece of the authoring workflow after `init`, `validate`, `lint`, + * `pack`, `install`, `update`, `format`, `inspect`. A plain `diff` on a + * SKILL.md file is noisy: a reordered heading, a one-word frontmatter + * change, and a re-wrapped paragraph all look like sprawling churn. `diff` + * pulls the *structural* signal out: which frontmatter fields changed, + * which H2/H3 sections were added/removed/reordered, and a coarse body + * line-count delta. + * + * Side-effect free: this command never writes to disk. Refuses to diff + * either file if its frontmatter doesn't validate — the structural diff + * assumes parseable inputs; broken files should be fixed first. + */ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import matter from "gray-matter"; +import { SkillFrontmatterSchema } from "./schema.js"; + +export interface FrontmatterChange { + key: string; + before: unknown; + after: unknown; +} + +export interface FrontmatterDiff { + /** Fields present in B but not A, keyed by field name. */ + added: Record; + /** Fields present in A but not B, keyed by field name. */ + removed: Record; + /** Fields present in both with non-equal values. */ + changed: FrontmatterChange[]; +} + +export interface HeadingMove { + heading: string; + from: number; + to: number; +} + +export interface BodyHeadingsDiff { + /** Headings present in B but not A. */ + added: string[]; + /** Headings present in A but not B. */ + removed: string[]; + /** + * Headings present in both whose document position shifted. `from` is the + * 0-indexed position in A, `to` is the 0-indexed position in B, computed + * over the set of common headings (i.e. excluding adds/removes so a + * single insert doesn't cascade as N moves). + */ + reordered: HeadingMove[]; +} + +export interface BodyLinesDelta { + /** Lines in B that aren't in A (coarse `+` count). */ + added: number; + /** Lines in A that aren't in B (coarse `-` count). */ + removed: number; +} + +export interface DiffResult { + pathA: string; + pathB: string; + frontmatter: FrontmatterDiff; + bodyHeadings: BodyHeadingsDiff; + bodyLinesDelta: BodyLinesDelta; + /** + * True iff there are zero frontmatter changes, zero heading changes, and + * the body line counts match. A stronger "byte-identical" check would + * also catch prose tweaks; this is the structural-equivalence signal. + */ + identical: boolean; +} + +/** + * Extract level-2 and level-3 headings from the body, in document order, + * skipping anything inside a fenced code block. Mirrors the parser in + * `inspect.ts` but widened from `##` only to `##` + `###` — diff cares + * about subsections too because authors often slice a section into named + * subsections, and treating that as one heading would hide real change. + */ +function extractHeadings(body: string): string[] { + const headings: string[] = []; + const lines = body.split("\n"); + let inFence = false; + const fenceRe = /^\s{0,3}(```+|~~~+)/; + for (const line of lines) { + if (fenceRe.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + const m = line.match(/^(#{2,3})\s+(.+?)\s*$/); + if (m) headings.push(m[2]); + } + return headings; +} + +/** + * Structural equality for frontmatter values. Arrays and plain objects are + * compared by deep value, scalars by `Object.is`. We deliberately avoid + * `JSON.stringify` round-trip — `undefined` inside arrays would silently + * coerce to `null` and falsely report equality. + */ +function valuesEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!valuesEqual(a[i], b[i])) return false; + } + return true; + } + if ( + a !== null && + b !== null && + typeof a === "object" && + typeof b === "object" && + !Array.isArray(a) && + !Array.isArray(b) + ) { + const ao = a as Record; + const bo = b as Record; + const ak = Object.keys(ao); + const bk = Object.keys(bo); + if (ak.length !== bk.length) return false; + for (const k of ak) { + if (!Object.hasOwn(bo, k)) return false; + if (!valuesEqual(ao[k], bo[k])) return false; + } + return true; + } + return false; +} + +function diffFrontmatter(a: Record, b: Record): FrontmatterDiff { + const added: Record = {}; + const removed: Record = {}; + const changed: FrontmatterChange[] = []; + + // Stable key order: union of (A keys, then B-only keys), each set sorted. + // Deterministic output matters for snapshot tests and human scanning. + const aKeys = Object.keys(a).sort(); + const bKeys = Object.keys(b).sort(); + const bSet = new Set(bKeys); + const aSet = new Set(aKeys); + + for (const k of aKeys) { + if (!bSet.has(k)) { + removed[k] = a[k]; + continue; + } + if (!valuesEqual(a[k], b[k])) { + changed.push({ key: k, before: a[k], after: b[k] }); + } + } + for (const k of bKeys) { + if (!aSet.has(k)) { + added[k] = b[k]; + } + } + return { added, removed, changed }; +} + +/** + * Compute heading-level diff: split into pure adds/removes plus a + * "reordered" list for the headings present in both. The reorder + * comparison is done over the *intersection* of heading sets after + * removing duplicates by name — that way one insertion doesn't cascade + * as N moves. If a heading appears multiple times in either file we only + * consider the first occurrence (rare in practice and the alternatives + * all add complexity for marginal gain). + */ +function diffHeadings(a: string[], b: string[]): BodyHeadingsDiff { + const aFirstIdx = new Map(); + const bFirstIdx = new Map(); + a.forEach((h, i) => { + if (!aFirstIdx.has(h)) aFirstIdx.set(h, i); + }); + b.forEach((h, i) => { + if (!bFirstIdx.has(h)) bFirstIdx.set(h, i); + }); + + const added: string[] = []; + const removed: string[] = []; + for (const h of bFirstIdx.keys()) { + if (!aFirstIdx.has(h)) added.push(h); + } + for (const h of aFirstIdx.keys()) { + if (!bFirstIdx.has(h)) removed.push(h); + } + + // Common headings in their A-order vs B-order. We rank each common + // heading by its position within the common-only sequence to avoid the + // cascade-from-insertion problem. + const common = [...aFirstIdx.keys()].filter((h) => bFirstIdx.has(h)); + const aOrder = a.filter((h) => bFirstIdx.has(h)); + const bOrder = b.filter((h) => aFirstIdx.has(h)); + // De-dup while preserving order — the rank is by *first occurrence*. + const aSeq: string[] = []; + const aSeen = new Set(); + for (const h of aOrder) { + if (!aSeen.has(h)) { + aSeen.add(h); + aSeq.push(h); + } + } + const bSeq: string[] = []; + const bSeen = new Set(); + for (const h of bOrder) { + if (!bSeen.has(h)) { + bSeen.add(h); + bSeq.push(h); + } + } + const aRank = new Map(aSeq.map((h, i) => [h, i])); + const bRank = new Map(bSeq.map((h, i) => [h, i])); + + const reordered: HeadingMove[] = []; + for (const h of common) { + const from = aRank.get(h); + const to = bRank.get(h); + if (from !== undefined && to !== undefined && from !== to) { + reordered.push({ heading: h, from, to }); + } + } + + added.sort(); + removed.sort(); + reordered.sort((x, y) => x.heading.localeCompare(y.heading)); + return { added, removed, reordered }; +} + +/** + * Coarse line delta: count of B-only lines vs A-only lines after + * collapsing each side into a multiset. Not a real Myers diff — that's + * what `git diff` is for; this is a one-glance "how much body churn?" + * signal. Whitespace-only lines collapse together; the multiset uses the + * raw line text (trailing whitespace stripped to dodge format-only churn). + */ +function diffBodyLines(a: string, b: string): BodyLinesDelta { + const norm = (s: string) => { + const lines = s.split("\n").map((l) => l.replace(/[ \t]+$/, "")); + // Drop ALL trailing empty lines so files that differ only in their + // trailing newline count don't show a phantom delta. + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + return lines; + }; + + const aLines = norm(a); + const bLines = norm(b); + + // Multiset diff: count occurrences, subtract. + const aCounts = new Map(); + for (const l of aLines) aCounts.set(l, (aCounts.get(l) ?? 0) + 1); + const bCounts = new Map(); + for (const l of bLines) bCounts.set(l, (bCounts.get(l) ?? 0) + 1); + + let added = 0; + let removed = 0; + const keys = new Set([...aCounts.keys(), ...bCounts.keys()]); + for (const k of keys) { + const av = aCounts.get(k) ?? 0; + const bv = bCounts.get(k) ?? 0; + if (bv > av) added += bv - av; + else if (av > bv) removed += av - bv; + } + return { added, removed }; +} + +async function readAndValidate( + path: string, +): Promise<{ data: Record; body: string }> { + if (!existsSync(path)) { + throw new Error(`diff: ${path} does not exist`); + } + const raw = await readFile(path, "utf8"); + const parsed = matter(raw); + const data = (parsed.data ?? {}) as Record; + const result = SkillFrontmatterSchema.safeParse(data); + if (!result.success) { + const detail = result.error.issues + .map((i) => `${i.path.join(".") || ""}: ${i.message}`) + .join("; "); + throw new Error(`diff: ${path} has invalid frontmatter — fix it first (${detail})`); + } + return { data, body: parsed.content }; +} + +/** + * Structural diff of two SKILL.md files. + * + * @throws when either path doesn't exist or either file's frontmatter + * fails schema validation. + */ +export async function diffSkills(pathA: string, pathB: string): Promise { + const [a, b] = await Promise.all([readAndValidate(pathA), readAndValidate(pathB)]); + + const frontmatter = diffFrontmatter(a.data, b.data); + const aHeadings = extractHeadings(a.body); + const bHeadings = extractHeadings(b.body); + const bodyHeadings = diffHeadings(aHeadings, bHeadings); + const bodyLinesDelta = diffBodyLines(a.body, b.body); + + const identical = + Object.keys(frontmatter.added).length === 0 && + Object.keys(frontmatter.removed).length === 0 && + frontmatter.changed.length === 0 && + bodyHeadings.added.length === 0 && + bodyHeadings.removed.length === 0 && + bodyHeadings.reordered.length === 0 && + bodyLinesDelta.added === 0 && + bodyLinesDelta.removed === 0; + + return { + pathA, + pathB, + frontmatter, + bodyHeadings, + bodyLinesDelta, + identical, + }; +} diff --git a/src/index.ts b/src/index.ts index f96f8fe..c8c8295 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,13 @@ /** Public programmatic API. */ +export { + type BodyHeadingsDiff, + type BodyLinesDelta, + type DiffResult, + diffSkills, + type FrontmatterChange, + type FrontmatterDiff, + type HeadingMove, +} from "./diff.js"; export { type FormatOptions, type FormatResult, formatSkill } from "./format.js"; export { initSkill, type InitOptions } from "./init.js"; export {