prjct-cli 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Diff Generator for Sync Preview
3
+ *
4
+ * Generates human-readable diffs between old and new context files.
5
+ * Shows what will change before sync applies.
6
+ *
7
+ * @see PRJ-125
8
+ * @module services/diff-generator
9
+ */
10
+
11
+ import chalk from 'chalk'
12
+
13
+ // =============================================================================
14
+ // Types
15
+ // =============================================================================
16
+
17
+ export interface DiffSection {
18
+ name: string
19
+ type: 'added' | 'modified' | 'removed' | 'unchanged'
20
+ before?: string
21
+ after?: string
22
+ lineCount: number
23
+ }
24
+
25
+ export interface PreservedInfo {
26
+ name: string
27
+ lineCount: number
28
+ }
29
+
30
+ export interface SyncDiff {
31
+ hasChanges: boolean
32
+ added: DiffSection[]
33
+ modified: DiffSection[]
34
+ removed: DiffSection[]
35
+ preserved: PreservedInfo[]
36
+ tokensBefore: number
37
+ tokensAfter: number
38
+ tokenDelta: number
39
+ }
40
+
41
+ export interface DiffOptions {
42
+ showFullDiff?: boolean
43
+ colorize?: boolean
44
+ }
45
+
46
+ // =============================================================================
47
+ // Constants
48
+ // =============================================================================
49
+
50
+ const CHARS_PER_TOKEN = 4
51
+
52
+ // =============================================================================
53
+ // Token Estimation
54
+ // =============================================================================
55
+
56
+ /**
57
+ * Estimate token count for content
58
+ */
59
+ export function estimateTokens(content: string): number {
60
+ return Math.ceil(content.length / CHARS_PER_TOKEN)
61
+ }
62
+
63
+ // =============================================================================
64
+ // Section Parsing
65
+ // =============================================================================
66
+
67
+ interface ParsedSection {
68
+ name: string
69
+ content: string
70
+ startLine: number
71
+ endLine: number
72
+ }
73
+
74
+ /**
75
+ * Parse markdown into sections based on headers
76
+ */
77
+ export function parseMarkdownSections(content: string): ParsedSection[] {
78
+ const lines = content.split('\n')
79
+ const sections: ParsedSection[] = []
80
+ let currentSection: ParsedSection | null = null
81
+
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i]
84
+ const headerMatch = line.match(/^(#{1,3})\s+(.+)$/)
85
+
86
+ if (headerMatch) {
87
+ // Save previous section
88
+ if (currentSection) {
89
+ currentSection.endLine = i - 1
90
+ sections.push(currentSection)
91
+ }
92
+
93
+ // Start new section
94
+ currentSection = {
95
+ name: headerMatch[2].trim(),
96
+ content: line,
97
+ startLine: i,
98
+ endLine: i,
99
+ }
100
+ } else if (currentSection) {
101
+ currentSection.content += `\n${line}`
102
+ }
103
+ }
104
+
105
+ // Save last section
106
+ if (currentSection) {
107
+ currentSection.endLine = lines.length - 1
108
+ sections.push(currentSection)
109
+ }
110
+
111
+ return sections
112
+ }
113
+
114
+ /**
115
+ * Check if content is within a preserve block
116
+ */
117
+ function isPreservedSection(content: string): boolean {
118
+ return content.includes('<!-- prjct:preserve')
119
+ }
120
+
121
+ // =============================================================================
122
+ // Diff Generation
123
+ // =============================================================================
124
+
125
+ /**
126
+ * Generate diff between old and new content
127
+ */
128
+ export function generateSyncDiff(oldContent: string, newContent: string): SyncDiff {
129
+ const oldSections = parseMarkdownSections(oldContent)
130
+ const newSections = parseMarkdownSections(newContent)
131
+
132
+ const diff: SyncDiff = {
133
+ hasChanges: false,
134
+ added: [],
135
+ modified: [],
136
+ removed: [],
137
+ preserved: [],
138
+ tokensBefore: estimateTokens(oldContent),
139
+ tokensAfter: estimateTokens(newContent),
140
+ tokenDelta: 0,
141
+ }
142
+
143
+ diff.tokenDelta = diff.tokensAfter - diff.tokensBefore
144
+
145
+ // Create maps for quick lookup
146
+ const oldMap = new Map(oldSections.map((s) => [s.name.toLowerCase(), s]))
147
+ const newMap = new Map(newSections.map((s) => [s.name.toLowerCase(), s]))
148
+
149
+ // Find preserved sections in old content
150
+ for (const section of oldSections) {
151
+ if (isPreservedSection(section.content)) {
152
+ diff.preserved.push({
153
+ name: section.name,
154
+ lineCount: section.content.split('\n').length,
155
+ })
156
+ }
157
+ }
158
+
159
+ // Find added and modified sections
160
+ for (const newSection of newSections) {
161
+ const key = newSection.name.toLowerCase()
162
+ const oldSection = oldMap.get(key)
163
+
164
+ if (!oldSection) {
165
+ // New section
166
+ diff.added.push({
167
+ name: newSection.name,
168
+ type: 'added',
169
+ after: newSection.content,
170
+ lineCount: newSection.content.split('\n').length,
171
+ })
172
+ diff.hasChanges = true
173
+ } else if (oldSection.content.trim() !== newSection.content.trim()) {
174
+ // Modified section (skip if preserved)
175
+ if (!isPreservedSection(oldSection.content)) {
176
+ diff.modified.push({
177
+ name: newSection.name,
178
+ type: 'modified',
179
+ before: oldSection.content,
180
+ after: newSection.content,
181
+ lineCount: newSection.content.split('\n').length,
182
+ })
183
+ diff.hasChanges = true
184
+ }
185
+ }
186
+ }
187
+
188
+ // Find removed sections
189
+ for (const oldSection of oldSections) {
190
+ const key = oldSection.name.toLowerCase()
191
+ if (!newMap.has(key) && !isPreservedSection(oldSection.content)) {
192
+ diff.removed.push({
193
+ name: oldSection.name,
194
+ type: 'removed',
195
+ before: oldSection.content,
196
+ lineCount: oldSection.content.split('\n').length,
197
+ })
198
+ diff.hasChanges = true
199
+ }
200
+ }
201
+
202
+ return diff
203
+ }
204
+
205
+ // =============================================================================
206
+ // Diff Formatting
207
+ // =============================================================================
208
+
209
+ /**
210
+ * Format diff for terminal display
211
+ */
212
+ export function formatDiffPreview(diff: SyncDiff, options: DiffOptions = {}): string {
213
+ const { colorize = true } = options
214
+ const lines: string[] = []
215
+
216
+ const green = colorize ? chalk.green : (s: string) => s
217
+ const red = colorize ? chalk.red : (s: string) => s
218
+ const yellow = colorize ? chalk.yellow : (s: string) => s
219
+ const dim = colorize ? chalk.dim : (s: string) => s
220
+ const bold = colorize ? chalk.bold : (s: string) => s
221
+
222
+ if (!diff.hasChanges) {
223
+ lines.push(dim('No changes detected (context is up to date)'))
224
+ return lines.join('\n')
225
+ }
226
+
227
+ lines.push('')
228
+ lines.push(bold('📋 Changes to context files:'))
229
+ lines.push('')
230
+
231
+ // Added sections
232
+ if (diff.added.length > 0) {
233
+ for (const section of diff.added) {
234
+ lines.push(green(`+ │ + ${section.name} (new)`))
235
+ }
236
+ }
237
+
238
+ // Modified sections
239
+ if (diff.modified.length > 0) {
240
+ for (const section of diff.modified) {
241
+ lines.push(yellow(`~ │ ${section.name} (modified)`))
242
+ }
243
+ }
244
+
245
+ // Removed sections
246
+ if (diff.removed.length > 0) {
247
+ for (const section of diff.removed) {
248
+ lines.push(red(`- │ - ${section.name} (removed)`))
249
+ }
250
+ }
251
+
252
+ // Preserved sections
253
+ if (diff.preserved.length > 0) {
254
+ lines.push('')
255
+ lines.push(dim(' ## Your Customizations'))
256
+ for (const section of diff.preserved) {
257
+ lines.push(dim(` │ ✓ ${section.name} (${section.lineCount} lines preserved)`))
258
+ }
259
+ }
260
+
261
+ // Summary
262
+ lines.push('')
263
+ lines.push(dim('────────────────────────────────'))
264
+
265
+ const summaryParts: string[] = []
266
+ if (diff.added.length > 0) summaryParts.push(green(`+${diff.added.length} added`))
267
+ if (diff.modified.length > 0) summaryParts.push(yellow(`~${diff.modified.length} modified`))
268
+ if (diff.removed.length > 0) summaryParts.push(red(`-${diff.removed.length} removed`))
269
+
270
+ lines.push(`Summary: ${summaryParts.join(', ') || 'no changes'}`)
271
+
272
+ // Token delta
273
+ const tokenSign = diff.tokenDelta >= 0 ? '+' : ''
274
+ const tokenColor = diff.tokenDelta >= 0 ? green : red
275
+ lines.push(
276
+ `Tokens: ${diff.tokensBefore.toLocaleString()} → ${diff.tokensAfter.toLocaleString()} (${tokenColor(tokenSign + diff.tokenDelta.toLocaleString())})`
277
+ )
278
+
279
+ lines.push('')
280
+
281
+ return lines.join('\n')
282
+ }
283
+
284
+ /**
285
+ * Format full git-style diff
286
+ */
287
+ export function formatFullDiff(diff: SyncDiff, options: DiffOptions = {}): string {
288
+ const { colorize = true } = options
289
+ const lines: string[] = []
290
+
291
+ const green = colorize ? chalk.green : (s: string) => s
292
+ const red = colorize ? chalk.red : (s: string) => s
293
+ const cyan = colorize ? chalk.cyan : (s: string) => s
294
+ const dim = colorize ? chalk.dim : (s: string) => s
295
+
296
+ // Added sections
297
+ for (const section of diff.added) {
298
+ lines.push(cyan(`@@ +${section.name} @@`))
299
+ if (section.after) {
300
+ for (const line of section.after.split('\n')) {
301
+ lines.push(green(`+ ${line}`))
302
+ }
303
+ }
304
+ lines.push('')
305
+ }
306
+
307
+ // Modified sections
308
+ for (const section of diff.modified) {
309
+ lines.push(cyan(`@@ ${section.name} @@`))
310
+ if (section.before) {
311
+ for (const line of section.before.split('\n').slice(0, 5)) {
312
+ lines.push(red(`- ${line}`))
313
+ }
314
+ if (section.before.split('\n').length > 5) {
315
+ lines.push(dim(` ... ${section.before.split('\n').length - 5} more lines`))
316
+ }
317
+ }
318
+ if (section.after) {
319
+ for (const line of section.after.split('\n').slice(0, 5)) {
320
+ lines.push(green(`+ ${line}`))
321
+ }
322
+ if (section.after.split('\n').length > 5) {
323
+ lines.push(dim(` ... ${section.after.split('\n').length - 5} more lines`))
324
+ }
325
+ }
326
+ lines.push('')
327
+ }
328
+
329
+ // Removed sections
330
+ for (const section of diff.removed) {
331
+ lines.push(cyan(`@@ -${section.name} @@`))
332
+ if (section.before) {
333
+ for (const line of section.before.split('\n').slice(0, 5)) {
334
+ lines.push(red(`- ${line}`))
335
+ }
336
+ if (section.before.split('\n').length > 5) {
337
+ lines.push(dim(` ... ${section.before.split('\n').length - 5} more lines`))
338
+ }
339
+ }
340
+ lines.push('')
341
+ }
342
+
343
+ return lines.join('\n')
344
+ }
345
+
346
+ // =============================================================================
347
+ // Exports
348
+ // =============================================================================
349
+
350
+ export default {
351
+ generateSyncDiff,
352
+ formatDiffPreview,
353
+ formatFullDiff,
354
+ estimateTokens,
355
+ parseMarkdownSections,
356
+ }
@@ -31,6 +31,7 @@ import pathManager from '../infrastructure/path-manager'
31
31
  import { metricsStorage } from '../storage/metrics-storage'
32
32
  import dateHelper from '../utils/date-helper'
33
33
  import { ContextFileGenerator } from './context-generator'
34
+ import type { SyncDiff } from './diff-generator'
34
35
  import { type StackDetection, StackDetector } from './stack-detector'
35
36
 
36
37
  const execAsync = promisify(exec)
@@ -104,10 +105,15 @@ interface SyncResult {
104
105
  aiTools: AIToolResult[]
105
106
  syncMetrics?: SyncMetrics
106
107
  error?: string
108
+ // Preview mode fields
109
+ isPreview?: boolean
110
+ previewDiff?: SyncDiff
107
111
  }
108
112
 
109
113
  interface SyncOptions {
110
114
  aiTools?: string[] // AI tools to generate context for (default: claude, cursor)
115
+ preview?: boolean // If true, return diff without applying changes
116
+ skipConfirmation?: boolean // If true, apply changes without confirmation (--yes flag)
111
117
  }
112
118
 
113
119
  // ============================================================================