updated design
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeDiff, diffComponents } from '../src/engine.js';
|
||||
import type { ComponentSnapshot } from '../src/schemas.js';
|
||||
|
||||
const btn: ComponentSnapshot = {
|
||||
id: 'btn-1',
|
||||
name: 'Button',
|
||||
filePath: 'src/components/Button.tsx',
|
||||
props: { variant: 'primary', size: 'md', disabled: false },
|
||||
styles: { background: '#0066FF', borderRadius: '8px' },
|
||||
};
|
||||
|
||||
describe('computeDiff', () => {
|
||||
it('marks unchanged when nothing changed', () => {
|
||||
const d = computeDiff(btn, btn);
|
||||
expect(d.changeType).toBe('unchanged');
|
||||
expect(d.propChanges).toHaveLength(0);
|
||||
expect(d.styleChanges).toHaveLength(0);
|
||||
expect(d.patch).toBe('');
|
||||
});
|
||||
|
||||
it('detects modified prop', () => {
|
||||
const after = { ...btn, props: { ...btn.props, size: 'lg' } };
|
||||
const d = computeDiff(btn, after);
|
||||
expect(d.changeType).toBe('modified');
|
||||
const change = d.propChanges.find(c => c.key === 'size');
|
||||
expect(change?.before).toBe('md');
|
||||
expect(change?.after).toBe('lg');
|
||||
expect(change?.changeType).toBe('modified');
|
||||
});
|
||||
|
||||
it('detects added prop', () => {
|
||||
const after = { ...btn, props: { ...btn.props, loading: true } };
|
||||
const d = computeDiff(btn, after);
|
||||
const change = d.propChanges.find(c => c.key === 'loading');
|
||||
expect(change?.changeType).toBe('added');
|
||||
expect(change?.before).toBeUndefined();
|
||||
expect(change?.after).toBe(true);
|
||||
});
|
||||
|
||||
it('detects removed prop', () => {
|
||||
const { disabled: _, ...remaining } = btn.props;
|
||||
const after = { ...btn, props: remaining };
|
||||
const d = computeDiff(btn, after);
|
||||
const change = d.propChanges.find(c => c.key === 'disabled');
|
||||
expect(change?.changeType).toBe('removed');
|
||||
expect(change?.before).toBe(false);
|
||||
expect(change?.after).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects style change', () => {
|
||||
const after = { ...btn, styles: { ...btn.styles, borderRadius: '12px' } };
|
||||
const d = computeDiff(btn, after);
|
||||
const change = d.styleChanges.find(c => c.key === 'borderRadius');
|
||||
expect(change?.before).toBe('8px');
|
||||
expect(change?.after).toBe('12px');
|
||||
});
|
||||
|
||||
it('generates a non-empty patch when changed', () => {
|
||||
const after = { ...btn, props: { ...btn.props, size: 'xl' } };
|
||||
const d = computeDiff(btn, after);
|
||||
expect(d.patch).toBeTruthy();
|
||||
expect(d.patch).toContain('--- a/');
|
||||
expect(d.patch).toContain('+++ b/');
|
||||
});
|
||||
|
||||
it('propagates id and name from after snapshot', () => {
|
||||
const d = computeDiff(btn, { ...btn, name: 'IconButton' });
|
||||
expect(d.name).toBe('IconButton');
|
||||
expect(d.id).toBe(btn.id);
|
||||
});
|
||||
|
||||
it('deep-equal objects are unchanged', () => {
|
||||
const snap: ComponentSnapshot = {
|
||||
id: 'x',
|
||||
name: 'X',
|
||||
props: { config: { a: 1, b: [2, 3] } },
|
||||
};
|
||||
const d = computeDiff(snap, { ...snap, props: { config: { a: 1, b: [2, 3] } } });
|
||||
expect(d.changeType).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('deep-equal objects with different value are modified', () => {
|
||||
const snap: ComponentSnapshot = {
|
||||
id: 'x',
|
||||
name: 'X',
|
||||
props: { config: { a: 1, b: [2, 3] } },
|
||||
};
|
||||
const d = computeDiff(snap, { ...snap, props: { config: { a: 1, b: [2, 99] } } });
|
||||
expect(d.changeType).toBe('modified');
|
||||
});
|
||||
|
||||
it('handles added child', () => {
|
||||
const child: ComponentSnapshot = { id: 'c1', name: 'Icon', props: { name: 'check' } };
|
||||
const before: ComponentSnapshot = { id: 'x', name: 'X', props: {}, children: [] };
|
||||
const after: ComponentSnapshot = { ...before, children: [child] };
|
||||
const d = computeDiff(before, after);
|
||||
expect(d.childDiffs).toBeDefined();
|
||||
expect(d.childDiffs![0]?.changeType).toBe('added');
|
||||
});
|
||||
|
||||
it('handles removed child', () => {
|
||||
const child: ComponentSnapshot = { id: 'c1', name: 'Icon', props: { name: 'check' } };
|
||||
const before: ComponentSnapshot = { id: 'x', name: 'X', props: {}, children: [child] };
|
||||
const after: ComponentSnapshot = { ...before, children: [] };
|
||||
const d = computeDiff(before, after);
|
||||
expect(d.childDiffs).toBeDefined();
|
||||
expect(d.childDiffs![0]?.changeType).toBe('removed');
|
||||
});
|
||||
|
||||
it('omits childDiffs when no child changes', () => {
|
||||
const child: ComponentSnapshot = { id: 'c1', name: 'Icon', props: { name: 'check' } };
|
||||
const snap: ComponentSnapshot = { id: 'x', name: 'X', props: {}, children: [child] };
|
||||
const d = computeDiff(snap, snap);
|
||||
expect(d.childDiffs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffComponents', () => {
|
||||
it('returns diff and patch at top level', () => {
|
||||
const after = { ...btn, props: { ...btn.props, size: 'xl' } };
|
||||
const result = diffComponents(btn, after);
|
||||
expect(result.diff.changeType).toBe('modified');
|
||||
expect(result.patch).toBe(result.diff.patch);
|
||||
expect(result.patch).toBeTruthy();
|
||||
});
|
||||
|
||||
it('patch and diff.patch are identical', () => {
|
||||
const result = diffComponents(btn, btn);
|
||||
expect(result.patch).toBe('');
|
||||
expect(result.diff.patch).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeEditScript } from '../src/myers.js';
|
||||
|
||||
describe('computeEditScript', () => {
|
||||
it('returns empty for two empty arrays', () => {
|
||||
expect(computeEditScript([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all-insert for empty before', () => {
|
||||
const ops = computeEditScript([], ['a', 'b']);
|
||||
expect(ops).toEqual([{ type: 'insert', lines: ['a', 'b'] }]);
|
||||
});
|
||||
|
||||
it('returns all-delete for empty after', () => {
|
||||
const ops = computeEditScript(['a', 'b'], []);
|
||||
expect(ops).toEqual([{ type: 'delete', lines: ['a', 'b'] }]);
|
||||
});
|
||||
|
||||
it('returns all-equal for identical arrays', () => {
|
||||
const ops = computeEditScript(['a', 'b', 'c'], ['a', 'b', 'c']);
|
||||
expect(ops).toEqual([{ type: 'equal', lines: ['a', 'b', 'c'] }]);
|
||||
});
|
||||
|
||||
it('detects a single changed line', () => {
|
||||
const ops = computeEditScript(['a', 'b', 'c'], ['a', 'X', 'c']);
|
||||
const types = ops.map(o => o.type);
|
||||
expect(types).toContain('delete');
|
||||
expect(types).toContain('insert');
|
||||
const deleted = ops.filter(o => o.type === 'delete').flatMap(o => o.lines);
|
||||
const inserted = ops.filter(o => o.type === 'insert').flatMap(o => o.lines);
|
||||
expect(deleted).toContain('b');
|
||||
expect(inserted).toContain('X');
|
||||
});
|
||||
|
||||
it('detects inserted lines at end', () => {
|
||||
const ops = computeEditScript(['a'], ['a', 'b', 'c']);
|
||||
const inserted = ops.filter(o => o.type === 'insert').flatMap(o => o.lines);
|
||||
expect(inserted).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
it('detects deleted lines at start', () => {
|
||||
const ops = computeEditScript(['x', 'y', 'a'], ['a']);
|
||||
const deleted = ops.filter(o => o.type === 'delete').flatMap(o => o.lines);
|
||||
expect(deleted).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
it('merges consecutive ops of same type', () => {
|
||||
const ops = computeEditScript(['a', 'b'], ['c', 'd']);
|
||||
// Should have merged deletes and inserts, not one-op-per-line
|
||||
const delOp = ops.find(o => o.type === 'delete');
|
||||
const insOp = ops.find(o => o.type === 'insert');
|
||||
expect(delOp?.lines.length).toBeGreaterThanOrEqual(1);
|
||||
expect(insOp?.lines.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('round-trip: applying ops to before yields after', () => {
|
||||
const before = ['prop: 1', 'style: red', 'size: md'];
|
||||
const after = ['prop: 1', 'style: blue', 'size: lg', 'weight: 600'];
|
||||
const ops = computeEditScript(before, after);
|
||||
|
||||
const result: string[] = [];
|
||||
for (const op of ops) {
|
||||
if (op.type === 'equal' || op.type === 'insert') result.push(...op.lines);
|
||||
}
|
||||
expect(result).toEqual(after);
|
||||
});
|
||||
|
||||
it('handles single-element change', () => {
|
||||
const ops = computeEditScript(['x'], ['y']);
|
||||
expect(ops.some(o => o.type === 'delete' && o.lines.includes('x'))).toBe(true);
|
||||
expect(ops.some(o => o.type === 'insert' && o.lines.includes('y'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generatePatch } from '../src/patch.js';
|
||||
|
||||
const A = `Component: Button
|
||||
Props:
|
||||
size: "md"
|
||||
variant: "primary"
|
||||
Styles:
|
||||
background: #0066FF
|
||||
borderRadius: 8px
|
||||
`;
|
||||
|
||||
const B = `Component: Button
|
||||
Props:
|
||||
size: "lg"
|
||||
variant: "primary"
|
||||
Styles:
|
||||
background: #0066FF
|
||||
borderRadius: 12px
|
||||
`;
|
||||
|
||||
describe('generatePatch', () => {
|
||||
it('returns empty string for identical inputs', () => {
|
||||
expect(generatePatch(A, A)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for two empty strings', () => {
|
||||
expect(generatePatch('', '')).toBe('');
|
||||
});
|
||||
|
||||
it('includes --- and +++ headers', () => {
|
||||
const patch = generatePatch(A, B);
|
||||
expect(patch).toMatch(/^--- a\//m);
|
||||
expect(patch).toMatch(/^\+\+\+ b\//m);
|
||||
});
|
||||
|
||||
it('includes @@ hunk header', () => {
|
||||
const patch = generatePatch(A, B);
|
||||
expect(patch).toMatch(/^@@ /m);
|
||||
});
|
||||
|
||||
it('marks changed lines correctly', () => {
|
||||
const patch = generatePatch(A, B);
|
||||
expect(patch).toContain('- size: "md"');
|
||||
expect(patch).toContain('+ size: "lg"');
|
||||
expect(patch).toContain('- borderRadius: 8px');
|
||||
expect(patch).toContain('+ borderRadius: 12px');
|
||||
});
|
||||
|
||||
it('preserves unchanged lines as context', () => {
|
||||
const patch = generatePatch(A, B);
|
||||
expect(patch).toContain(' variant: "primary"');
|
||||
});
|
||||
|
||||
it('uses custom filename in headers', () => {
|
||||
const patch = generatePatch(A, B, { filename: 'Button.tsx' });
|
||||
expect(patch).toContain('--- a/Button.tsx');
|
||||
expect(patch).toContain('+++ b/Button.tsx');
|
||||
});
|
||||
|
||||
it('handles all-insert (empty before)', () => {
|
||||
const patch = generatePatch('', 'line1\nline2\n');
|
||||
expect(patch).toContain('+line1');
|
||||
expect(patch).toContain('+line2');
|
||||
});
|
||||
|
||||
it('handles all-delete (empty after)', () => {
|
||||
const patch = generatePatch('line1\nline2\n', '');
|
||||
expect(patch).toContain('-line1');
|
||||
expect(patch).toContain('-line2');
|
||||
});
|
||||
|
||||
it('produces multiple hunks when changes are far apart', () => {
|
||||
const before = Array.from({ length: 20 }, (_, i) => `line${i}`).join('\n') + '\n';
|
||||
const after = before
|
||||
.replace('line0', 'CHANGED0')
|
||||
.replace('line19', 'CHANGED19');
|
||||
const patch = generatePatch(before, after);
|
||||
const hunkCount = (patch.match(/^@@/gm) ?? []).length;
|
||||
expect(hunkCount).toBe(2);
|
||||
});
|
||||
|
||||
it('merges nearby hunks into one', () => {
|
||||
const before = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\n') + '\n';
|
||||
const after = before.replace('line0', 'X').replace('line1', 'Y');
|
||||
const patch = generatePatch(before, after);
|
||||
const hunkCount = (patch.match(/^@@/gm) ?? []).length;
|
||||
expect(hunkCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { serializeSnapshot, serializeValue } from '../src/serialize.js';
|
||||
import type { ComponentSnapshot } from '../src/schemas.js';
|
||||
|
||||
const snap: ComponentSnapshot = {
|
||||
id: 'btn-1',
|
||||
name: 'Button',
|
||||
filePath: 'src/components/Button.tsx',
|
||||
props: { variant: 'primary', size: 'md', disabled: false },
|
||||
styles: { borderRadius: '8px', background: '#0066FF' },
|
||||
};
|
||||
|
||||
describe('serializeSnapshot', () => {
|
||||
it('includes component name and filePath', () => {
|
||||
const out = serializeSnapshot(snap);
|
||||
expect(out).toContain('Component: Button [src/components/Button.tsx]');
|
||||
});
|
||||
|
||||
it('sorts props alphabetically for stable diffs', () => {
|
||||
const out = serializeSnapshot(snap);
|
||||
const propLines = out.split('\n').filter(l => l.includes(':') && !l.includes('Component'));
|
||||
const propNames = propLines
|
||||
.map(l => l.trim().split(':')[0]!.trim())
|
||||
.filter(k => ['disabled', 'size', 'variant', 'background', 'borderRadius'].includes(k));
|
||||
// disabled < size < variant (alphabetical)
|
||||
const dIdx = propNames.indexOf('disabled');
|
||||
const sIdx = propNames.indexOf('size');
|
||||
const vIdx = propNames.indexOf('variant');
|
||||
expect(dIdx).toBeLessThan(sIdx);
|
||||
expect(sIdx).toBeLessThan(vIdx);
|
||||
});
|
||||
|
||||
it('sorts styles alphabetically', () => {
|
||||
const out = serializeSnapshot(snap);
|
||||
const bgIdx = out.indexOf('background:');
|
||||
const brIdx = out.indexOf('borderRadius:');
|
||||
expect(bgIdx).toBeLessThan(brIdx);
|
||||
});
|
||||
|
||||
it('produces identical output for same input (deterministic)', () => {
|
||||
expect(serializeSnapshot(snap)).toBe(serializeSnapshot(snap));
|
||||
});
|
||||
|
||||
it('handles component without filePath', () => {
|
||||
const out = serializeSnapshot({ id: 'x', name: 'Foo', props: {} });
|
||||
expect(out).toBe('Component: Foo');
|
||||
});
|
||||
|
||||
it('handles empty props and styles', () => {
|
||||
const out = serializeSnapshot({ id: 'x', name: 'Foo', props: {}, styles: {} });
|
||||
expect(out).not.toContain('Props:');
|
||||
expect(out).not.toContain('Styles:');
|
||||
});
|
||||
|
||||
it('serializes nested children', () => {
|
||||
const withChild: ComponentSnapshot = {
|
||||
...snap,
|
||||
children: [{ id: 'icon', name: 'Icon', props: { name: 'check' } }],
|
||||
};
|
||||
const out = serializeSnapshot(withChild);
|
||||
expect(out).toContain('Children:');
|
||||
expect(out).toContain('[0]');
|
||||
expect(out).toContain('Component: Icon');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeValue', () => {
|
||||
it('wraps strings in quotes', () => expect(serializeValue('hello')).toBe('"hello"'));
|
||||
it('renders numbers as-is', () => expect(serializeValue(42)).toBe('42'));
|
||||
it('renders booleans', () => {
|
||||
expect(serializeValue(true)).toBe('true');
|
||||
expect(serializeValue(false)).toBe('false');
|
||||
});
|
||||
it('renders null', () => expect(serializeValue(null)).toBe('null'));
|
||||
it('renders undefined', () => expect(serializeValue(undefined)).toBe('undefined'));
|
||||
it('renders arrays', () => expect(serializeValue([1, 'a'])).toBe('[1, "a"]'));
|
||||
it('renders objects with sorted keys', () => {
|
||||
expect(serializeValue({ z: 1, a: 2 })).toBe('{ a: 2, z: 1 }');
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,12 @@
|
||||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "^1.1.19",
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.0"
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { ComponentSnapshot, ComponentDiff, PropChange, ChangeType } from './schemas.js';
|
||||
import { serializeSnapshot } from './serialize.js';
|
||||
import { generatePatch } from './patch.js';
|
||||
|
||||
// ── Core diff result ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface DiffResult {
|
||||
diff: ComponentDiff;
|
||||
patch: string;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function diffComponents(
|
||||
before: ComponentSnapshot,
|
||||
after: ComponentSnapshot
|
||||
): DiffResult {
|
||||
const diff = computeDiff(before, after);
|
||||
return { diff, patch: diff.patch };
|
||||
}
|
||||
|
||||
export function computeDiff(
|
||||
before: ComponentSnapshot,
|
||||
after: ComponentSnapshot
|
||||
): ComponentDiff {
|
||||
const propChanges = diffRecord(before.props, after.props);
|
||||
const styleChanges = diffRecord(
|
||||
before.styles ?? {},
|
||||
after.styles ?? {}
|
||||
) as PropChange[];
|
||||
|
||||
const changeType = determineChangeType(propChanges, styleChanges);
|
||||
|
||||
const childDiffs = diffChildren(before.children ?? [], after.children ?? []);
|
||||
|
||||
const beforeText = serializeSnapshot(before);
|
||||
const afterText = serializeSnapshot(after);
|
||||
const filename = after.filePath ?? `${after.name}.tsx`;
|
||||
const patch = generatePatch(beforeText, afterText, { filename });
|
||||
|
||||
const result: ComponentDiff = {
|
||||
id: after.id,
|
||||
name: after.name,
|
||||
changeType,
|
||||
propChanges,
|
||||
styleChanges,
|
||||
patch,
|
||||
};
|
||||
if (childDiffs.length > 0) result.childDiffs = childDiffs;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function diffRecord(
|
||||
before: Record<string, unknown>,
|
||||
after: Record<string, unknown>
|
||||
): PropChange[] {
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
const changes: PropChange[] = [];
|
||||
|
||||
for (const key of [...keys].sort()) {
|
||||
const b = before[key];
|
||||
const a = after[key];
|
||||
const hadKey = Object.prototype.hasOwnProperty.call(before, key);
|
||||
const hasKey = Object.prototype.hasOwnProperty.call(after, key);
|
||||
|
||||
if (!hadKey) {
|
||||
changes.push({ key, before: undefined, after: a, changeType: 'added' });
|
||||
} else if (!hasKey) {
|
||||
changes.push({ key, before: b, after: undefined, changeType: 'removed' });
|
||||
} else if (!deepEqual(b, a)) {
|
||||
changes.push({ key, before: b, after: a, changeType: 'modified' });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
function diffChildren(
|
||||
before: ComponentSnapshot[],
|
||||
after: ComponentSnapshot[]
|
||||
): ComponentDiff[] {
|
||||
const diffs: ComponentDiff[] = [];
|
||||
|
||||
// Match by id first; fall back to positional match
|
||||
const beforeById = new Map(before.map(c => [c.id, c]));
|
||||
const afterById = new Map(after.map(c => [c.id, c]));
|
||||
|
||||
// Modified or unchanged children that exist in both
|
||||
for (const afterChild of after) {
|
||||
const beforeChild = beforeById.get(afterChild.id);
|
||||
if (beforeChild) {
|
||||
const d = computeDiff(beforeChild, afterChild);
|
||||
if (d.changeType !== 'unchanged') diffs.push(d);
|
||||
} else {
|
||||
// Added child
|
||||
diffs.push(addedDiff(afterChild));
|
||||
}
|
||||
}
|
||||
|
||||
// Removed children
|
||||
for (const beforeChild of before) {
|
||||
if (!afterById.has(beforeChild.id)) {
|
||||
diffs.push(removedDiff(beforeChild));
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
function addedDiff(snap: ComponentSnapshot): ComponentDiff {
|
||||
const props = Object.keys(snap.props).sort().map(key => ({
|
||||
key,
|
||||
before: undefined as unknown,
|
||||
after: snap.props[key],
|
||||
changeType: 'added' as ChangeType,
|
||||
}));
|
||||
const styles = Object.keys(snap.styles ?? {}).sort().map(key => ({
|
||||
key,
|
||||
before: undefined as unknown,
|
||||
after: snap.styles![key],
|
||||
changeType: 'added' as ChangeType,
|
||||
}));
|
||||
return {
|
||||
id: snap.id,
|
||||
name: snap.name,
|
||||
changeType: 'added',
|
||||
propChanges: props,
|
||||
styleChanges: styles,
|
||||
patch: generatePatch('', serializeSnapshot(snap), { filename: snap.name }),
|
||||
};
|
||||
}
|
||||
|
||||
function removedDiff(snap: ComponentSnapshot): ComponentDiff {
|
||||
const props = Object.keys(snap.props).sort().map(key => ({
|
||||
key,
|
||||
before: snap.props[key],
|
||||
after: undefined as unknown,
|
||||
changeType: 'removed' as ChangeType,
|
||||
}));
|
||||
const styles = Object.keys(snap.styles ?? {}).sort().map(key => ({
|
||||
key,
|
||||
before: snap.styles![key] as unknown,
|
||||
after: undefined as unknown,
|
||||
changeType: 'removed' as ChangeType,
|
||||
}));
|
||||
return {
|
||||
id: snap.id,
|
||||
name: snap.name,
|
||||
changeType: 'removed',
|
||||
propChanges: props,
|
||||
styleChanges: styles,
|
||||
patch: generatePatch(serializeSnapshot(snap), '', { filename: snap.name }),
|
||||
};
|
||||
}
|
||||
|
||||
function determineChangeType(
|
||||
propChanges: PropChange[],
|
||||
styleChanges: PropChange[]
|
||||
): ChangeType {
|
||||
const hasChanges = propChanges.length > 0 || styleChanges.length > 0;
|
||||
return hasChanges ? 'modified' : 'unchanged';
|
||||
}
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (typeof a !== 'object') return false;
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((v, i) => deepEqual(v, b[i]));
|
||||
}
|
||||
|
||||
const ao = a as Record<string, unknown>;
|
||||
const bo = b as Record<string, unknown>;
|
||||
const aKeys = Object.keys(ao).sort();
|
||||
const bKeys = Object.keys(bo).sort();
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
if (aKeys.join(',') !== bKeys.join(',')) return false;
|
||||
return aKeys.every(k => deepEqual(ao[k], bo[k]));
|
||||
}
|
||||
@@ -1 +1,25 @@
|
||||
export {};
|
||||
// @originmain/diff-engine — public API
|
||||
|
||||
export type {
|
||||
ComponentSnapshot,
|
||||
ComponentDiff,
|
||||
PropChange,
|
||||
ChangeType,
|
||||
DiffLayout,
|
||||
} from './schemas.js';
|
||||
|
||||
export {
|
||||
ComponentSnapshotSchema,
|
||||
ComponentDiffSchema,
|
||||
PropChangeSchema,
|
||||
ChangeTypeSchema,
|
||||
DiffLayoutSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
export { computeEditScript, type EditOp, type EditType } from './myers.js';
|
||||
|
||||
export { serializeSnapshot, serializeValue } from './serialize.js';
|
||||
|
||||
export { generatePatch, type PatchOptions } from './patch.js';
|
||||
|
||||
export { diffComponents, computeDiff, type DiffResult } from './engine.js';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// LCS-based O(nm) diff algorithm — correct and simple for component-sized inputs.
|
||||
// Produces a minimal edit script as a sequence of equal/insert/delete operations.
|
||||
|
||||
export type EditType = 'equal' | 'insert' | 'delete';
|
||||
|
||||
export interface EditOp {
|
||||
type: EditType;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export function computeEditScript(before: string[], after: string[]): EditOp[] {
|
||||
const n = before.length;
|
||||
const m = after.length;
|
||||
|
||||
if (n === 0 && m === 0) return [];
|
||||
if (n === 0) return [{ type: 'insert', lines: [...after] }];
|
||||
if (m === 0) return [{ type: 'delete', lines: [...before] }];
|
||||
|
||||
// Build LCS DP table
|
||||
const dp: 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++) {
|
||||
dp[i]![j] =
|
||||
before[i - 1] === after[j - 1]
|
||||
? (dp[i - 1]![j - 1] ?? 0) + 1
|
||||
: Math.max(dp[i - 1]![j] ?? 0, dp[i]![j - 1] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Backtrack from (n, m) to (0, 0)
|
||||
type RawOp = { type: EditType; line: string };
|
||||
const raw: RawOp[] = [];
|
||||
let i = n, j = m;
|
||||
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && before[i - 1] === after[j - 1]) {
|
||||
raw.push({ type: 'equal', line: before[i - 1]! });
|
||||
i--; j--;
|
||||
} else if (j > 0 && (i === 0 || (dp[i]![j - 1] ?? 0) >= (dp[i - 1]![j] ?? 0))) {
|
||||
raw.push({ type: 'insert', line: after[j - 1]! });
|
||||
j--;
|
||||
} else {
|
||||
raw.push({ type: 'delete', line: before[i - 1]! });
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
raw.reverse();
|
||||
|
||||
// Merge consecutive ops of the same type
|
||||
const ops: EditOp[] = [];
|
||||
for (const op of raw) {
|
||||
const last = ops[ops.length - 1];
|
||||
if (last && last.type === op.type) {
|
||||
last.lines.push(op.line);
|
||||
} else {
|
||||
ops.push({ type: op.type, lines: [op.line] });
|
||||
}
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { computeEditScript, type EditOp } from './myers.js';
|
||||
|
||||
// ── Flat line representation ──────────────────────────────────────────────────
|
||||
|
||||
interface FlatLine {
|
||||
type: 'equal' | 'insert' | 'delete';
|
||||
content: string;
|
||||
beforeIdx: number; // 1-based; -1 for pure insertions
|
||||
afterIdx: number; // 1-based; -1 for pure deletions
|
||||
}
|
||||
|
||||
function flattenOps(ops: EditOp[]): FlatLine[] {
|
||||
const lines: FlatLine[] = [];
|
||||
let b = 1, a = 1;
|
||||
|
||||
for (const op of ops) {
|
||||
for (const content of op.lines) {
|
||||
if (op.type === 'equal') {
|
||||
lines.push({ type: 'equal', content, beforeIdx: b++, afterIdx: a++ });
|
||||
} else if (op.type === 'delete') {
|
||||
lines.push({ type: 'delete', content, beforeIdx: b++, afterIdx: -1 });
|
||||
} else {
|
||||
lines.push({ type: 'insert', content, beforeIdx: -1, afterIdx: a++ });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── Hunk grouping ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface Hunk {
|
||||
flatStart: number;
|
||||
flatEnd: number;
|
||||
}
|
||||
|
||||
function buildHunks(flat: FlatLine[], context: number): Hunk[] {
|
||||
const changed = flat.reduce<number[]>((acc, l, i) => {
|
||||
if (l.type !== 'equal') acc.push(i);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (changed.length === 0) return [];
|
||||
|
||||
const hunks: Hunk[] = [];
|
||||
let start = Math.max(0, changed[0]! - context);
|
||||
let end = Math.min(flat.length - 1, changed[0]! + context);
|
||||
|
||||
for (let i = 1; i < changed.length; i++) {
|
||||
const ns = Math.max(0, changed[i]! - context);
|
||||
if (ns <= end + 1) {
|
||||
end = Math.min(flat.length - 1, changed[i]! + context);
|
||||
} else {
|
||||
hunks.push({ flatStart: start, flatEnd: end });
|
||||
start = ns;
|
||||
end = Math.min(flat.length - 1, changed[i]! + context);
|
||||
}
|
||||
}
|
||||
hunks.push({ flatStart: start, flatEnd: end });
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
// ── Patch generation ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PatchOptions {
|
||||
filename?: string;
|
||||
contextLines?: number;
|
||||
}
|
||||
|
||||
export function generatePatch(
|
||||
beforeText: string,
|
||||
afterText: string,
|
||||
opts: PatchOptions = {}
|
||||
): string {
|
||||
const { filename = 'component', contextLines = 3 } = opts;
|
||||
|
||||
const before = splitLines(beforeText);
|
||||
const after = splitLines(afterText);
|
||||
|
||||
if (before.join('\n') === after.join('\n')) return '';
|
||||
|
||||
const ops = computeEditScript(before, after);
|
||||
const flat = flattenOps(ops);
|
||||
const hunks = buildHunks(flat, contextLines);
|
||||
|
||||
if (hunks.length === 0) return '';
|
||||
|
||||
const out: string[] = [`--- a/${filename}`, `+++ b/${filename}`];
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const hunkLines = flat.slice(hunk.flatStart, hunk.flatEnd + 1);
|
||||
|
||||
const beforeStart = hunkLines.find(l => l.beforeIdx !== -1)?.beforeIdx ?? 1;
|
||||
const afterStart = hunkLines.find(l => l.afterIdx !== -1)?.afterIdx ?? 1;
|
||||
const beforeCount = hunkLines.filter(l => l.type !== 'insert').length;
|
||||
const afterCount = hunkLines.filter(l => l.type !== 'delete').length;
|
||||
|
||||
out.push(`@@ -${beforeStart},${beforeCount} +${afterStart},${afterCount} @@`);
|
||||
|
||||
for (const line of hunkLines) {
|
||||
if (line.type === 'equal') out.push(` ${line.content}`);
|
||||
else if (line.type === 'delete') out.push(`-${line.content}`);
|
||||
else out.push(`+${line.content}`);
|
||||
}
|
||||
}
|
||||
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function splitLines(text: string): string[] {
|
||||
return text === '' ? [] : text.split('\n').filter((_, i, arr) => i < arr.length - 1 || arr[arr.length - 1] !== '');
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Component snapshot ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComponentSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
filePath?: string;
|
||||
props: Record<string, unknown>;
|
||||
styles?: Record<string, string>;
|
||||
children?: ComponentSnapshot[];
|
||||
}
|
||||
|
||||
export const ComponentSnapshotSchema: z.ZodType<ComponentSnapshot> = z.lazy(() =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
filePath: z.string().optional(),
|
||||
props: z.record(z.unknown()),
|
||||
styles: z.record(z.string()).optional(),
|
||||
children: z.array(ComponentSnapshotSchema).optional(),
|
||||
})
|
||||
) as z.ZodType<ComponentSnapshot>;
|
||||
|
||||
// ── Change types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ChangeTypeSchema = z.enum(['added', 'removed', 'modified', 'unchanged']);
|
||||
export type ChangeType = z.infer<typeof ChangeTypeSchema>;
|
||||
|
||||
export const PropChangeSchema = z.object({
|
||||
key: z.string(),
|
||||
before: z.unknown(),
|
||||
after: z.unknown(),
|
||||
changeType: ChangeTypeSchema,
|
||||
});
|
||||
export type PropChange = z.infer<typeof PropChangeSchema>;
|
||||
|
||||
// ── Component diff ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComponentDiff {
|
||||
id: string;
|
||||
name: string;
|
||||
changeType: ChangeType;
|
||||
propChanges: PropChange[];
|
||||
styleChanges: PropChange[];
|
||||
patch: string;
|
||||
childDiffs?: ComponentDiff[];
|
||||
}
|
||||
|
||||
export const ComponentDiffSchema: z.ZodType<ComponentDiff> = z.lazy(() =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
changeType: ChangeTypeSchema,
|
||||
propChanges: z.array(PropChangeSchema),
|
||||
styleChanges: z.array(PropChangeSchema),
|
||||
patch: z.string(),
|
||||
childDiffs: z.array(ComponentDiffSchema).optional(),
|
||||
})
|
||||
) as z.ZodType<ComponentDiff>;
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DiffLayoutSchema = z.enum(['split', 'unified']);
|
||||
export type DiffLayout = z.infer<typeof DiffLayoutSchema>;
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ComponentSnapshot } from './schemas.js';
|
||||
|
||||
// Stable text serialization of a component snapshot.
|
||||
// Props are sorted alphabetically so diffs are determined by value changes, not key ordering.
|
||||
|
||||
export function serializeSnapshot(snapshot: ComponentSnapshot, depth = 0): string {
|
||||
const indent = ' '.repeat(depth);
|
||||
const lines: string[] = [];
|
||||
|
||||
const header = snapshot.filePath
|
||||
? `Component: ${snapshot.name} [${snapshot.filePath}]`
|
||||
: `Component: ${snapshot.name}`;
|
||||
lines.push(`${indent}${header}`);
|
||||
|
||||
// Props section
|
||||
const propKeys = Object.keys(snapshot.props).sort();
|
||||
if (propKeys.length > 0) {
|
||||
lines.push(`${indent}Props:`);
|
||||
for (const key of propKeys) {
|
||||
lines.push(`${indent} ${key}: ${serializeValue(snapshot.props[key])}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Styles section
|
||||
const styleKeys = snapshot.styles ? Object.keys(snapshot.styles).sort() : [];
|
||||
if (styleKeys.length > 0) {
|
||||
lines.push(`${indent}Styles:`);
|
||||
for (const key of styleKeys) {
|
||||
lines.push(`${indent} ${key}: ${snapshot.styles![key]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Children section
|
||||
if (snapshot.children && snapshot.children.length > 0) {
|
||||
lines.push(`${indent}Children:`);
|
||||
for (let i = 0; i < snapshot.children.length; i++) {
|
||||
lines.push(`${indent} [${i}]`);
|
||||
lines.push(...serializeSnapshot(snapshot.children[i]!, depth + 2).split('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function serializeValue(val: unknown): string {
|
||||
if (val === null) return 'null';
|
||||
if (val === undefined) return 'undefined';
|
||||
if (typeof val === 'string') return `"${val}"`;
|
||||
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
|
||||
if (Array.isArray(val)) return `[${val.map(serializeValue).join(', ')}]`;
|
||||
if (typeof val === 'object') {
|
||||
const entries = Object.entries(val as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}: ${serializeValue(v)}`);
|
||||
return `{ ${entries.join(', ')} }`;
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['__tests__/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/index.ts'],
|
||||
thresholds: { lines: 85, functions: 85, branches: 80 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user