mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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:
co-authored by
Claude Opus 4.7
parent
01ab21c407
commit
484e61a3ce
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user