create-universal-ai-context 2.0.0 → 2.1.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,540 @@
1
+ /**
2
+ * AI Context Engineering - Smart Merge Module
3
+ *
4
+ * Intelligently merges existing documentation with new analysis results.
5
+ * Preserves user customizations while updating stale references and adding new content.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ /**
12
+ * Decision types for merge operations
13
+ */
14
+ const DECISION_TYPE = {
15
+ PRESERVE: 'preserve', // Keep existing value (user customized)
16
+ UPDATE: 'update', // Replace with new value
17
+ CONFLICT: 'conflict', // Values differ, needs resolution
18
+ PRESERVE_SECTION: 'preserve_section', // Keep custom section
19
+ REMOVE_STALE_REF: 'remove_stale_ref', // Remove reference to deleted file
20
+ UPDATE_REF: 'update_ref', // Update line reference
21
+ ADD_WORKFLOW: 'add_workflow', // Add newly discovered workflow
22
+ ADD_ENTRY_POINT: 'add_entry_point' // Add new entry point
23
+ };
24
+
25
+ /**
26
+ * Extract content from an existing documentation file
27
+ * @param {string} filePath - Path to existing file
28
+ * @param {string} templatePath - Path to template for comparison
29
+ * @returns {object} Extracted content
30
+ */
31
+ function extractExistingContent(filePath, templatePath = null) {
32
+ if (!fs.existsSync(filePath)) {
33
+ return null;
34
+ }
35
+
36
+ const content = fs.readFileSync(filePath, 'utf-8');
37
+
38
+ const result = {
39
+ raw: content,
40
+ sections: parseMarkdownSections(content),
41
+ placeholders: extractPlaceholderValues(content),
42
+ lineReferences: extractLineReferences(content),
43
+ customSections: [],
44
+ frontmatter: extractFrontmatter(content)
45
+ };
46
+
47
+ // If template provided, identify custom sections
48
+ if (templatePath && fs.existsSync(templatePath)) {
49
+ const templateContent = fs.readFileSync(templatePath, 'utf-8');
50
+ const templateSections = parseMarkdownSections(templateContent);
51
+ const templateHeadings = new Set(templateSections.map(s => s.heading.toLowerCase()));
52
+
53
+ result.customSections = result.sections.filter(
54
+ s => !templateHeadings.has(s.heading.toLowerCase())
55
+ );
56
+ }
57
+
58
+ return result;
59
+ }
60
+
61
+ /**
62
+ * Parse markdown into sections by headings
63
+ * @param {string} content - Markdown content
64
+ * @returns {Array} Array of sections
65
+ */
66
+ function parseMarkdownSections(content) {
67
+ const sections = [];
68
+ const lines = content.split('\n');
69
+ let currentSection = { heading: 'root', level: 0, content: [], startLine: 0 };
70
+
71
+ for (let i = 0; i < lines.length; i++) {
72
+ const line = lines[i];
73
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
74
+
75
+ if (headingMatch) {
76
+ // Save previous section if it has content
77
+ if (currentSection.content.length > 0 || currentSection.heading !== 'root') {
78
+ currentSection.content = currentSection.content.join('\n').trim();
79
+ sections.push(currentSection);
80
+ }
81
+
82
+ currentSection = {
83
+ heading: headingMatch[2].trim(),
84
+ level: headingMatch[1].length,
85
+ content: [],
86
+ startLine: i
87
+ };
88
+ } else {
89
+ currentSection.content.push(line);
90
+ }
91
+ }
92
+
93
+ // Save final section
94
+ currentSection.content = currentSection.content.join('\n').trim();
95
+ sections.push(currentSection);
96
+
97
+ return sections;
98
+ }
99
+
100
+ /**
101
+ * Extract placeholder values from content
102
+ * @param {string} content - File content
103
+ * @returns {object} Map of placeholder name to value
104
+ */
105
+ function extractPlaceholderValues(content) {
106
+ const values = {};
107
+
108
+ // Known placeholder patterns and their extraction contexts
109
+ const patterns = [
110
+ { name: 'PROJECT_NAME', regex: /\*\*Project(?:\s*Name)?:\*\*\s*(.+?)(?:\n|$)/i },
111
+ { name: 'PROJECT_DESCRIPTION', regex: /\*\*(?:Platform|Description):\*\*\s*(.+?)(?:\n|$)/i },
112
+ { name: 'TECH_STACK', regex: /\*\*Tech Stack:\*\*\s*(.+?)(?:\n|$)/i },
113
+ { name: 'PRODUCTION_URL', regex: /\*\*(?:Domain|URL):\*\*\s*(.+?)(?:\n|$)/i },
114
+ { name: 'API_URL', regex: /\*\*API:\*\*\s*(.+?)(?:\n|$)/i },
115
+ { name: 'REPO_URL', regex: /\*\*Repo:\*\*\s*(.+?)(?:\n|$)/i }
116
+ ];
117
+
118
+ for (const { name, regex } of patterns) {
119
+ const match = content.match(regex);
120
+ if (match && match[1]) {
121
+ const value = match[1].trim();
122
+ // Skip if still a placeholder
123
+ if (!value.match(/\{\{[A-Z_]+\}\}/)) {
124
+ values[name] = value;
125
+ }
126
+ }
127
+ }
128
+
129
+ return values;
130
+ }
131
+
132
+ /**
133
+ * Extract line references from content
134
+ * @param {string} content - File content
135
+ * @returns {Array} Array of line references
136
+ */
137
+ function extractLineReferences(content) {
138
+ const refs = [];
139
+ const pattern = /([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+):(\d+)(?:-(\d+))?/g;
140
+
141
+ let match;
142
+ while ((match = pattern.exec(content)) !== null) {
143
+ refs.push({
144
+ file: match[1].replace(/\\/g, '/'),
145
+ line: parseInt(match[2], 10),
146
+ endLine: match[3] ? parseInt(match[3], 10) : null,
147
+ original: match[0],
148
+ position: match.index
149
+ });
150
+ }
151
+
152
+ return refs;
153
+ }
154
+
155
+ /**
156
+ * Extract YAML frontmatter from markdown
157
+ * @param {string} content - File content
158
+ * @returns {object|null} Parsed frontmatter
159
+ */
160
+ function extractFrontmatter(content) {
161
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
162
+ if (!match) return null;
163
+
164
+ try {
165
+ // Simple YAML parsing for common cases
166
+ const yaml = {};
167
+ const lines = match[1].split('\n');
168
+ for (const line of lines) {
169
+ const kvMatch = line.match(/^(\w+):\s*(.*)$/);
170
+ if (kvMatch) {
171
+ yaml[kvMatch[1]] = kvMatch[2].trim();
172
+ }
173
+ }
174
+ return yaml;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Generate merge decisions
182
+ * @param {object} existing - Extracted existing content
183
+ * @param {object} newAnalysis - New analysis results
184
+ * @param {object} options - Merge options
185
+ * @returns {Array} Array of merge decisions
186
+ */
187
+ function decideMerge(existing, newAnalysis, options = {}) {
188
+ const {
189
+ preserveCustom = true,
190
+ updateRefs = false,
191
+ defaultPlaceholders = {}
192
+ } = options;
193
+
194
+ const decisions = [];
195
+
196
+ if (!existing) {
197
+ // No existing content, use all new values
198
+ return [{
199
+ type: DECISION_TYPE.UPDATE,
200
+ reason: 'No existing content'
201
+ }];
202
+ }
203
+
204
+ // 1. Placeholder decisions
205
+ for (const [name, existingValue] of Object.entries(existing.placeholders || {})) {
206
+ const newValue = newAnalysis?.values?.[name] || defaultPlaceholders[name];
207
+ const defaultValue = defaultPlaceholders[name];
208
+
209
+ // Check if value was customized (different from default)
210
+ const isCustomized = existingValue !== defaultValue && existingValue !== `{{${name}}}`;
211
+
212
+ if (isCustomized && preserveCustom) {
213
+ decisions.push({
214
+ type: DECISION_TYPE.PRESERVE,
215
+ placeholder: name,
216
+ value: existingValue,
217
+ reason: 'User customized'
218
+ });
219
+ } else if (newValue && newValue !== existingValue) {
220
+ decisions.push({
221
+ type: DECISION_TYPE.UPDATE,
222
+ placeholder: name,
223
+ oldValue: existingValue,
224
+ newValue,
225
+ reason: 'Updated from analysis'
226
+ });
227
+ }
228
+ }
229
+
230
+ // 2. Custom section decisions
231
+ for (const section of existing.customSections || []) {
232
+ decisions.push({
233
+ type: DECISION_TYPE.PRESERVE_SECTION,
234
+ heading: section.heading,
235
+ content: section.content,
236
+ position: section.startLine,
237
+ reason: 'Custom section not in template'
238
+ });
239
+ }
240
+
241
+ // 3. Line reference decisions
242
+ if (updateRefs) {
243
+ for (const ref of existing.lineReferences || []) {
244
+ const fullPath = path.join(process.cwd(), ref.file);
245
+
246
+ if (!fs.existsSync(fullPath)) {
247
+ decisions.push({
248
+ type: DECISION_TYPE.REMOVE_STALE_REF,
249
+ reference: ref.original,
250
+ reason: 'File no longer exists'
251
+ });
252
+ } else {
253
+ try {
254
+ const fileContent = fs.readFileSync(fullPath, 'utf-8');
255
+ const lineCount = fileContent.split('\n').length;
256
+
257
+ if (ref.line > lineCount) {
258
+ decisions.push({
259
+ type: DECISION_TYPE.UPDATE_REF,
260
+ oldRef: ref.original,
261
+ newRef: `${ref.file}:${lineCount}`,
262
+ reason: `Line ${ref.line} exceeds file length (${lineCount} lines)`
263
+ });
264
+ }
265
+ } catch {
266
+ // Can't read file, skip
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ // 4. New workflow decisions
273
+ for (const workflow of newAnalysis?.workflows || []) {
274
+ const existsInDocs = existing.sections?.some(
275
+ s => s.heading.toLowerCase().includes(workflow.name?.toLowerCase() || '')
276
+ );
277
+
278
+ if (!existsInDocs) {
279
+ decisions.push({
280
+ type: DECISION_TYPE.ADD_WORKFLOW,
281
+ workflow,
282
+ reason: 'Newly discovered workflow'
283
+ });
284
+ }
285
+ }
286
+
287
+ return decisions;
288
+ }
289
+
290
+ /**
291
+ * Generate merged content from decisions
292
+ * @param {string} templateContent - Template content
293
+ * @param {Array} decisions - Merge decisions
294
+ * @param {object} existing - Existing content
295
+ * @returns {string} Merged content
296
+ */
297
+ function generateMergedContent(templateContent, decisions, existing) {
298
+ let content = templateContent;
299
+
300
+ // Apply decisions
301
+ for (const decision of decisions) {
302
+ switch (decision.type) {
303
+ case DECISION_TYPE.PRESERVE:
304
+ case DECISION_TYPE.UPDATE:
305
+ // Replace placeholder with value
306
+ if (decision.placeholder && (decision.value || decision.newValue)) {
307
+ const value = decision.value || decision.newValue;
308
+ const placeholder = `{{${decision.placeholder}}}`;
309
+ content = content.replace(new RegExp(escapeRegex(placeholder), 'g'), value);
310
+ }
311
+ break;
312
+
313
+ case DECISION_TYPE.PRESERVE_SECTION:
314
+ // Insert custom section at appropriate position
315
+ if (decision.heading && decision.content) {
316
+ const level = '#'.repeat(decision.level || 2);
317
+ const sectionContent = `\n${level} ${decision.heading}\n\n${decision.content}\n`;
318
+ // Append before the last section or at end
319
+ const lastHeadingMatch = content.match(/\n(#{1,6}\s+[^\n]+)\n[^#]*$/);
320
+ if (lastHeadingMatch) {
321
+ content = content.replace(lastHeadingMatch[0], sectionContent + lastHeadingMatch[0]);
322
+ } else {
323
+ content += sectionContent;
324
+ }
325
+ }
326
+ break;
327
+
328
+ case DECISION_TYPE.UPDATE_REF:
329
+ // Update line reference
330
+ if (decision.oldRef && decision.newRef) {
331
+ content = content.replace(
332
+ new RegExp(escapeRegex(decision.oldRef), 'g'),
333
+ decision.newRef
334
+ );
335
+ }
336
+ break;
337
+
338
+ case DECISION_TYPE.REMOVE_STALE_REF:
339
+ // Comment out or remove stale reference
340
+ if (decision.reference) {
341
+ content = content.replace(
342
+ new RegExp(escapeRegex(decision.reference), 'g'),
343
+ `<!-- REMOVED: ${decision.reference} -->`
344
+ );
345
+ }
346
+ break;
347
+ }
348
+ }
349
+
350
+ return content;
351
+ }
352
+
353
+ /**
354
+ * Generate diff between old and new content
355
+ * @param {string} oldContent - Original content
356
+ * @param {string} newContent - New content
357
+ * @param {Array} decisions - Merge decisions
358
+ * @returns {object} Diff summary
359
+ */
360
+ function generateDiff(oldContent, newContent, decisions) {
361
+ const diff = {
362
+ summary: {
363
+ preserved: 0,
364
+ updated: 0,
365
+ added: 0,
366
+ removed: 0,
367
+ conflicts: 0
368
+ },
369
+ changes: [],
370
+ migrationNotes: []
371
+ };
372
+
373
+ for (const decision of decisions) {
374
+ switch (decision.type) {
375
+ case DECISION_TYPE.PRESERVE:
376
+ case DECISION_TYPE.PRESERVE_SECTION:
377
+ diff.summary.preserved++;
378
+ diff.changes.push({
379
+ type: 'preserve',
380
+ location: decision.placeholder || decision.heading,
381
+ reason: decision.reason
382
+ });
383
+ break;
384
+
385
+ case DECISION_TYPE.UPDATE:
386
+ case DECISION_TYPE.UPDATE_REF:
387
+ diff.summary.updated++;
388
+ diff.changes.push({
389
+ type: 'update',
390
+ location: decision.placeholder || decision.oldRef,
391
+ oldValue: decision.oldValue || decision.oldRef,
392
+ newValue: decision.newValue || decision.newRef
393
+ });
394
+ break;
395
+
396
+ case DECISION_TYPE.ADD_WORKFLOW:
397
+ case DECISION_TYPE.ADD_ENTRY_POINT:
398
+ diff.summary.added++;
399
+ diff.changes.push({
400
+ type: 'add',
401
+ description: decision.workflow?.name || decision.entryPoint?.file
402
+ });
403
+ break;
404
+
405
+ case DECISION_TYPE.REMOVE_STALE_REF:
406
+ diff.summary.removed++;
407
+ diff.migrationNotes.push({
408
+ type: 'removed',
409
+ reference: decision.reference,
410
+ reason: decision.reason
411
+ });
412
+ break;
413
+
414
+ case DECISION_TYPE.CONFLICT:
415
+ diff.summary.conflicts++;
416
+ diff.changes.push({
417
+ type: 'conflict',
418
+ location: decision.placeholder,
419
+ existing: decision.existingValue,
420
+ proposed: decision.newValue
421
+ });
422
+ break;
423
+ }
424
+ }
425
+
426
+ return diff;
427
+ }
428
+
429
+ /**
430
+ * Escape regex special characters
431
+ */
432
+ function escapeRegex(string) {
433
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
434
+ }
435
+
436
+ /**
437
+ * Smart merge a file with new analysis
438
+ * @param {string} filePath - Path to existing file
439
+ * @param {string} templatePath - Path to template
440
+ * @param {object} analysis - New analysis results
441
+ * @param {object} options - Merge options
442
+ * @returns {object} Merge result
443
+ */
444
+ async function smartMergeFile(filePath, templatePath, analysis, options = {}) {
445
+ const {
446
+ dryRun = false,
447
+ backup = false,
448
+ preserveCustom = true,
449
+ updateRefs = false
450
+ } = options;
451
+
452
+ // Check if file exists
453
+ const fileExists = fs.existsSync(filePath);
454
+ const templateExists = fs.existsSync(templatePath);
455
+
456
+ if (!templateExists) {
457
+ return {
458
+ success: false,
459
+ error: 'Template file not found'
460
+ };
461
+ }
462
+
463
+ const templateContent = fs.readFileSync(templatePath, 'utf-8');
464
+
465
+ // If file doesn't exist, just use template
466
+ if (!fileExists) {
467
+ if (!dryRun) {
468
+ fs.writeFileSync(filePath, templateContent);
469
+ }
470
+ return {
471
+ success: true,
472
+ isNew: true,
473
+ decisions: []
474
+ };
475
+ }
476
+
477
+ // Extract existing content
478
+ const existing = extractExistingContent(filePath, templatePath);
479
+
480
+ // Generate merge decisions
481
+ const decisions = decideMerge(existing, analysis, {
482
+ preserveCustom,
483
+ updateRefs,
484
+ defaultPlaceholders: options.defaultPlaceholders || {}
485
+ });
486
+
487
+ // Generate merged content
488
+ const mergedContent = generateMergedContent(templateContent, decisions, existing);
489
+
490
+ // Generate diff
491
+ const diff = generateDiff(existing.raw, mergedContent, decisions);
492
+
493
+ if (dryRun) {
494
+ return {
495
+ success: true,
496
+ dryRun: true,
497
+ decisions,
498
+ diff,
499
+ wouldWrite: mergedContent !== existing.raw
500
+ };
501
+ }
502
+
503
+ // Backup if requested
504
+ if (backup) {
505
+ const backupPath = filePath + '.backup-' + Date.now();
506
+ fs.writeFileSync(backupPath, existing.raw);
507
+ }
508
+
509
+ // Write merged content
510
+ fs.writeFileSync(filePath, mergedContent);
511
+
512
+ return {
513
+ success: true,
514
+ decisions,
515
+ diff,
516
+ preserved: diff.summary.preserved,
517
+ updated: diff.summary.updated,
518
+ added: diff.summary.added,
519
+ removed: diff.summary.removed,
520
+ migrationNotes: diff.migrationNotes
521
+ };
522
+ }
523
+
524
+ module.exports = {
525
+ // Core functions
526
+ extractExistingContent,
527
+ decideMerge,
528
+ generateMergedContent,
529
+ generateDiff,
530
+ smartMergeFile,
531
+
532
+ // Parsing functions
533
+ parseMarkdownSections,
534
+ extractPlaceholderValues,
535
+ extractLineReferences,
536
+ extractFrontmatter,
537
+
538
+ // Constants
539
+ DECISION_TYPE
540
+ };
package/lib/spinner.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Spinner utilities for create-claude-context
2
+ * Spinner utilities for create-universal-ai-context
3
3
  *
4
4
  * Provides consistent progress indicators using ora
5
5
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-universal-ai-context",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Universal AI Context Engineering - Set up context for Claude, Copilot, Cline, Antigravity, and more",
5
5
  "main": "lib/index.js",
6
6
  "bin": {