From b28c83f1a48da93c76176872f7bebdcd3ad72aa6 Mon Sep 17 00:00:00 2001 From: PresenceWith Date: Wed, 11 Mar 2026 13:09:44 +0900 Subject: [PATCH] feat(diff): add programmatic diff engine and redesign sub-agent pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement D-015 (Hybrid Diff Architecture) and D-016 (Sub-agent as visualization expert): - Add `tools/cli/diff.ts`: ID-based structural diff (.lastread vs .vync), tree output, layout field filtering, --no-snapshot option - Register `vync diff` subcommand in CLI - Redesign vync-translator agent: "통역가" → "시각화 전문가" with context-aware visualization judgment and programmatic diff input - Redesign vync command flows: Read/Create/Update now include conversation context + programmatic diff in sub-agent prompts - Add 15 unit tests for diff engine (computeDiff, vyncDiff, formatDiffResult) Co-Authored-By: Claude Opus 4.6 --- agents/vync-translator.md | 60 +++--- commands/vync.md | 66 +++--- tools/cli/__tests__/diff.test.ts | 346 +++++++++++++++++++++++++++++++ tools/cli/diff.ts | 291 ++++++++++++++++++++++++++ tools/cli/main.ts | 15 ++ 5 files changed, 709 insertions(+), 69 deletions(-) create mode 100644 tools/cli/__tests__/diff.test.ts create mode 100644 tools/cli/diff.ts diff --git a/agents/vync-translator.md b/agents/vync-translator.md index 9d128dc..199e7c3 100644 --- a/agents/vync-translator.md +++ b/agents/vync-translator.md @@ -1,15 +1,15 @@ --- name: vync-translator -description: Vync 통역가 — prose ↔ .vync JSON 양방향 번역. 시각적 다이어그램 생성/읽기/수정. +description: Vync 시각화 전문가 — 맥락 인지형 시각화 판단 + .vync JSON 생성/읽기/수정. tools: Read, Write, Edit, Bash, Glob, Grep skills: vync-editing model: sonnet permissionMode: bypassPermissions --- -당신은 Vync 통역가입니다. -prose 구조를 .vync 파일(PlaitElement JSON)로 변환하거나, -.vync 파일을 읽고 prose로 요약하는 전문가입니다. +당신은 Vync 시각화 전문가입니다. +대화 맥락과 유저 피드백을 이해하고, +적절한 시각적 표현을 판단하여 .vync 파일을 작성/수정합니다. ## 핵심 규칙 @@ -18,12 +18,24 @@ prose 구조를 .vync 파일(PlaitElement JSON)로 변환하거나, 3. **검증**: Write/Edit 후 반드시 `node ~/.claude/skills/vync-editing/scripts/validate.js ` 실행. 에러 시 수정 후 재작성. (PostToolUse hook은 sub-agent에서 발동하지 않음) 4. **서버 열기**: 파일 작성 후 `node "$VYNC_HOME/bin/vync.js" open ` 실행 (idempotent). +## 시각화 판단 가이드 + +맥락에 따른 시각화 유형 선택: +- 계획/구조 정리 → mindmap +- 프로세스/흐름 → flowchart (geometry + arrow-line) +- 비교/분류 → mindmap with 병렬 가지 +- 관계/연결 → flowchart + +시각화 보강 원칙: +- 유저의 변경 의도를 존중: 추가한 것은 유지, 삭제한 것은 되살리지 않음 +- 맥락에서 빠진 항목이 있으면 보강 제안 가능 +- 과도한 세분화 지양: 2-3단계 깊이 권장 (부분적 4단계는 허용, 20노드 이내) + ## 반환 포맷 **성공 시**: 한 줄 요약만 반환. 추가 설명 불필요. - create: `"mindmap: 프로젝트 > [기획, 개발, 출시]"` -- read (첫 읽기): `"mindmap: 프로젝트 > [기획(시장조사, 인터뷰), 개발(FE, BE)]"` -- read (변경 있음): `"mindmap: 프로젝트 > [기획, 개발, +테스팅] (변경: 테스팅 추가)"` +- read (변경 있음): `"변화 요약: 개발 프로세스에 테스팅이 추가되고, 기획의 리서치가 축소됨. 실행 중심으로 구조가 이동하는 방향."` - read (변경 없음): `"mindmap: 프로젝트 > [기획, 개발] (변경 없음)"` - update: `"updated: 개발 > [FE, BE, +테스트, +CI/CD]"` @@ -33,40 +45,32 @@ prose 구조를 .vync 파일(PlaitElement JSON)로 변환하거나, ## 작업별 절차 ### Create -1. 해당 타입의 참조 문서 Read (mindmap.md / geometry.md+arrow-line.md) -2. ID 생성 -3. PlaitElement[] JSON 구성 (skill 규칙 준수) +1. 대화 맥락 분석 → 시각화 유형 + 구조 판단 +2. 해당 타입의 참조 문서 Read (mindmap.md / geometry.md+arrow-line.md) +3. ID 생성 → PlaitElement[] JSON 구성 (skill 규칙 준수) 4. .vync 파일 Write (기존 파일 있으면 Read 후 merge) 5. `node ~/.claude/skills/vync-editing/scripts/validate.js ` 실행. 실패 시 수정 후 재작성. 6. 서버 열기 -7. 스냅샷 갱신: 작성한 .vync 내용을 `.lastread`에 Write +7. 스냅샷 생성: 작성한 .vync 내용을 `.lastread`에 Write +8. 반환: 무엇을 어떤 구조로 시각화했는지 요약 ### Read -1. .vync 파일 Read (현재 상태) -2. 스냅샷 파일 확인: `.lastread` 존재 여부 체크 (Bash: `test -f`) -3. **스냅샷 없음** (첫 읽기): - a. JSON 파싱하여 elements 분석 - b. 구조를 2단계 깊이까지 요약 - c. 한 줄 요약 반환 - d. 현재 .vync 내용을 `.lastread`에 Write (스냅샷 생성) -4. **스냅샷 있음** (후속 읽기): - a. 스냅샷 파일 Read - b. 현재 elements와 스냅샷 elements 비교: - - 추가된 노드: 스냅샷에 없는 id - - 삭제된 노드: 현재에 없는 id - - 변경된 노드: 같은 id지만 text/children이 다름 - c. 변경사항이 있으면 diff 포함하여 요약, 없으면 "(변경 없음)" 표시 - d. 현재 .vync 내용을 `.lastread`에 Write (스냅샷 갱신) +1. 전달받은 diff 결과를 분석 (직접 JSON 비교하지 않음!) +2. 대화 맥락과 결합하여 "생각의 변화"를 의미적으로 번역 +3. 반환: 변화의 의미 요약 + 예: "개발 프로세스에 테스팅이 추가되고, 기획의 리서치가 축소됨. 실행 중심으로 구조가 이동하는 방향." +4. 스냅샷 갱신은 vync diff가 이미 처리 (sub-agent에서 안 함) ### Update -1. .vync 파일 Read -2. 현재 구조 파악 -3. 지시에 따라 노드 추가/수정/삭제 +1. 대화 맥락 + diff 이해 +2. .vync 파일 Read (현재 상태) +3. 맥락과 diff를 고려하여 수정 판단 + 실행 - 구조적 변경(이동/재배치): Write로 전체 교체 - 텍스트 수정/노드 추가: Edit로 부분 수정 4. `node ~/.claude/skills/vync-editing/scripts/validate.js ` 실행. 실패 시 수정 후 재작성. 5. 서버 열기 6. 스냅샷 갱신: 수정된 .vync 내용을 `.lastread`에 Write +7. 반환: 무엇을 어떻게 변경했는지 요약 ## Skill 로드 Fallback diff --git a/commands/vync.md b/commands/vync.md index 6ec5f66..c2bc8df 100644 --- a/commands/vync.md +++ b/commands/vync.md @@ -1,7 +1,7 @@ --- -description: Vync server and file management (init, open, close, stop, read, create, update) +description: Vync server and file management (init, open, close, stop, diff, create, read, update) allowed-tools: Bash, Read, Agent -argument-hint: [args] +argument-hint: [args] --- Run the Vync CLI or delegate to the vync-translator sub-agent. @@ -14,6 +14,8 @@ Run the Vync CLI or delegate to the vync-translator sub-agent. - `close [file]` — Unregister file from server. If no files remain, server stops. - `--keep-server` — Unregister but keep server running. - `stop` — Stop the running Vync server +- `diff [file]` — 마지막 동기화 이후 변경사항 표시 (프로그래밍적) + - `--no-snapshot` — diff만 보고 스냅샷 갱신 안 함 For these, run via Bash: ```bash @@ -21,9 +23,9 @@ node "$VYNC_HOME/bin/vync.js" [args] ``` ### Sub-agent delegation (context window 보호) -- `create ` — prose → .vync 생성 + 서버 열기 -- `read [file]` — .vync → prose 번역 -- `update ` — 기존 .vync 점진적 편집 + 서버 열기 +- `create ` — 맥락 분석 → 시각화 판단 → .vync 생성 + 서버 열기 +- `read [file]` — diff 실행 → 의미적 번역 +- `update ` — diff 확인 → 맥락+지시 기반 .vync 수정 + 서버 열기 For these, use the Agent tool with `subagent_type: "vync-translator"`. @@ -39,71 +41,53 @@ For these, use the Agent tool with `subagent_type: "vync-translator"`. ### Create -1. 대화 맥락에서 구조 추출 → 트리 prose로 정리 -2. 파일경로 해결 (없으면 `vync init` 먼저) +1. 파일경로 해결 (없으면 `vync init` 먼저) +2. 대화 맥락 요약 (2-5문장) 3. Agent tool 호출: ``` Agent({ - description: "Vync create ", + description: "Vync create visualization", subagent_type: "vync-translator", mode: "bypassPermissions", - prompt: "## 작업: Create\n타입: \n파일: \n\n## 구조\n" + prompt: "## 작업: Create\n파일: \n\n## 대화 맥락\n<메인 세션이 요약한 현재 논의 상황, 2-5문장>\n\n## 지시\n<구체적 지시 or '맥락에 맞게 판단해서 시각화해줘'>\n<선호하는 유형이 있으면: 'mindmap 형식으로' 등>" }) ``` -4. Sub-agent의 한 줄 요약을 사용자에게 전달 +4. Sub-agent의 시각화 요약을 사용자에게 전달 ### Read 1. 파일경로 해결 -2. Agent tool 호출: +2. `vync diff ` 실행 (Bash) → 프로그래밍적 diff 결과 획득 +3. 대화 맥락 요약 (2-5문장) +4. Agent tool 호출: ``` Agent({ - description: "Vync read file", + description: "Vync read + translate diff", subagent_type: "vync-translator", - prompt: "## 작업: Read\n파일: " + mode: "bypassPermissions", + prompt: "## 작업: Read\n파일: \n\n## 대화 맥락\n<현재 논의 상황>\n\n## 유저 피드백 (diff)\n\n\n## 지시\n위 변경사항을 대화 맥락에 비춰 의미적으로 번역해줘." }) ``` -3. Sub-agent의 prose 요약을 대화에 통합 - - diff가 포함된 경우 ("변경: ..."), 변경 내용을 대화 맥락에 반영 - - "(변경 없음)"인 경우, 현재 상태만 참고 +5. Sub-agent의 의미적 번역을 대화에 통합 ### Update 1. 파일경로 해결 -2. 수정 지시를 자연어로 정리 (모호하면 사용자에게 확인) -3. Agent tool 호출: +2. `vync diff ` 실행 (유저 수정이 있었을 수 있으므로) +3. 대화 맥락 + diff + 지시 정리 +4. Agent tool 호출: ``` Agent({ - description: "Vync update diagram", + description: "Vync update visualization", subagent_type: "vync-translator", mode: "bypassPermissions", - prompt: "## 작업: Update\n파일: \n\n## 수정 지시\n<자연어 지시>" + prompt: "## 작업: Update\n파일: \n\n## 대화 맥락\n<현재 논의 상황>\n\n## 유저 피드백 (diff)\n\n\n## 지시\n<구체적 수정 지시>" }) ``` -4. Sub-agent의 변경 요약을 사용자에게 전달 - -## Prose 정리 가이드 (Create/Update 시) - -구조를 정리할 때: -- 트리 형태의 인덴트된 목록 사용 -- 각 항목은 구체적인 이름/레이블 포함 -- 관계나 연결이 있으면 명시 (A → B) -- 대명사 대신 실제 내용 사용 -- 구조가 불명확하면 사용자에게 확인 후 sub-agent 호출 - -예시: -- 프로젝트 (root) - - 기획 - - 시장 조사 - - 사용자 인터뷰 - - 개발 - - 프론트엔드 (React) - - 백엔드 (Express) - - 출시 - - 마케팅 +5. Sub-agent의 변경 요약을 사용자에게 전달 diff --git a/tools/cli/__tests__/diff.test.ts b/tools/cli/__tests__/diff.test.ts new file mode 100644 index 0000000..febc4c0 --- /dev/null +++ b/tools/cli/__tests__/diff.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { computeDiff, vyncDiff, formatDiffResult } from '../diff.js'; + +// --- Helper: build mindmap element --- + +function mindmapEl( + id: string, + text: string, + children: Record[] = [], + extra: Record = {} +): Record { + return { + id, + type: children.length === 0 && !extra.isRoot ? 'mind_child' : 'mindmap', + data: { topic: { children: [{ text }] } }, + children, + ...extra, + }; +} + +function childEl( + id: string, + text: string, + children: Record[] = [] +): Record { + return { + id, + type: 'mind_child', + data: { topic: { children: [{ text }] } }, + children, + }; +} + +function vyncFile(elements: Record[]): string { + return JSON.stringify( + { version: 1, viewport: { zoom: 1, x: 0, y: 0 }, elements }, + null, + 2 + ); +} + +// --- computeDiff tests --- + +describe('computeDiff', () => { + it('detects no changes when elements are identical', () => { + const elements = [ + mindmapEl('root', 'Project', [childEl('a', 'Design')], { isRoot: true }), + ]; + const changes = computeDiff(elements, elements); + expect(changes).toEqual([]); + }); + + it('detects added nodes', () => { + const snapshot = [ + mindmapEl('root', 'Project', [childEl('a', 'Design')], { + isRoot: true, + }), + ]; + const current = [ + mindmapEl( + 'root', + 'Project', + [childEl('a', 'Design'), childEl('b', 'Dev')], + { isRoot: true } + ), + ]; + const changes = computeDiff(current, snapshot); + expect(changes).toHaveLength(1); + expect(changes[0].kind).toBe('added'); + expect(changes[0].text).toBe('Dev'); + expect(changes[0].detail).toContain('under Project'); + }); + + it('detects removed nodes', () => { + const snapshot = [ + mindmapEl( + 'root', + 'Project', + [childEl('a', 'Design'), childEl('b', 'Dev')], + { isRoot: true } + ), + ]; + const current = [ + mindmapEl('root', 'Project', [childEl('a', 'Design')], { + isRoot: true, + }), + ]; + const changes = computeDiff(current, snapshot); + expect(changes).toHaveLength(1); + expect(changes[0].kind).toBe('removed'); + expect(changes[0].text).toBe('Dev'); + expect(changes[0].detail).toContain('was under Project'); + }); + + it('detects modified text', () => { + const snapshot = [ + mindmapEl('root', 'Project', [childEl('a', 'Design')], { + isRoot: true, + }), + ]; + const current = [ + mindmapEl('root', 'Project', [childEl('a', 'Architecture')], { + isRoot: true, + }), + ]; + const changes = computeDiff(current, snapshot); + expect(changes).toHaveLength(1); + expect(changes[0].kind).toBe('modified'); + expect(changes[0].detail).toContain('"Design" → "Architecture"'); + }); + + it('detects moved nodes (parent changed)', () => { + const snapshot = [ + mindmapEl( + 'root', + 'Project', + [ + childEl('a', 'Design', [childEl('c', 'UX')]), + childEl('b', 'Dev'), + ], + { isRoot: true } + ), + ]; + // Move UX from Design to Dev + const current = [ + mindmapEl( + 'root', + 'Project', + [ + childEl('a', 'Design'), + childEl('b', 'Dev', [childEl('c', 'UX')]), + ], + { isRoot: true } + ), + ]; + const changes = computeDiff(current, snapshot); + expect(changes).toHaveLength(1); + expect(changes[0].kind).toBe('moved'); + expect(changes[0].detail).toContain('Design → Dev'); + }); + + it('detects multiple change types at once', () => { + const snapshot = [ + mindmapEl( + 'root', + 'Project', + [ + childEl('a', 'Design'), + childEl('b', 'Dev'), + childEl('c', 'Test'), + ], + { isRoot: true } + ), + ]; + const current = [ + mindmapEl( + 'root', + 'Project', + [ + childEl('a', 'UI Design'), // modified + // b removed + childEl('c', 'Test'), + childEl('d', 'Deploy'), // added + ], + { isRoot: true } + ), + ]; + const changes = computeDiff(current, snapshot); + const kinds = changes.map((c) => c.kind); + expect(kinds).toContain('added'); + expect(kinds).toContain('removed'); + expect(kinds).toContain('modified'); + }); + + it('handles geometry elements with text field', () => { + const snapshot = [ + { + id: 'g1', + type: 'geometry', + shape: 'rectangle', + text: { children: [{ text: 'Start' }] }, + points: [[0, 0], [100, 50]], + children: [], + }, + ]; + const current = [ + { + id: 'g1', + type: 'geometry', + shape: 'rectangle', + text: { children: [{ text: 'Begin' }] }, + points: [[0, 0], [100, 50]], + children: [], + }, + ]; + const changes = computeDiff(current, snapshot); + expect(changes).toHaveLength(1); + expect(changes[0].kind).toBe('modified'); + expect(changes[0].detail).toContain('"Start" → "Begin"'); + }); + + it('returns empty changes for empty elements', () => { + const changes = computeDiff([], []); + expect(changes).toEqual([]); + }); +}); + +// --- vyncDiff integration tests --- + +describe('vyncDiff', () => { + const tmpDir = path.join(os.tmpdir(), 'vync-test-diff'); + + beforeEach(async () => { + await fs.mkdir(tmpDir, { recursive: true }); + process.env.VYNC_CALLER_CWD = tmpDir; + }); + + afterEach(async () => { + delete process.env.VYNC_CALLER_CWD; + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns tree structure with no changes for first diff', async () => { + const elements = [ + mindmapEl('root', 'Project', [childEl('a', 'Design')], { + isRoot: true, + width: 100, + height: 50, + points: [[0, 0]], + }), + ]; + const filePath = path.join(tmpDir, 'test.vync'); + await fs.writeFile(filePath, vyncFile(elements)); + + const result = await vyncDiff(filePath); + + expect(result.tree).toContain('Project'); + expect(result.tree).toContain('Design'); + expect(result.changes).toEqual([]); + expect(result.hasChanges).toBe(false); + }); + + it('creates snapshot on first diff', async () => { + const filePath = path.join(tmpDir, 'test.vync'); + await fs.writeFile(filePath, vyncFile([])); + + await vyncDiff(filePath); + + const snapshotExists = await fs + .access(filePath + '.lastread') + .then(() => true) + .catch(() => false); + expect(snapshotExists).toBe(true); + }); + + it('detects changes between snapshot and current', async () => { + const filePath = path.join(tmpDir, 'test.vync'); + + // Write initial version and create snapshot + const v1 = [ + mindmapEl('root', 'Project', [childEl('a', 'Design')], { + isRoot: true, + }), + ]; + await fs.writeFile(filePath, vyncFile(v1)); + await vyncDiff(filePath); // creates snapshot + + // Modify file (simulate browser edit) + const v2 = [ + mindmapEl( + 'root', + 'Project', + [childEl('a', 'Design'), childEl('b', 'Dev')], + { isRoot: true } + ), + ]; + await fs.writeFile(filePath, vyncFile(v2)); + + // Diff again + const result = await vyncDiff(filePath); + expect(result.hasChanges).toBe(true); + expect(result.changes).toHaveLength(1); + expect(result.changes[0].kind).toBe('added'); + expect(result.changes[0].text).toBe('Dev'); + }); + + it('respects --no-snapshot option', async () => { + const filePath = path.join(tmpDir, 'test.vync'); + await fs.writeFile(filePath, vyncFile([])); + + await vyncDiff(filePath, { noSnapshot: true }); + + const snapshotExists = await fs + .access(filePath + '.lastread') + .then(() => true) + .catch(() => false); + expect(snapshotExists).toBe(false); + }); + + it('throws for non-existent file', async () => { + await expect( + vyncDiff(path.join(tmpDir, 'nope.vync')) + ).rejects.toThrow('File not found'); + }); +}); + +// --- formatDiffResult tests --- + +describe('formatDiffResult', () => { + it('formats output with changes', () => { + const output = formatDiffResult({ + filePath: '/path/to/plan.vync', + tree: ' Project\n ├── Design\n └── Dev', + changes: [ + { + kind: 'added', + id: 'b', + text: 'Dev', + detail: 'Added: Dev (under Project)', + }, + ], + hasChanges: true, + }); + + expect(output).toContain('=== Vync Diff: plan.vync ==='); + expect(output).toContain('현재 구조:'); + expect(output).toContain('Project'); + expect(output).toContain('변경사항:'); + expect(output).toContain('Added: Dev (under Project)'); + expect(output).toContain('Snapshot updated.'); + }); + + it('formats output with no changes', () => { + const output = formatDiffResult({ + filePath: '/path/to/plan.vync', + tree: ' Project', + changes: [], + hasChanges: false, + }); + + expect(output).toContain('변경사항: 없음'); + }); +}); diff --git a/tools/cli/diff.ts b/tools/cli/diff.ts new file mode 100644 index 0000000..d4c806c --- /dev/null +++ b/tools/cli/diff.ts @@ -0,0 +1,291 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { resolveVyncPath } from './resolve.js'; + +// --- Types --- + +interface FlatNode { + id: string; + text: string; + parentId: string | null; + type: string; + childIds: string[]; +} + +interface DiffChange { + kind: 'added' | 'removed' | 'modified' | 'moved'; + id: string; + text: string; + detail: string; +} + +export interface DiffResult { + filePath: string; + tree: string; + changes: DiffChange[]; + hasChanges: boolean; +} + +// --- Layout fields to ignore --- + +const LAYOUT_FIELDS = new Set([ + 'points', + 'width', + 'height', + 'manualWidth', + 'manualHeight', +]); + +// --- Helper: extract text from element --- + +function extractText(el: Record): string { + // Mindmap: data.topic.children[0].text + const data = el.data as Record | undefined; + if (data) { + const topic = data.topic as Record | undefined; + if (topic) { + const children = topic.children as Array<{ text?: string }> | undefined; + if (children && children.length > 0 && children[0].text) { + return children[0].text; + } + } + } + // Geometry/Arrow: text.children[0].text + const text = el.text as Record | undefined; + if (text) { + const children = text.children as Array<{ text?: string }> | undefined; + if (children && children.length > 0 && children[0].text) { + return children[0].text; + } + } + return ''; +} + +// --- Helper: flatten elements recursively --- + +function flattenElements( + elements: Record[], + parentId: string | null, + map: Map +): void { + for (const el of elements) { + const id = el.id as string; + if (!id) continue; + + const text = extractText(el); + const type = (el.type as string) || 'unknown'; + const children = (el.children as Record[]) || []; + const childIds = children + .map((c) => c.id as string) + .filter(Boolean); + + map.set(id, { id, text, parentId, type, childIds }); + + if (children.length > 0) { + flattenElements(children, id, map); + } + } +} + +// --- Helper: build tree string --- + +function buildTreeString( + elements: Record[], + indent: string = '', + isLast: boolean[] = [] +): string { + const lines: string[] = []; + + for (let i = 0; i < elements.length; i++) { + const el = elements[i]; + const text = extractText(el); + const children = (el.children as Record[]) || []; + const last = i === elements.length - 1; + + if (indent === '') { + // Root level + lines.push(` ${text}`); + } else { + const prefix = indent + (last ? '└── ' : '├── '); + lines.push(prefix + text); + } + + if (children.length > 0) { + const childIndent = + indent === '' ? ' ' : indent + (last ? ' ' : '│ '); + lines.push( + buildTreeString(children, childIndent, [...isLast, last]) + ); + } + } + + return lines.join('\n'); +} + +// --- Helper: find parent text --- + +function findParentText( + map: Map, + parentId: string | null +): string { + if (!parentId) return 'root'; + const parent = map.get(parentId); + return parent ? parent.text : parentId; +} + +// --- Core: compute diff --- + +export function computeDiff( + currentElements: Record[], + snapshotElements: Record[] +): DiffChange[] { + const currentMap = new Map(); + const snapshotMap = new Map(); + + flattenElements(currentElements, null, currentMap); + flattenElements(snapshotElements, null, snapshotMap); + + const changes: DiffChange[] = []; + + // Added: in current but not in snapshot + for (const [id, node] of currentMap) { + if (!snapshotMap.has(id)) { + const parentText = findParentText(currentMap, node.parentId); + changes.push({ + kind: 'added', + id, + text: node.text, + detail: `Added: ${node.text} (under ${parentText})`, + }); + } + } + + // Removed: in snapshot but not in current + for (const [id, node] of snapshotMap) { + if (!currentMap.has(id)) { + const parentText = findParentText(snapshotMap, node.parentId); + changes.push({ + kind: 'removed', + id, + text: node.text, + detail: `Removed: ${node.text} (was under ${parentText})`, + }); + } + } + + // Modified & Moved: same ID in both + for (const [id, current] of currentMap) { + const snapshot = snapshotMap.get(id); + if (!snapshot) continue; + + // Text modified + if (current.text !== snapshot.text) { + changes.push({ + kind: 'modified', + id, + text: current.text, + detail: `Modified: "${snapshot.text}" → "${current.text}"`, + }); + } + + // Parent changed (moved) + if (current.parentId !== snapshot.parentId) { + const fromParent = findParentText(snapshotMap, snapshot.parentId); + const toParent = findParentText(currentMap, current.parentId); + changes.push({ + kind: 'moved', + id, + text: current.text, + detail: `Moved: ${current.text} — ${fromParent} → ${toParent}`, + }); + } + } + + return changes; +} + +// --- CLI entry point --- + +export async function vyncDiff( + filePath: string, + options: { noSnapshot?: boolean } = {} +): Promise { + const resolved = resolveVyncPath(filePath); + const snapshotPath = resolved + '.lastread'; + const fileName = path.basename(resolved); + + // Read current .vync file + let currentRaw: string; + try { + currentRaw = await fs.readFile(resolved, 'utf-8'); + } catch { + throw new Error(`File not found: ${resolved}`); + } + + const currentData = JSON.parse(currentRaw); + const currentElements = currentData.elements || []; + + // Build tree from current state + const tree = buildTreeString(currentElements); + + // Check snapshot + let snapshotExists = false; + let snapshotElements: Record[] = []; + try { + const snapshotRaw = await fs.readFile(snapshotPath, 'utf-8'); + const snapshotData = JSON.parse(snapshotRaw); + snapshotElements = snapshotData.elements || []; + snapshotExists = true; + } catch { + // No snapshot — first diff + } + + // Compute changes + let changes: DiffChange[] = []; + if (snapshotExists) { + changes = computeDiff(currentElements, snapshotElements); + } + + // Update snapshot (unless --no-snapshot) + if (!options.noSnapshot) { + await fs.writeFile(snapshotPath, currentRaw, 'utf-8'); + } + + return { + filePath: resolved, + tree, + changes, + hasChanges: changes.length > 0, + }; +} + +// --- Format output --- + +export function formatDiffResult(result: DiffResult): string { + const fileName = path.basename(result.filePath); + const lines: string[] = []; + + lines.push(`=== Vync Diff: ${fileName} ===`); + lines.push(''); + lines.push('현재 구조:'); + if (result.tree) { + lines.push(result.tree); + } else { + lines.push(' (empty)'); + } + + lines.push(''); + if (result.changes.length > 0) { + lines.push('변경사항:'); + for (const change of result.changes) { + lines.push(` ${change.detail}`); + } + } else { + lines.push('변경사항: 없음'); + } + + lines.push(''); + lines.push('Snapshot updated.'); + + return lines.join('\n'); +} diff --git a/tools/cli/main.ts b/tools/cli/main.ts index 9811cf8..aa40c3b 100644 --- a/tools/cli/main.ts +++ b/tools/cli/main.ts @@ -1,5 +1,6 @@ import { vyncInit } from './init.js'; import { vyncOpen, vyncStop, vyncClose } from './open.js'; +import { vyncDiff, formatDiffResult } from './diff.js'; const USAGE = `Usage: vync [options] @@ -10,12 +11,15 @@ Commands: close [file] Unregister file (or all files if no file given) --keep-server Keep server running even if no files left stop Stop the running server + diff Show changes since last sync + --no-snapshot Don't update snapshot after diff Examples: vync init plan # creates .vync/plan.vync vync open plan # opens .vync/plan.vync vync close plan # unregisters plan.vync (stops server if last file) vync close # unregisters all files and stops server + vync diff plan # shows changes since last read vync stop`; async function main() { @@ -53,6 +57,17 @@ async function main() { await vyncClose(filePath, { keepServer }); break; } + case 'diff': { + const noSnapshot = args.includes('--no-snapshot'); + const filePath = args.find((a) => !a.startsWith('--')); + if (!filePath) { + console.error('Usage: vync diff [--no-snapshot]'); + process.exit(1); + } + const result = await vyncDiff(filePath, { noSnapshot }); + console.log(formatDiffResult(result)); + break; + } case 'stop': { await vyncStop(); break;