updated design

This commit is contained in:
SinachPat
2026-04-25 22:52:30 +01:00
parent df79ae0bca
commit e836ac6d2b
22 changed files with 3480 additions and 781 deletions
+185
View File
@@ -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]));
}
+25 -1
View File
@@ -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';
+62
View File
@@ -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;
}
+114
View File
@@ -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] !== '');
}
+65
View File
@@ -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>;
+58
View File
@@ -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);
}