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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-17 15:01:53 -07:00
co-authored by Claude Opus 4.7
parent 01ab21c407
commit 484e61a3ce
6 changed files with 1499 additions and 0 deletions
+285
View File
@@ -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;
}
+235
View File
@@ -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 (HuntMcIlroy), 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;
}
+325
View File
@@ -0,0 +1,325 @@
/**
* skillpack/reference.ts — `gbrain skillpack reference <name>`.
*
* 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<ReferenceOptions, 'skillSlug'>): 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 <name>` 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,
};
}
+238
View File
@@ -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);
});
});
+221
View File
@@ -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/);
});
});
+195
View File
@@ -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');
});
});