From 484e61a3ce517a2df09d3e3ca6d92dfc5bea1e4c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 17 May 2026 15:01:53 -0700 Subject: [PATCH] feat(skillpack): reference command + apply-clean-hunks (T4 + T15) reference is the read-only diff lens with an agent-readable framing line. Pure-JS unified-diff producer + parser + applier (no patch(1) dependency). Two-way merge with documented limitation: without scaffold-time base tracking, applied hunks align everything to gbrain. The agent dry-runs reference first, then decides. Co-Authored-By: Claude Opus 4.7 --- src/core/skillpack/apply-hunks.ts | 285 ++++++++++++++++++++++ src/core/skillpack/diff-text.ts | 235 ++++++++++++++++++ src/core/skillpack/reference.ts | 325 +++++++++++++++++++++++++ test/skillpack-apply-hunks.test.ts | 238 ++++++++++++++++++ test/skillpack-reference-apply.test.ts | 221 +++++++++++++++++ test/skillpack-reference.test.ts | 195 +++++++++++++++ 6 files changed, 1499 insertions(+) create mode 100644 src/core/skillpack/apply-hunks.ts create mode 100644 src/core/skillpack/diff-text.ts create mode 100644 src/core/skillpack/reference.ts create mode 100644 test/skillpack-apply-hunks.test.ts create mode 100644 test/skillpack-reference-apply.test.ts create mode 100644 test/skillpack-reference.test.ts diff --git a/src/core/skillpack/apply-hunks.ts b/src/core/skillpack/apply-hunks.ts new file mode 100644 index 000000000..48783ba57 --- /dev/null +++ b/src/core/skillpack/apply-hunks.ts @@ -0,0 +1,285 @@ +/** + * skillpack/apply-hunks.ts — pure-JS unified-diff parser + clean-hunk + * applier (D15, TODO-3 folded). + * + * Used by `gbrain skillpack reference --apply-clean-hunks`. For each + * hunk in a diff between gbrain's bundle and the user's local file, + * apply ONLY when the hunk's pre-change context lines (everything + * prefixed with ' ' or '-') appear as a contiguous block in the user's + * file. If the block is missing or appears more than once, the hunk + * conflicts — skip it and report. + * + * Two-way diff against gbrain's CURRENT bundle (no scaffold-time base + * tracking). Conflicting hunks are skipped, not merged — the agent + * picks them up via the conflict report and merges by hand. This is + * the explicit "agent is the merge driver" contract. + * + * No system `patch(1)` dependency — portable to every gbrain target. + */ + +export interface Hunk { + /** Old-file start line (1-indexed). Informational. */ + oldStart: number; + /** Old-file line count. */ + oldCount: number; + /** New-file start line (1-indexed). Informational. */ + newStart: number; + /** New-file line count. */ + newCount: number; + /** Hunk body lines, prefix-preserved (' ', '-', '+'). */ + lines: string[]; + /** Whether this hunk's pre-change text lacked a final newline. */ + oldNoNewlineAtEnd: boolean; + /** Whether this hunk's post-change text lacks a final newline. */ + newNoNewlineAtEnd: boolean; +} + +export interface ParsedDiff { + hunks: Hunk[]; +} + +export class ApplyHunksError extends Error { + constructor(message: string, public code: 'parse_error') { + super(message); + this.name = 'ApplyHunksError'; + } +} + +/** + * Parse a unified-diff string into hunks. Tolerates leading file + * headers (`--- a/...` / `+++ b/...`) but doesn't require them. Throws + * `ApplyHunksError(parse_error)` on malformed hunk headers. + */ +export function parseUnifiedDiff(text: string): ParsedDiff { + const hunks: Hunk[] = []; + if (text.length === 0) return { hunks }; + const lines = text.split('\n'); + // Strip a trailing empty entry that arises when text ends with '\n'. + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + // Skip file headers and any inter-hunk junk until we hit a hunk header. + if (!line.startsWith('@@')) { + i += 1; + continue; + } + + // Hunk header: @@ -aStart,aCount +bStart,bCount @@ + const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (!m) { + throw new ApplyHunksError(`Malformed hunk header: ${line}`, 'parse_error'); + } + const oldStart = parseInt(m[1], 10); + const oldCount = m[2] !== undefined ? parseInt(m[2], 10) : 1; + const newStart = parseInt(m[3], 10); + const newCount = m[4] !== undefined ? parseInt(m[4], 10) : 1; + + i += 1; + const body: string[] = []; + let oldNoNewlineAtEnd = false; + let newNoNewlineAtEnd = false; + + // Collect body lines until we hit the next hunk header, file header, + // or end of input. Track `\ No newline at end of file` markers and + // attribute them to the line they follow. Don't break early on + // line-counts being met — a `\` marker can legitimately follow the + // last body line. + let aSeen = 0; + let bSeen = 0; + while (i < lines.length) { + const ln = lines[i]; + if (ln.startsWith('@@') || ln.startsWith('---') || ln.startsWith('+++')) break; + if (ln === '\\ No newline at end of file') { + // Attribute to the previous body line — was it from a or b? + if (body.length > 0) { + const prev = body[body.length - 1].charAt(0); + if (prev === '-' || prev === ' ') oldNoNewlineAtEnd = true; + if (prev === '+' || prev === ' ') newNoNewlineAtEnd = true; + } + i += 1; + continue; + } + const c = ln.charAt(0); + if (c === ' ') { + aSeen += 1; + bSeen += 1; + } else if (c === '-') aSeen += 1; + else if (c === '+') bSeen += 1; + else { + // Unknown prefix; tolerate as context (gnu diff sometimes emits + // empty-prefix blank lines for an empty context line). + body.push(' ' + ln); + aSeen += 1; + bSeen += 1; + i += 1; + continue; + } + body.push(ln); + i += 1; + // Once counts are met, peek ahead: if the next line is a `\` + // marker, keep going to consume it. Otherwise we're done with + // this hunk. + if (aSeen >= oldCount && bSeen >= newCount) { + if (i >= lines.length || lines[i] !== '\\ No newline at end of file') { + break; + } + // else loop continues and consumes the marker + } + } + + hunks.push({ + oldStart, + oldCount, + newStart, + newCount, + lines: body, + oldNoNewlineAtEnd, + newNoNewlineAtEnd, + }); + } + + return { hunks }; +} + +export interface ApplyHunksResult { + /** Final text after applying clean hunks (and leaving conflicts in place). */ + text: string; + /** Number of hunks applied. */ + applied: number; + /** Number of hunks skipped due to conflict. */ + conflicted: number; + /** Per-hunk applied/skipped flag (in hunk order). */ + outcomes: Array<{ + hunk: number; + status: 'applied' | 'conflict_missing' | 'conflict_ambiguous'; + }>; +} + +/** + * Apply a parsed diff to a target file's text. For each hunk: + * - Extract the pre-change block (the ' ' and '-' lines, in order). + * - Search the target text for that block as a contiguous run. + * - Found exactly once → replace with the post-change block (' ' and '+'). + * - Not found → conflict_missing (skip). + * - Found 2+ times → conflict_ambiguous (skip; refuse to guess). + * + * Multiple-hunk diffs are applied in order. Each successful apply + * mutates the in-memory text so subsequent hunks see the updated + * state. Conflicts don't poison subsequent hunks — they get a clean + * shot at the post-apply text. + */ +export function applyHunks(targetText: string, diff: ParsedDiff): ApplyHunksResult { + let currentText = targetText; + let applied = 0; + let conflicted = 0; + const outcomes: ApplyHunksResult['outcomes'] = []; + + for (let i = 0; i < diff.hunks.length; i++) { + const hunk = diff.hunks[i]; + const beforeLines: string[] = []; + const afterLines: string[] = []; + for (const ln of hunk.lines) { + const c = ln.charAt(0); + const body = ln.slice(1); + if (c === ' ') { + beforeLines.push(body); + afterLines.push(body); + } else if (c === '-') { + beforeLines.push(body); + } else if (c === '+') { + afterLines.push(body); + } + } + + // Empty pre-change (pure addition at start of file or end of file) + // — apply at line oldStart-1 (0-indexed). Unambiguous since there's + // nothing to match. + if (beforeLines.length === 0) { + currentText = applyPureAddition(currentText, afterLines, hunk); + applied += 1; + outcomes.push({ hunk: i, status: 'applied' }); + continue; + } + + const split = splitTextPreserveTrailing(currentText); + const matches = findAllMatches(split.lines, beforeLines); + if (matches.length === 0) { + conflicted += 1; + outcomes.push({ hunk: i, status: 'conflict_missing' }); + continue; + } + if (matches.length > 1) { + conflicted += 1; + outcomes.push({ hunk: i, status: 'conflict_ambiguous' }); + continue; + } + + // Found exactly once — splice in the after-lines. + const at = matches[0]; + const newLines = [ + ...split.lines.slice(0, at), + ...afterLines, + ...split.lines.slice(at + beforeLines.length), + ]; + // Trailing-newline behavior: if hunk says newNoNewlineAtEnd AND the + // hunk's range covers the end of the file, the result loses its + // trailing newline. Otherwise preserve whatever the file had. + const coversEnd = at + beforeLines.length === split.lines.length; + const trailingNewline = coversEnd + ? !hunk.newNoNewlineAtEnd + : split.trailingNewline; + currentText = joinLines(newLines, trailingNewline); + applied += 1; + outcomes.push({ hunk: i, status: 'applied' }); + } + + return { text: currentText, applied, conflicted, outcomes }; +} + +function applyPureAddition(text: string, additions: string[], hunk: Hunk): string { + const split = splitTextPreserveTrailing(text); + // Pure addition at oldStart (1-indexed, before any content). If + // oldStart is 0 or 1 with oldCount 0, insert at the file start. + const insertAt = Math.max(0, hunk.oldStart - 1); + const newLines = [...split.lines.slice(0, insertAt), ...additions, ...split.lines.slice(insertAt)]; + const trailingNewline = + insertAt === split.lines.length ? !hunk.newNoNewlineAtEnd : split.trailingNewline; + return joinLines(newLines, trailingNewline); +} + +interface SplitText { + lines: string[]; + trailingNewline: boolean; +} + +function splitTextPreserveTrailing(text: string): SplitText { + if (text.length === 0) return { lines: [], trailingNewline: true }; + const trailingNewline = text.endsWith('\n'); + const body = trailingNewline ? text.slice(0, -1) : text; + return { lines: body.split('\n'), trailingNewline }; +} + +function joinLines(lines: string[], trailingNewline: boolean): string { + if (lines.length === 0) return trailingNewline ? '' : ''; + return lines.join('\n') + (trailingNewline ? '\n' : ''); +} + +/** + * Return every starting index in `haystack` at which `needle` matches + * as a contiguous block. Naive O(n*m) scan — fine for skill files. + */ +function findAllMatches(haystack: string[], needle: string[]): number[] { + if (needle.length === 0) return []; + if (needle.length > haystack.length) return []; + const matches: number[] = []; + outer: for (let i = 0; i <= haystack.length - needle.length; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer; + } + matches.push(i); + } + return matches; +} diff --git a/src/core/skillpack/diff-text.ts b/src/core/skillpack/diff-text.ts new file mode 100644 index 000000000..f94befe34 --- /dev/null +++ b/src/core/skillpack/diff-text.ts @@ -0,0 +1,235 @@ +/** + * skillpack/diff-text.ts — minimal pure-JS unified-diff producer. + * + * Used by: + * - `gbrain skillpack reference` (T4) to present per-file diffs to + * the agent so it can decide what to integrate. + * - `gbrain skillpack reference --apply-clean-hunks` (T15) as the + * producer side; the applier in `apply-hunks.ts` parses the same + * format on the consumer side. + * + * Algorithm: line-based LCS (Hunt–McIlroy), unified-diff output with a + * configurable context window (default 3). Zero deps. Predictable + * output across runs so the apply-clean-hunks round-trip is honest. + */ + +const DEFAULT_CONTEXT = 3; + +export interface UnifiedDiffOpts { + /** Lines of context around each hunk. Default 3. */ + context?: number; + /** Path label printed in the `--- a/...` header. Defaults to "a". */ + oldPath?: string; + /** Path label printed in the `+++ b/...` header. Defaults to "b". */ + newPath?: string; +} + +export function unifiedDiff(a: string, b: string, opts: UnifiedDiffOpts = {}): string { + if (a === b) return ''; + const context = opts.context ?? DEFAULT_CONTEXT; + const oldPath = opts.oldPath ?? 'a'; + const newPath = opts.newPath ?? 'b'; + + const aSplit = splitLines(a); + const bSplit = splitLines(b); + + const ops = diffLines(aSplit.lines, bSplit.lines); + if (ops.length === 0) return ''; + + // Step 1: walk ops, attach per-op (aIndex, bIndex) for header math. + // aIndex = 0-indexed line position in `a` that this op consumes. + // bIndex = 0-indexed line position in `b` that this op consumes. + interface AnnotatedOp { + op: DiffOp; + aIdx: number; // valid for kind='equal' or 'del' + bIdx: number; // valid for kind='equal' or 'add' + } + const ann: AnnotatedOp[] = []; + { + let ai = 0; + let bi = 0; + for (const op of ops) { + ann.push({ op, aIdx: ai, bIdx: bi }); + if (op.kind === 'equal') { + ai += 1; + bi += 1; + } else if (op.kind === 'del') ai += 1; + else if (op.kind === 'add') bi += 1; + } + } + + // Step 2: identify hunk ranges. A hunk spans from `context` equals + // before the first change to `context` equals after the last change, + // with consecutive change-groups within 2*context equals merged. + interface Range { start: number; end: number; } // inclusive op indices + + // First find every "change" op index. + const changes: number[] = []; + for (let k = 0; k < ann.length; k++) { + if (ann[k].op.kind !== 'equal') changes.push(k); + } + if (changes.length === 0) return ''; + + // Build merged ranges. + const ranges: Range[] = []; + let curStart = Math.max(0, changes[0] - context); + let curEnd = changes[0]; + for (let c = 1; c < changes.length; c++) { + const want = Math.max(0, changes[c] - context); + // If extending the current range's context forward covers the gap, + // merge. Coverage check: previous change's end-of-context >= next + // change's start-of-context. + if (curEnd + context >= want) { + curEnd = changes[c]; + } else { + ranges.push({ start: curStart, end: curEnd }); + curStart = want; + curEnd = changes[c]; + } + } + ranges.push({ start: curStart, end: curEnd }); + + // Step 3: extend each range's end forward by `context` equals. + for (const r of ranges) { + let endIdx = r.end; + let added = 0; + while (endIdx + 1 < ann.length && added < context) { + endIdx += 1; + if (ann[endIdx].op.kind === 'equal') added += 1; + } + r.end = endIdx; + } + + // Step 4: emit hunks. + const out: string[] = []; + out.push(`--- ${oldPath}`); + out.push(`+++ ${newPath}`); + + for (const r of ranges) { + let aStart = -1; + let bStart = -1; + let aCount = 0; + let bCount = 0; + const body: string[] = []; + for (let k = r.start; k <= r.end; k++) { + const { op, aIdx, bIdx } = ann[k]; + if (op.kind === 'equal') { + if (aStart === -1) { + aStart = aIdx; + bStart = bIdx; + } + body.push(' ' + op.line); + aCount += 1; + bCount += 1; + } else if (op.kind === 'del') { + if (aStart === -1) { + aStart = aIdx; + bStart = bIdx; + } + body.push('-' + op.line); + aCount += 1; + } else if (op.kind === 'add') { + if (aStart === -1) { + aStart = aIdx; + bStart = bIdx; + } + body.push('+' + op.line); + bCount += 1; + } + } + // Empty file edge cases — if no ops emitted (shouldn't happen), + // skip the hunk. + if (body.length === 0) continue; + + // 1-indexed line numbers in the header. + out.push(`@@ -${aStart + 1},${aCount} +${bStart + 1},${bCount} @@`); + for (const ln of body) out.push(ln); + } + + // Trailing-newline markers. + if (!aSplit.trailingNewline) { + // Insert `\ No newline at end of file` after the last ' ' or '-' line. + for (let i = out.length - 1; i >= 0; i--) { + const c = out[i].charAt(0); + if (c === ' ' || c === '-') { + out.splice(i + 1, 0, '\\ No newline at end of file'); + break; + } + } + } + if (!bSplit.trailingNewline) { + for (let i = out.length - 1; i >= 0; i--) { + const c = out[i].charAt(0); + if (c === ' ' || c === '+') { + out.splice(i + 1, 0, '\\ No newline at end of file'); + break; + } + } + } + + return out.join('\n') + '\n'; +} + +interface LineSplit { + lines: string[]; + trailingNewline: boolean; +} + +function splitLines(s: string): LineSplit { + if (s.length === 0) return { lines: [], trailingNewline: true }; + const trailingNewline = s.endsWith('\n'); + const body = trailingNewline ? s.slice(0, -1) : s; + return { lines: body.split('\n'), trailingNewline }; +} + +interface DiffOp { + kind: 'equal' | 'del' | 'add'; + line: string; +} + +/** + * Line-level LCS-driven diff. Classic O(N*M) dynamic programming. + */ +function diffLines(a: string[], b: string[]): DiffOp[] { + const n = a.length; + const m = b.length; + const lcs: number[][] = Array.from({ length: n + 1 }, () => + new Array(m + 1).fill(0), + ); + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (a[i - 1] === b[j - 1]) { + lcs[i][j] = lcs[i - 1][j - 1] + 1; + } else { + lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]); + } + } + } + + const ops: DiffOp[] = []; + let i = n; + let j = m; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + ops.push({ kind: 'equal', line: a[i - 1] }); + i -= 1; + j -= 1; + } else if (lcs[i - 1][j] >= lcs[i][j - 1]) { + ops.push({ kind: 'del', line: a[i - 1] }); + i -= 1; + } else { + ops.push({ kind: 'add', line: b[j - 1] }); + j -= 1; + } + } + while (i > 0) { + ops.push({ kind: 'del', line: a[i - 1] }); + i -= 1; + } + while (j > 0) { + ops.push({ kind: 'add', line: b[j - 1] }); + j -= 1; + } + ops.reverse(); + return ops; +} diff --git a/src/core/skillpack/reference.ts b/src/core/skillpack/reference.ts new file mode 100644 index 000000000..4ebbe40df --- /dev/null +++ b/src/core/skillpack/reference.ts @@ -0,0 +1,325 @@ +/** + * skillpack/reference.ts — `gbrain skillpack reference `. + * + * Read-only update lens. Compares every file in gbrain's bundle (skill + * dir + paired sources + shared deps) against the host's local copy + * and emits per-file status + unified diffs. + * + * The agent reads this output and decides what to integrate. gbrain + * does NOT auto-apply (that's `reference --apply-clean-hunks` in T15, + * which composes a separate apply step on top). + * + * Framing line is the load-bearing signal: "these are references; your + * local edits are intentional; do not blindly overwrite." + */ + +import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { + enumerateScaffoldEntries, + loadBundleManifest, + bundledSkillSlugs, +} from './bundle.ts'; +import type { ScaffoldEntry } from './bundle.ts'; +import { unifiedDiff } from './diff-text.ts'; +import { applyHunks, parseUnifiedDiff } from './apply-hunks.ts'; + +export interface ReferenceOptions { + /** Absolute path to gbrain repo root (source-of-truth bundle). */ + gbrainRoot: string; + /** Absolute path to the target workspace. */ + targetWorkspace: string; + /** Single skill slug, or `null` for --all (one-line-per-skill summary). */ + skillSlug: string | null; +} + +export type ReferenceStatus = 'identical' | 'differs' | 'missing'; + +export interface ReferenceFileResult { + source: string; + target: string; + status: ReferenceStatus; + sharedDep: boolean; + pairedSource: boolean; + /** Unified diff text when status is 'differs'. Empty otherwise. */ + unifiedDiff: string; + /** Byte sizes for quick scanning. */ + sourceBytes: number; + targetBytes: number; +} + +export interface ReferenceResult { + /** Agent-readable framing line — load-bearing for the new model. */ + framing: string; + files: ReferenceFileResult[]; + summary: { + identical: number; + differs: number; + missing: number; + }; +} + +export interface ReferenceAllResult { + framing: string; + skills: Array<{ + slug: string; + summary: { identical: number; differs: number; missing: number }; + }>; +} + +const FRAMING_TEMPLATE = (refPath: string): string => + `These files live at ${refPath} as reference. Read them and decide what (if anything) to integrate into your local skills/. Your local edits are intentional — do not blindly overwrite.`; + +/** + * Run reference against a single skill. Returns per-file status + + * unified diffs. + */ +export function runReference(opts: ReferenceOptions): ReferenceResult { + if (opts.skillSlug === null) { + throw new Error('runReference requires a slug; use runReferenceAll() for --all'); + } + const manifest = loadBundleManifest(opts.gbrainRoot); + const entries = enumerateScaffoldEntries({ + gbrainRoot: opts.gbrainRoot, + skillSlug: opts.skillSlug, + manifest, + }); + + const files = entries.map(e => diffOne(opts.targetWorkspace, e)); + const framing = FRAMING_TEMPLATE(`${opts.gbrainRoot}/skills/${opts.skillSlug}/`); + return { + framing, + files, + summary: { + identical: files.filter(f => f.status === 'identical').length, + differs: files.filter(f => f.status === 'differs').length, + missing: files.filter(f => f.status === 'missing').length, + }, + }; +} + +/** + * Run reference across every bundled skill — one-line-per-skill summary. + * Used by `gbrain skillpack reference --all` for the upgrade sweep + * workflow. + */ +export function runReferenceAll(opts: Omit): ReferenceAllResult { + const manifest = loadBundleManifest(opts.gbrainRoot); + const slugs = bundledSkillSlugs(manifest); + const skills = slugs.map(slug => { + const entries = enumerateScaffoldEntries({ + gbrainRoot: opts.gbrainRoot, + skillSlug: slug, + manifest, + }); + const files = entries.map(e => diffOne(opts.targetWorkspace, e)); + return { + slug, + summary: { + identical: files.filter(f => f.status === 'identical').length, + differs: files.filter(f => f.status === 'differs').length, + missing: files.filter(f => f.status === 'missing').length, + }, + }; + }); + return { + framing: FRAMING_TEMPLATE(`${opts.gbrainRoot}/skills/`), + skills, + }; +} + +// --------------------------------------------------------------------------- +// `reference --apply-clean-hunks` (D15, TODO-3 folded) +// --------------------------------------------------------------------------- + +export interface ReferenceApplyOptions extends ReferenceOptions { + /** Dry-run: compute the apply, report outcomes, don't write. */ + dryRun?: boolean; +} + +export interface ReferenceApplyFileResult { + source: string; + target: string; + status: 'identical' | 'missing' | 'applied_clean' | 'partial' | 'binary_skip'; + /** Hunks applied to the target. 0 when identical / missing / binary. */ + hunksApplied: number; + /** Hunks that conflicted and were left alone. */ + hunksConflicted: number; + /** Detail entries for each conflicting hunk — `file:line: Manual merge needed.` */ + conflicts: string[]; + sharedDep: boolean; + pairedSource: boolean; +} + +export interface ReferenceApplyResult { + framing: string; + dryRun: boolean; + files: ReferenceApplyFileResult[]; + summary: { + filesApplied: number; + filesPartial: number; + filesIdentical: number; + filesMissing: number; + filesBinarySkipped: number; + totalHunksApplied: number; + totalHunksConflicted: number; + }; +} + +/** + * Run `reference --apply-clean-hunks`. For each `differs` file, parses + * the diff between the user's local copy (target) and gbrain's bundle + * (source), then applies every hunk whose pre-change context appears + * uniquely in the user's file. Writes the result back. Conflicting + * hunks are left alone and reported. + * + * **Two-way merge limitation (D15 contract).** Without scaffold-time + * base tracking, this command cannot distinguish "gbrain changed line + * X" from "the user changed line X." Both look like differences from + * gbrain's current bundle. Applied hunks therefore replace the user's + * local content with gbrain's wherever they differ, including spots + * the user intentionally edited. Use `--dry-run` first, or run + * `gbrain skillpack reference ` to inspect the diff before + * applying. True three-way merge with scaffold-time base is in the + * NOT-in-scope section of the v0.33 plan. + * + * Missing target files are NOT created here — scaffold's job. Binary + * files are skipped (can't text-merge). + */ +export function runReferenceApply(opts: ReferenceApplyOptions): ReferenceApplyResult { + if (opts.skillSlug === null) { + throw new Error( + 'runReferenceApply requires a slug; --all+--apply-clean-hunks is intentionally not supported (apply one skill at a time)', + ); + } + const manifest = loadBundleManifest(opts.gbrainRoot); + const entries = enumerateScaffoldEntries({ + gbrainRoot: opts.gbrainRoot, + skillSlug: opts.skillSlug, + manifest, + }); + + const files: ReferenceApplyFileResult[] = []; + for (const entry of entries) { + files.push(applyOne(opts.targetWorkspace, entry, opts.dryRun ?? false)); + } + + return { + framing: FRAMING_TEMPLATE(`${opts.gbrainRoot}/skills/${opts.skillSlug}/`), + dryRun: opts.dryRun ?? false, + files, + summary: { + filesApplied: files.filter(f => f.status === 'applied_clean').length, + filesPartial: files.filter(f => f.status === 'partial').length, + filesIdentical: files.filter(f => f.status === 'identical').length, + filesMissing: files.filter(f => f.status === 'missing').length, + filesBinarySkipped: files.filter(f => f.status === 'binary_skip').length, + totalHunksApplied: files.reduce((s, f) => s + f.hunksApplied, 0), + totalHunksConflicted: files.reduce((s, f) => s + f.hunksConflicted, 0), + }, + }; +} + +function applyOne( + targetWorkspace: string, + entry: ScaffoldEntry, + dryRun: boolean, +): ReferenceApplyFileResult { + const target = join(targetWorkspace, entry.relWorkspaceTarget); + const base = { + source: entry.source, + target, + hunksApplied: 0, + hunksConflicted: 0, + conflicts: [] as string[], + sharedDep: entry.sharedDep, + pairedSource: entry.pairedSource, + }; + + if (!existsSync(target)) { + return { ...base, status: 'missing' }; + } + const aBuf = readFileSync(entry.source); + const bBuf = readFileSync(target); + if (aBuf.equals(bBuf)) { + return { ...base, status: 'identical' }; + } + if (aBuf.includes(0) || bBuf.includes(0)) { + return { ...base, status: 'binary_skip' }; + } + + const diff = unifiedDiff(aBuf.toString('utf-8'), bBuf.toString('utf-8')); + // The diff above is target→source (b→a). We need source→target for + // apply: gbrain (a) is what we want to bring into the target. So + // recompute with the operands swapped. + const diffApply = unifiedDiff(bBuf.toString('utf-8'), aBuf.toString('utf-8')); + const parsed = parseUnifiedDiff(diffApply); + const result = applyHunks(bBuf.toString('utf-8'), parsed); + + // Conflict locations: report by zero-based hunk index + line range. + for (let i = 0; i < result.outcomes.length; i++) { + const o = result.outcomes[i]; + if (o.status === 'applied') continue; + const h = parsed.hunks[i]; + base.conflicts.push( + `${target}:${h.oldStart},${h.oldCount}: Manual merge needed (${o.status}).`, + ); + } + + base.hunksApplied = result.applied; + base.hunksConflicted = result.conflicted; + + if (!dryRun && result.applied > 0) { + writeFileSync(target, result.text); + } + + const status: ReferenceApplyFileResult['status'] = + result.applied > 0 && result.conflicted === 0 + ? 'applied_clean' + : 'partial'; + return { ...base, status }; +} + +function diffOne(targetWorkspace: string, entry: ScaffoldEntry): ReferenceFileResult { + const target = join(targetWorkspace, entry.relWorkspaceTarget); + const sourceBytes = statSync(entry.source).size; + let targetBytes = 0; + let status: ReferenceStatus; + let diffText = ''; + + if (!existsSync(target)) { + status = 'missing'; + } else { + const aBuf = readFileSync(entry.source); + const bBuf = readFileSync(target); + targetBytes = bBuf.length; + if (aBuf.equals(bBuf)) { + status = 'identical'; + } else { + status = 'differs'; + // Best-effort textual diff. Binary files fall through to a stub + // (binary detection: any NUL byte in either buffer). + const isBinary = aBuf.includes(0) || bBuf.includes(0); + if (isBinary) { + diffText = `Binary files differ (a: ${sourceBytes}B, b: ${targetBytes}B). Use a binary-aware tool to inspect.\n`; + } else { + diffText = unifiedDiff(aBuf.toString('utf-8'), bBuf.toString('utf-8'), { + oldPath: `a/${entry.relWorkspaceTarget}`, + newPath: `b/${entry.relWorkspaceTarget}`, + }); + } + } + } + + return { + source: entry.source, + target, + status, + sharedDep: entry.sharedDep, + pairedSource: entry.pairedSource, + unifiedDiff: diffText, + sourceBytes, + targetBytes, + }; +} diff --git a/test/skillpack-apply-hunks.test.ts b/test/skillpack-apply-hunks.test.ts new file mode 100644 index 000000000..887e6068e --- /dev/null +++ b/test/skillpack-apply-hunks.test.ts @@ -0,0 +1,238 @@ +/** + * Tests for src/core/skillpack/apply-hunks.ts — the pure-JS unified-diff + * parser + clean-hunk applier (D15, TODO-3 folded). + * + * Pins: + * - parse: well-formed hunks, multi-hunk diffs, no-newline marker + * - apply: clean hunk applies, conflicts skip, ambiguous matches skip + * - round-trip: produce a diff with diff-text.ts, apply it cleanly + * - parse_error: malformed hunk header throws + */ + +import { describe, expect, it } from 'bun:test'; + +import { unifiedDiff } from '../src/core/skillpack/diff-text.ts'; +import { + ApplyHunksError, + applyHunks, + parseUnifiedDiff, +} from '../src/core/skillpack/apply-hunks.ts'; + +describe('parseUnifiedDiff', () => { + it('returns empty hunks for empty input', () => { + expect(parseUnifiedDiff('').hunks).toEqual([]); + }); + + it('parses a single well-formed hunk', () => { + const text = `--- a/file ++++ b/file +@@ -1,3 +1,4 @@ + line A +-line B ++line B updated ++line C added + line D +`; + const parsed = parseUnifiedDiff(text); + expect(parsed.hunks).toHaveLength(1); + expect(parsed.hunks[0].oldStart).toBe(1); + expect(parsed.hunks[0].oldCount).toBe(3); + expect(parsed.hunks[0].newStart).toBe(1); + expect(parsed.hunks[0].newCount).toBe(4); + }); + + it('parses a no-newline marker', () => { + const text = `@@ -1,1 +1,1 @@ +-old ++new +\\ No newline at end of file +`; + const parsed = parseUnifiedDiff(text); + expect(parsed.hunks[0].newNoNewlineAtEnd).toBe(true); + }); + + it('throws on malformed hunk header', () => { + const text = `@@ malformed header @@\n-x\n+y\n`; + expect(() => parseUnifiedDiff(text)).toThrow(ApplyHunksError); + }); + + it('parses multi-hunk diffs', () => { + const text = `@@ -1,3 +1,3 @@ + first +-changed-a ++changed-b + third +@@ -10,2 +10,3 @@ + tenth ++new-line + eleventh +`; + const parsed = parseUnifiedDiff(text); + expect(parsed.hunks).toHaveLength(2); + expect(parsed.hunks[1].oldStart).toBe(10); + }); +}); + +describe('applyHunks — happy path', () => { + it('applies a clean hunk when context matches exactly', () => { + const target = 'line A\nline B\nline C\nline D\n'; + const diff = parseUnifiedDiff(`@@ -1,4 +1,5 @@ + line A +-line B ++line B updated ++line B-extra + line C + line D +`); + const result = applyHunks(target, diff); + expect(result.applied).toBe(1); + expect(result.conflicted).toBe(0); + expect(result.text).toBe('line A\nline B updated\nline B-extra\nline C\nline D\n'); + }); + + it('applies multi-hunk diffs in order', () => { + const target = 'a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\n'; + const diff = parseUnifiedDiff(`@@ -1,3 +1,3 @@ + a +-b ++B + c +@@ -9,3 +9,3 @@ + i +-j ++J + k +`); + const result = applyHunks(target, diff); + expect(result.applied).toBe(2); + expect(result.text).toBe('a\nB\nc\nd\ne\nf\ng\nh\ni\nJ\nk\nl\n'); + }); +}); + +describe('applyHunks — conflict detection', () => { + it('conflict_missing: pre-change block not found in target', () => { + const target = 'completely different content\nthat does not match\n'; + const diff = parseUnifiedDiff(`@@ -1,3 +1,3 @@ + line A +-line B ++line B updated + line C +`); + const result = applyHunks(target, diff); + expect(result.applied).toBe(0); + expect(result.conflicted).toBe(1); + expect(result.outcomes[0].status).toBe('conflict_missing'); + expect(result.text).toBe(target); // unchanged + }); + + it('conflict_ambiguous: pre-change block appears more than once', () => { + const target = 'pattern X\nbody\npattern X\nbody\n'; + const diff = parseUnifiedDiff(`@@ -1,2 +1,2 @@ + pattern X +-body ++BODY +`); + const result = applyHunks(target, diff); + expect(result.applied).toBe(0); + expect(result.conflicted).toBe(1); + expect(result.outcomes[0].status).toBe('conflict_ambiguous'); + }); + + it('mixed: clean hunk applies even when sibling hunk conflicts', () => { + const target = 'line A\nline B\nline C\n'; + const diff = parseUnifiedDiff(`@@ -1,3 +1,3 @@ + line A +-line B ++line B updated + line C +@@ -100,2 +100,2 @@ + not present +-also not ++nope +`); + const result = applyHunks(target, diff); + expect(result.applied).toBe(1); + expect(result.conflicted).toBe(1); + expect(result.text).toBe('line A\nline B updated\nline C\n'); + }); +}); + +describe('round-trip: unifiedDiff produces output parseUnifiedDiff + applyHunks consume', () => { + it('full round-trip yields the new file when applied to the old file', () => { + const oldText = 'one\ntwo\nthree\nfour\nfive\n'; + const newText = 'one\nTWO updated\nthree\nfour\nFIVE updated\n'; + + const diff = unifiedDiff(oldText, newText); + const parsed = parseUnifiedDiff(diff); + const result = applyHunks(oldText, parsed); + + expect(result.text).toBe(newText); + expect(result.conflicted).toBe(0); + }); + + it('round-trip works with multi-line additions/deletions', () => { + const oldText = Array.from({ length: 10 }, (_, i) => `line${i + 1}`).join('\n') + '\n'; + const newText = oldText.replace('line5', 'line5\nINSERTED\nALSO INSERTED'); + + const diff = unifiedDiff(oldText, newText); + const result = applyHunks(oldText, parseUnifiedDiff(diff)); + expect(result.text).toBe(newText); + }); + + it('user-edited file: identical diff applies clean to unrelated section', () => { + // gbrain bundle and user file both have lines 1-20. Bundle changed + // line 5; user changed line 15. Distance is > 2*context, so the + // hunk's post-context never reaches the user's edit. Apply succeeds. + const gbrainOld = + Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join('\n') + '\n'; + const gbrainNew = gbrainOld.replace('line5\n', 'line5 GBRAIN\n'); + const userFile = gbrainOld.replace('line15\n', 'line15 USER\n'); + + const diff = unifiedDiff(gbrainOld, gbrainNew); + const result = applyHunks(userFile, parseUnifiedDiff(diff)); + + expect(result.applied).toBe(1); + expect(result.text).toContain('line5 GBRAIN'); + expect(result.text).toContain('line15 USER'); + }); +}); + +describe('applyHunks — trailing-newline edge cases', () => { + it('apply-clean adds newline when diff has no `\\` marker (strict patch semantic)', () => { + // Target lacks trailing newline; diff doesn't carry a `\ No newline` + // marker, so the diff implies the file ends with one. Apply normalizes + // to the diff's view of the file. Standard `patch(1)` semantic. + const target = 'a\nb\nc'; // no final newline + const diff = parseUnifiedDiff(`@@ -1,3 +1,3 @@ + a +-b ++B + c +`); + const result = applyHunks(target, diff); + expect(result.text).toBe('a\nB\nc\n'); + }); + + it('apply-clean preserves no-newline when diff carries the marker', () => { + const target = 'a\nb\nc'; // no final newline + const diff = parseUnifiedDiff(`@@ -1,3 +1,3 @@ + a +-b ++B + c +\\ No newline at end of file +`); + const result = applyHunks(target, diff); + expect(result.text).toBe('a\nB\nc'); + }); +}); + +describe('applyHunks — pure additions and edge cases', () => { + it('identical files: empty diff is a no-op', () => { + const text = 'identical\n'; + const diff = parseUnifiedDiff(unifiedDiff(text, text)); + const result = applyHunks(text, diff); + expect(result.text).toBe(text); + expect(result.applied).toBe(0); + }); +}); diff --git a/test/skillpack-reference-apply.test.ts b/test/skillpack-reference-apply.test.ts new file mode 100644 index 000000000..fe0186d59 --- /dev/null +++ b/test/skillpack-reference-apply.test.ts @@ -0,0 +1,221 @@ +/** + * Tests for `reference --apply-clean-hunks` (D15, TODO-3 folded). + * + * Pins: + * - clean apply: user's local file gets gbrain's upstream changes + * where context is unchanged + * - conflict reporting: conflicting hunks listed with file:line+kind + * - identical / missing / binary files reported, not touched + * - dry-run: outcomes computed, no writes + * - paired source files included + * - --all is intentionally NOT supported (apply one skill at a time) + */ + +import { describe, expect, it, afterEach } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import { runReferenceApply } from '../src/core/skillpack/reference.ts'; +import { runScaffold } from '../src/core/skillpack/scaffold.ts'; + +const created: string[] = []; +afterEach(() => { + while (created.length) { + const p = created.pop()!; + try { + rmSync(p, { recursive: true, force: true }); + } catch {} + } +}); + +function scratchGbrain(): string { + const root = mkdtempSync(join(tmpdir(), 'sp-refapply-gbrain-')); + created.push(root); + mkdirSync(join(root, 'src', 'commands'), { recursive: true }); + writeFileSync(join(root, 'src', 'cli.ts'), '// stub'); + + mkdirSync(join(root, 'skills', 'demo'), { recursive: true }); + // Long SKILL.md so the diff has well-isolated hunks. + const baseLines = Array.from({ length: 30 }, (_, i) => `Line ${i + 1}`).join('\n') + '\n'; + writeFileSync(join(root, 'skills', 'demo', 'SKILL.md'), baseLines); + + writeFileSync( + join(root, 'openclaw.plugin.json'), + JSON.stringify( + { + name: 'gbrain-test', + version: '0.33.0-test', + skills: ['skills/demo'], + shared_deps: [], + }, + null, + 2, + ), + ); + return root; +} + +function scratchWorkspace(): string { + const ws = mkdtempSync(join(tmpdir(), 'sp-refapply-ws-')); + created.push(ws); + return ws; +} + +describe('runReferenceApply — happy paths', () => { + it('applies upstream gbrain changes to a file the user has not edited', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + // gbrain ships a new version with line 15 updated. User has not + // touched the file locally. + const gbrainSkill = join(gbrainRoot, 'skills', 'demo', 'SKILL.md'); + writeFileSync( + gbrainSkill, + readFileSync(gbrainSkill, 'utf-8').replace('Line 15\n', 'Line 15 UPDATED\n'), + ); + + const userSkill = join(ws, 'skills', 'demo', 'SKILL.md'); + const result = runReferenceApply({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + expect(result.summary.filesApplied).toBe(1); + expect(result.summary.totalHunksApplied).toBe(1); + expect(result.summary.totalHunksConflicted).toBe(0); + expect(readFileSync(userSkill, 'utf-8')).toContain('Line 15 UPDATED'); + }); + + it('two-way limitation: user edits in differing area DO get replaced by gbrain content', () => { + // D15 contract: this is a TWO-WAY diff against gbrain's current + // bundle. Without scaffold-time base tracking, we cannot tell + // whether a difference came from gbrain or from the user. Applied + // hunks therefore align everything to gbrain. The agent uses + // --dry-run / reference (read-only) BEFORE applying to decide. + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + // gbrain changes line 25. User changes line 5 (independent areas). + const gbrainSkill = join(gbrainRoot, 'skills', 'demo', 'SKILL.md'); + writeFileSync( + gbrainSkill, + readFileSync(gbrainSkill, 'utf-8').replace('Line 25\n', 'Line 25 GBRAIN\n'), + ); + const userSkill = join(ws, 'skills', 'demo', 'SKILL.md'); + writeFileSync( + userSkill, + readFileSync(userSkill, 'utf-8').replace('Line 5\n', 'Line 5 USER\n'), + ); + + const result = runReferenceApply({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + expect(result.summary.totalHunksApplied).toBeGreaterThanOrEqual(1); + // gbrain's change lands… + expect(readFileSync(userSkill, 'utf-8')).toContain('Line 25 GBRAIN'); + // …AND the user's edit gets overwritten (the two-way limitation). + expect(readFileSync(userSkill, 'utf-8')).not.toContain('Line 5 USER'); + }); + + it('identical file: reported as identical, not touched', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + const before = readFileSync(join(ws, 'skills', 'demo', 'SKILL.md'), 'utf-8'); + const result = runReferenceApply({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + const after = readFileSync(join(ws, 'skills', 'demo', 'SKILL.md'), 'utf-8'); + + expect(result.summary.filesIdentical).toBe(1); + expect(after).toBe(before); + }); + + it('missing file: reported as missing, not created', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + // Don't scaffold — leave target missing. + + const result = runReferenceApply({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + expect(result.summary.filesMissing).toBe(1); + expect(existsSync(join(ws, 'skills', 'demo', 'SKILL.md'))).toBe(false); + }); +}); + +describe('runReferenceApply — applied-status surface', () => { + it('applied_clean status set when every hunk lands without conflict', () => { + // runReferenceApply uses just-in-time diff (user→gbrain), so its + // own before-blocks are by construction always found in the user + // file. The conflict path is exercised structurally by the + // underlying applyHunks tests (apply-hunks.test.ts) — see those + // for the conflict_missing / conflict_ambiguous coverage. Here we + // just pin the status-label surface that the CLI reports. + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + const gbrainSkill = join(gbrainRoot, 'skills', 'demo', 'SKILL.md'); + writeFileSync( + gbrainSkill, + readFileSync(gbrainSkill, 'utf-8').replace('Line 15\n', 'Line 15 GBRAIN\n'), + ); + + const result = runReferenceApply({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + expect(result.files.some(f => f.status === 'applied_clean')).toBe(true); + }); +}); + +describe('runReferenceApply — dry-run', () => { + it('reports apply outcomes without writing the file', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + // gbrain ships an upstream change. + const gbrainSkill = join(gbrainRoot, 'skills', 'demo', 'SKILL.md'); + writeFileSync( + gbrainSkill, + readFileSync(gbrainSkill, 'utf-8').replace('Line 15\n', 'Line 15 GBRAIN\n'), + ); + + const userSkill = join(ws, 'skills', 'demo', 'SKILL.md'); + const before = readFileSync(userSkill, 'utf-8'); + + const result = runReferenceApply({ + gbrainRoot, + targetWorkspace: ws, + skillSlug: 'demo', + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.summary.totalHunksApplied).toBeGreaterThan(0); + // File NOT modified (dry-run). + expect(readFileSync(userSkill, 'utf-8')).toBe(before); + }); +}); + +describe('runReferenceApply — binary files', () => { + it('binary files are reported binary_skip and not touched', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + + const binPath = join(gbrainRoot, 'skills', 'demo', 'icon.png'); + writeFileSync(binPath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff])); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + writeFileSync(join(ws, 'skills', 'demo', 'icon.png'), Buffer.from([0x89, 0x50, 0x00])); + + const result = runReferenceApply({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + expect(result.summary.filesBinarySkipped).toBeGreaterThan(0); + const bin = result.files.find(f => f.target.endsWith('icon.png'))!; + expect(bin.status).toBe('binary_skip'); + }); +}); + +describe('runReferenceApply — --all is not supported', () => { + it('throws when called with skillSlug: null', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + expect(() => + runReferenceApply({ gbrainRoot, targetWorkspace: ws, skillSlug: null }), + ).toThrow(/--all\+--apply-clean-hunks is intentionally not supported|apply one skill/); + }); +}); diff --git a/test/skillpack-reference.test.ts b/test/skillpack-reference.test.ts new file mode 100644 index 000000000..8447e282c --- /dev/null +++ b/test/skillpack-reference.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for src/core/skillpack/reference.ts — the read-only update lens. + * + * Pins: + * - per-file status: missing / identical / differs + * - unified diff text emitted for `differs` + * - paired source files included via frontmatter `sources:` + * - framing line present (load-bearing for the new model) + * - --all mode: one-line-per-skill summary + * - binary file path: stub message, no crash + */ + +import { describe, expect, it, afterEach } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import { runReference, runReferenceAll } from '../src/core/skillpack/reference.ts'; +import { runScaffold } from '../src/core/skillpack/scaffold.ts'; + +const created: string[] = []; +afterEach(() => { + while (created.length) { + const p = created.pop()!; + try { + rmSync(p, { recursive: true, force: true }); + } catch {} + } +}); + +function scratchGbrain(opts: { paired?: boolean } = {}): string { + const root = mkdtempSync(join(tmpdir(), 'sp-ref-gbrain-')); + created.push(root); + mkdirSync(join(root, 'src', 'commands'), { recursive: true }); + writeFileSync(join(root, 'src', 'cli.ts'), '// stub'); + + mkdirSync(join(root, 'skills', 'demo'), { recursive: true }); + const fm = opts.paired + ? '---\nname: demo\ntriggers:\n - d\nsources:\n - src/commands/demo.ts\n---\n# demo skill\n\nLine A\nLine B\nLine C\n' + : '---\nname: demo\ntriggers:\n - d\n---\n# demo skill\n\nLine A\nLine B\nLine C\n'; + writeFileSync(join(root, 'skills', 'demo', 'SKILL.md'), fm); + if (opts.paired) { + writeFileSync(join(root, 'src', 'commands', 'demo.ts'), '// real impl\n'); + } + + mkdirSync(join(root, 'skills', 'other'), { recursive: true }); + writeFileSync( + join(root, 'skills', 'other', 'SKILL.md'), + '---\nname: other\ntriggers:\n - o\n---\n# other\n', + ); + + writeFileSync( + join(root, 'openclaw.plugin.json'), + JSON.stringify( + { + name: 'gbrain-test', + version: '0.33.0-test', + skills: ['skills/demo', 'skills/other'], + shared_deps: [], + }, + null, + 2, + ), + ); + return root; +} + +function scratchWorkspace(): string { + const ws = mkdtempSync(join(tmpdir(), 'sp-ref-ws-')); + created.push(ws); + return ws; +} + +describe('runReference — file statuses', () => { + it('missing: nothing scaffolded yet', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + + const result = runReference({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + expect(result.summary.missing).toBeGreaterThan(0); + expect(result.summary.identical).toBe(0); + expect(result.summary.differs).toBe(0); + expect(result.files.every(f => f.status === 'missing')).toBe(true); + }); + + it('identical: scaffolded with no edits', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + const result = runReference({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + expect(result.summary.identical).toBeGreaterThan(0); + expect(result.summary.differs).toBe(0); + expect(result.summary.missing).toBe(0); + }); + + it('differs: user edits a scaffolded file → unified diff emitted', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + const skillMd = join(ws, 'skills', 'demo', 'SKILL.md'); + writeFileSync(skillMd, readFileSync(skillMd, 'utf-8') + '\n## My edits\n'); + + const result = runReference({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + expect(result.summary.differs).toBe(1); + const differ = result.files.find(f => f.status === 'differs')!; + expect(differ.unifiedDiff).toContain('--- a/'); + expect(differ.unifiedDiff).toContain('+++ b/'); + expect(differ.unifiedDiff).toContain('+## My edits'); + }); +}); + +describe('runReference — paired source files', () => { + it('includes paired source files declared in frontmatter `sources:`', () => { + const gbrainRoot = scratchGbrain({ paired: true }); + const ws = scratchWorkspace(); + + const result = runReference({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + const pairedEntries = result.files.filter(f => f.pairedSource); + expect(pairedEntries.length).toBe(1); + expect(pairedEntries[0].target).toBe(join(ws, 'src', 'commands', 'demo.ts')); + }); + + it('reports differs on a paired source after user edits', () => { + const gbrainRoot = scratchGbrain({ paired: true }); + const ws = scratchWorkspace(); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + writeFileSync(join(ws, 'src', 'commands', 'demo.ts'), '// user replaced\n'); + + const result = runReference({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + const paired = result.files.find(f => f.pairedSource)!; + expect(paired.status).toBe('differs'); + expect(paired.unifiedDiff).toContain('user replaced'); + }); +}); + +describe('runReference — framing line (load-bearing)', () => { + it('emits the agent-readable framing string for single-skill mode', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + const result = runReference({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + expect(result.framing).toContain('as reference'); + expect(result.framing).toContain('do not blindly overwrite'); + expect(result.framing).toContain(gbrainRoot); + }); + + it('emits the framing string for --all mode (with sweep summary)', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + const result = runReferenceAll({ gbrainRoot, targetWorkspace: ws }); + + expect(result.framing).toContain('as reference'); + expect(result.skills.length).toBe(2); // demo + other + expect(result.skills.find(s => s.slug === 'demo')).toBeDefined(); + }); +}); + +describe('runReference --json envelope shape', () => { + it('result is JSON-stringify-able and round-trips losslessly', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + const result = runReference({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + + const json = JSON.stringify(result); + const round = JSON.parse(json); + expect(round.framing).toBe(result.framing); + expect(round.summary).toEqual(result.summary); + expect(round.files.length).toBe(result.files.length); + }); +}); + +describe('runReference — binary files', () => { + it('emits a binary-files-differ stub when content has NUL bytes', () => { + const gbrainRoot = scratchGbrain(); + const ws = scratchWorkspace(); + + // Plant a binary file in gbrain's bundle and a different one on host. + const binPath = join(gbrainRoot, 'skills', 'demo', 'icon.png'); + writeFileSync(binPath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00])); + runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + writeFileSync(join(ws, 'skills', 'demo', 'icon.png'), Buffer.from([0x89, 0x50, 0x00])); + + const result = runReference({ gbrainRoot, targetWorkspace: ws, skillSlug: 'demo' }); + const bin = result.files.find(f => f.target.endsWith('icon.png'))!; + expect(bin.status).toBe('differs'); + expect(bin.unifiedDiff).toContain('Binary'); + }); +});