@unrdf/kgn 5.0.1
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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/package.json +90 -0
- package/src/MIGRATION_COMPLETE.md +186 -0
- package/src/PORT-MAP.md +302 -0
- package/src/base/filter-templates.js +479 -0
- package/src/base/index.js +92 -0
- package/src/base/injection-targets.js +583 -0
- package/src/base/macro-templates.js +298 -0
- package/src/base/macro-templates.js.bak +461 -0
- package/src/base/shacl-templates.js +617 -0
- package/src/base/template-base.js +388 -0
- package/src/core/attestor.js +381 -0
- package/src/core/filters.js +518 -0
- package/src/core/index.js +21 -0
- package/src/core/kgen-engine.js +372 -0
- package/src/core/parser.js +447 -0
- package/src/core/post-processor.js +313 -0
- package/src/core/renderer.js +469 -0
- package/src/doc-generator/cli.mjs +122 -0
- package/src/doc-generator/index.mjs +28 -0
- package/src/doc-generator/mdx-generator.mjs +71 -0
- package/src/doc-generator/nav-generator.mjs +136 -0
- package/src/doc-generator/parser.mjs +291 -0
- package/src/doc-generator/rdf-builder.mjs +306 -0
- package/src/doc-generator/scanner.mjs +189 -0
- package/src/engine/index.js +42 -0
- package/src/engine/pipeline.js +448 -0
- package/src/engine/renderer.js +604 -0
- package/src/engine/template-engine.js +566 -0
- package/src/filters/array.js +436 -0
- package/src/filters/data.js +479 -0
- package/src/filters/index.js +270 -0
- package/src/filters/rdf.js +264 -0
- package/src/filters/text.js +369 -0
- package/src/index.js +109 -0
- package/src/inheritance/index.js +40 -0
- package/src/injection/api.js +260 -0
- package/src/injection/atomic-writer.js +327 -0
- package/src/injection/constants.js +136 -0
- package/src/injection/idempotency-manager.js +295 -0
- package/src/injection/index.js +28 -0
- package/src/injection/injection-engine.js +378 -0
- package/src/injection/integration.js +339 -0
- package/src/injection/modes/index.js +341 -0
- package/src/injection/rollback-manager.js +373 -0
- package/src/injection/target-resolver.js +323 -0
- package/src/injection/tests/atomic-writer.test.js +382 -0
- package/src/injection/tests/injection-engine.test.js +611 -0
- package/src/injection/tests/integration.test.js +392 -0
- package/src/injection/tests/run-tests.js +283 -0
- package/src/injection/validation-engine.js +547 -0
- package/src/linter/determinism-linter.js +473 -0
- package/src/linter/determinism.js +410 -0
- package/src/linter/index.js +6 -0
- package/src/linter/test-doubles.js +475 -0
- package/src/parser/frontmatter.js +228 -0
- package/src/parser/variables.js +344 -0
- package/src/renderer/deterministic.js +245 -0
- package/src/renderer/index.js +6 -0
- package/src/templates/latex/academic-paper.njk +186 -0
- package/src/templates/latex/index.js +104 -0
- package/src/templates/nextjs/app-page.njk +66 -0
- package/src/templates/nextjs/index.js +80 -0
- package/src/templates/office/docx/document.njk +368 -0
- package/src/templates/office/index.js +79 -0
- package/src/templates/office/word-report.njk +129 -0
- package/src/utils/template-utils.js +426 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KGEN Rollback Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages rollback operations for failed injections, including
|
|
5
|
+
* backup restoration and operation undo capabilities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { promises as fs } from 'fs';
|
|
9
|
+
import { join, dirname, basename } from 'path';
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
|
|
12
|
+
import { ERROR_CODES, CHECKSUM_ALGORITHMS } from './constants.js';
|
|
13
|
+
|
|
14
|
+
export class RollbackManager {
|
|
15
|
+
constructor(config = {}) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.rollbackHistory = new Map();
|
|
18
|
+
this.operationBackups = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rollback a failed operation
|
|
23
|
+
*/
|
|
24
|
+
async rollbackOperation(operationId, operationData) {
|
|
25
|
+
const rollbackEntry = {
|
|
26
|
+
operationId,
|
|
27
|
+
timestamp: Date.now(),
|
|
28
|
+
phase: 'starting',
|
|
29
|
+
restoredFiles: [],
|
|
30
|
+
errors: []
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
rollbackEntry.phase = 'restoring-files';
|
|
35
|
+
|
|
36
|
+
// Restore files from backups
|
|
37
|
+
if (operationData.backups && operationData.backups.length > 0) {
|
|
38
|
+
for (const backup of operationData.backups) {
|
|
39
|
+
try {
|
|
40
|
+
await this._restoreFromBackup(backup.filePath, backup.backupPath);
|
|
41
|
+
rollbackEntry.restoredFiles.push({
|
|
42
|
+
file: backup.filePath,
|
|
43
|
+
backup: backup.backupPath,
|
|
44
|
+
success: true
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
rollbackEntry.errors.push({
|
|
48
|
+
file: backup.filePath,
|
|
49
|
+
error: error.message
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Clean up temporary files
|
|
56
|
+
rollbackEntry.phase = 'cleanup';
|
|
57
|
+
await this._cleanupTemporaryFiles(operationData);
|
|
58
|
+
|
|
59
|
+
// Release locks
|
|
60
|
+
await this._releaseLocks(operationData);
|
|
61
|
+
|
|
62
|
+
rollbackEntry.phase = 'completed';
|
|
63
|
+
rollbackEntry.success = rollbackEntry.errors.length === 0;
|
|
64
|
+
|
|
65
|
+
this.rollbackHistory.set(operationId, rollbackEntry);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
success: rollbackEntry.success,
|
|
69
|
+
filesRestored: rollbackEntry.restoredFiles.length,
|
|
70
|
+
errors: rollbackEntry.errors
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
} catch (error) {
|
|
74
|
+
rollbackEntry.phase = 'failed';
|
|
75
|
+
rollbackEntry.error = error.message;
|
|
76
|
+
this.rollbackHistory.set(operationId, rollbackEntry);
|
|
77
|
+
|
|
78
|
+
throw new Error(`Rollback failed: ${error.message}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Undo a completed operation
|
|
84
|
+
*/
|
|
85
|
+
async undoOperation(operationId) {
|
|
86
|
+
const operationHistory = this._getOperationHistory(operationId);
|
|
87
|
+
if (!operationHistory) {
|
|
88
|
+
throw new Error(`Operation ${operationId} not found in history`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (operationHistory.phase !== 'committed') {
|
|
92
|
+
throw new Error(`Cannot undo operation ${operationId} - not in committed state`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const undoEntry = {
|
|
96
|
+
originalOperationId: operationId,
|
|
97
|
+
undoOperationId: `undo-${operationId}`,
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
phase: 'starting',
|
|
100
|
+
restoredFiles: [],
|
|
101
|
+
errors: []
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// Find backups for this operation
|
|
106
|
+
const backups = this._findOperationBackups(operationId);
|
|
107
|
+
if (backups.length === 0) {
|
|
108
|
+
throw new Error(`No backups found for operation ${operationId}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
undoEntry.phase = 'restoring';
|
|
112
|
+
|
|
113
|
+
for (const backup of backups) {
|
|
114
|
+
try {
|
|
115
|
+
await this._restoreFromBackup(backup.targetPath, backup.backupPath);
|
|
116
|
+
undoEntry.restoredFiles.push({
|
|
117
|
+
file: backup.targetPath,
|
|
118
|
+
backup: backup.backupPath,
|
|
119
|
+
success: true
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
undoEntry.errors.push({
|
|
123
|
+
file: backup.targetPath,
|
|
124
|
+
error: error.message
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
undoEntry.phase = 'completed';
|
|
130
|
+
undoEntry.success = undoEntry.errors.length === 0;
|
|
131
|
+
|
|
132
|
+
// Mark original operation as undone
|
|
133
|
+
operationHistory.undone = true;
|
|
134
|
+
operationHistory.undoTimestamp = Date.now();
|
|
135
|
+
|
|
136
|
+
this.rollbackHistory.set(undoEntry.undoOperationId, undoEntry);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
success: undoEntry.success,
|
|
140
|
+
filesRestored: undoEntry.restoredFiles.length,
|
|
141
|
+
errors: undoEntry.errors,
|
|
142
|
+
operationId: undoEntry.undoOperationId
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
} catch (error) {
|
|
146
|
+
undoEntry.phase = 'failed';
|
|
147
|
+
undoEntry.error = error.message;
|
|
148
|
+
this.rollbackHistory.set(undoEntry.undoOperationId, undoEntry);
|
|
149
|
+
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Register backup for tracking
|
|
156
|
+
*/
|
|
157
|
+
registerBackup(operationId, targetPath, backupPath, checksum) {
|
|
158
|
+
if (!this.operationBackups.has(operationId)) {
|
|
159
|
+
this.operationBackups.set(operationId, []);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.operationBackups.get(operationId).push({
|
|
163
|
+
targetPath,
|
|
164
|
+
backupPath,
|
|
165
|
+
checksum,
|
|
166
|
+
timestamp: Date.now()
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get rollback history
|
|
172
|
+
*/
|
|
173
|
+
getRollbackHistory(operationId = null) {
|
|
174
|
+
if (operationId) {
|
|
175
|
+
return this.rollbackHistory.get(operationId);
|
|
176
|
+
}
|
|
177
|
+
return Array.from(this.rollbackHistory.values());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Clean up old backups
|
|
182
|
+
*/
|
|
183
|
+
async cleanupOldBackups(maxAge = 24 * 60 * 60 * 1000) { // 24 hours default
|
|
184
|
+
const cutoffTime = Date.now() - maxAge;
|
|
185
|
+
const toCleanup = [];
|
|
186
|
+
|
|
187
|
+
for (const [operationId, backups] of this.operationBackups.entries()) {
|
|
188
|
+
const oldBackups = backups.filter(backup => backup.timestamp < cutoffTime);
|
|
189
|
+
|
|
190
|
+
for (const backup of oldBackups) {
|
|
191
|
+
try {
|
|
192
|
+
await fs.unlink(backup.backupPath);
|
|
193
|
+
toCleanup.push(backup);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.warn(`Failed to cleanup backup ${backup.backupPath}:`, error.message);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Remove cleaned up backups from tracking
|
|
200
|
+
const remainingBackups = backups.filter(backup => backup.timestamp >= cutoffTime);
|
|
201
|
+
if (remainingBackups.length === 0) {
|
|
202
|
+
this.operationBackups.delete(operationId);
|
|
203
|
+
} else {
|
|
204
|
+
this.operationBackups.set(operationId, remainingBackups);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
cleanedBackups: toCleanup.length,
|
|
210
|
+
remainingOperations: this.operationBackups.size
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Private Methods
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
async _restoreFromBackup(targetPath, backupPath) {
|
|
219
|
+
// Verify backup exists
|
|
220
|
+
try {
|
|
221
|
+
await fs.access(backupPath);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw new Error(`Backup file not found: ${backupPath}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Verify backup integrity if checksum available
|
|
227
|
+
const backupChecksum = await this._calculateFileChecksum(backupPath);
|
|
228
|
+
|
|
229
|
+
// Create target directory if it doesn't exist
|
|
230
|
+
const targetDir = dirname(targetPath);
|
|
231
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
232
|
+
|
|
233
|
+
// Copy backup to target (atomic operation)
|
|
234
|
+
const tempPath = `${targetPath}.kgen-restore-${Date.now()}`;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
await fs.copyFile(backupPath, tempPath);
|
|
238
|
+
await fs.rename(tempPath, targetPath);
|
|
239
|
+
|
|
240
|
+
// Verify restoration
|
|
241
|
+
const restoredChecksum = await this._calculateFileChecksum(targetPath);
|
|
242
|
+
if (restoredChecksum !== backupChecksum) {
|
|
243
|
+
throw new Error('Restored file checksum does not match backup');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// Clean up temp file on failure
|
|
248
|
+
try {
|
|
249
|
+
await fs.unlink(tempPath);
|
|
250
|
+
} catch (cleanupError) {
|
|
251
|
+
console.warn('Failed to cleanup temp restore file:', tempPath);
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async _cleanupTemporaryFiles(operationData) {
|
|
258
|
+
// Clean up any temporary files created during operation
|
|
259
|
+
if (operationData.tempFiles) {
|
|
260
|
+
for (const tempFile of operationData.tempFiles) {
|
|
261
|
+
try {
|
|
262
|
+
await fs.unlink(tempFile);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
console.warn(`Failed to cleanup temp file ${tempFile}:`, error.message);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async _releaseLocks(operationData) {
|
|
271
|
+
// Release any locks held by the operation
|
|
272
|
+
if (operationData.locks) {
|
|
273
|
+
for (const lockFile of operationData.locks) {
|
|
274
|
+
try {
|
|
275
|
+
await fs.unlink(lockFile);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
console.warn(`Failed to release lock ${lockFile}:`, error.message);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_getOperationHistory(operationId) {
|
|
284
|
+
// This would normally come from the injection engine's history
|
|
285
|
+
// For now, return a mock structure
|
|
286
|
+
return {
|
|
287
|
+
id: operationId,
|
|
288
|
+
phase: 'committed',
|
|
289
|
+
results: [],
|
|
290
|
+
metadata: {}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_findOperationBackups(operationId) {
|
|
295
|
+
return this.operationBackups.get(operationId) || [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async _calculateFileChecksum(filePath, algorithm = CHECKSUM_ALGORITHMS.SHA256) {
|
|
299
|
+
const content = await fs.readFile(filePath);
|
|
300
|
+
const hash = createHash(algorithm);
|
|
301
|
+
hash.update(content);
|
|
302
|
+
return hash.digest('hex');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Verify operation can be undone
|
|
307
|
+
*/
|
|
308
|
+
async verifyUndoable(operationId) {
|
|
309
|
+
const operationHistory = this._getOperationHistory(operationId);
|
|
310
|
+
if (!operationHistory) {
|
|
311
|
+
return { undoable: false, reason: 'Operation not found' };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (operationHistory.undone) {
|
|
315
|
+
return { undoable: false, reason: 'Operation already undone' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (operationHistory.phase !== 'committed') {
|
|
319
|
+
return { undoable: false, reason: 'Operation not completed successfully' };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const backups = this._findOperationBackups(operationId);
|
|
323
|
+
if (backups.length === 0) {
|
|
324
|
+
return { undoable: false, reason: 'No backups available' };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Verify all backup files exist
|
|
328
|
+
for (const backup of backups) {
|
|
329
|
+
try {
|
|
330
|
+
await fs.access(backup.backupPath);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
return { undoable: false, reason: `Backup file missing: ${backup.backupPath}` };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { undoable: true, backupsCount: backups.length };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Create operation snapshot for recovery
|
|
341
|
+
*/
|
|
342
|
+
async createOperationSnapshot(operationId, targets) {
|
|
343
|
+
const snapshot = {
|
|
344
|
+
operationId,
|
|
345
|
+
timestamp: Date.now(),
|
|
346
|
+
targets: [],
|
|
347
|
+
checksums: new Map()
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
for (const target of targets) {
|
|
351
|
+
try {
|
|
352
|
+
const exists = await fs.access(target.resolvedPath).then(() => true, () => false);
|
|
353
|
+
const targetInfo = {
|
|
354
|
+
path: target.resolvedPath,
|
|
355
|
+
exists,
|
|
356
|
+
checksum: null,
|
|
357
|
+
stats: null
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
if (exists) {
|
|
361
|
+
targetInfo.checksum = await this._calculateFileChecksum(target.resolvedPath);
|
|
362
|
+
targetInfo.stats = await fs.stat(target.resolvedPath);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
snapshot.targets.push(targetInfo);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
console.warn(`Failed to snapshot target ${target.resolvedPath}:`, error.message);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return snapshot;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KGEN Target Resolver
|
|
3
|
+
*
|
|
4
|
+
* Resolves injection targets from template configuration with deterministic
|
|
5
|
+
* path resolution, glob pattern matching, and security validation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { promises as fs } from 'fs';
|
|
9
|
+
import { resolve, join, relative, dirname, basename } from 'path';
|
|
10
|
+
// Simple glob implementation for basic pattern matching
|
|
11
|
+
// In production, use a proper glob library like 'glob' or 'fast-glob'
|
|
12
|
+
function simpleGlob(pattern, options = {}) {
|
|
13
|
+
// Very basic glob implementation for demo purposes
|
|
14
|
+
return Promise.resolve([]);
|
|
15
|
+
}
|
|
16
|
+
const glob = simpleGlob;
|
|
17
|
+
|
|
18
|
+
import { ERROR_CODES, DEFAULT_CONFIG } from './constants.js';
|
|
19
|
+
|
|
20
|
+
export class TargetResolver {
|
|
21
|
+
constructor(config = {}) {
|
|
22
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
23
|
+
this.projectRoot = config.projectRoot || process.cwd();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve all targets from template configuration
|
|
28
|
+
*/
|
|
29
|
+
async resolveTargets(templateConfig, variables = {}) {
|
|
30
|
+
const targets = [];
|
|
31
|
+
|
|
32
|
+
// Handle single target
|
|
33
|
+
if (templateConfig.to) {
|
|
34
|
+
const target = await this._resolveSingleTarget(templateConfig, variables);
|
|
35
|
+
targets.push(target);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Handle multiple targets
|
|
39
|
+
if (templateConfig.targets && Array.isArray(templateConfig.targets)) {
|
|
40
|
+
for (const targetConfig of templateConfig.targets) {
|
|
41
|
+
const target = await this._resolveSingleTarget(targetConfig, variables);
|
|
42
|
+
targets.push(target);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (targets.length === 0) {
|
|
47
|
+
throw new Error('No injection targets specified');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Sort targets for deterministic processing
|
|
51
|
+
targets.sort((a, b) => a.resolvedPath.localeCompare(b.resolvedPath));
|
|
52
|
+
|
|
53
|
+
return targets;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Private Methods
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
async _resolveSingleTarget(targetConfig, variables) {
|
|
61
|
+
let targetPath = targetConfig.to;
|
|
62
|
+
|
|
63
|
+
if (!targetPath) {
|
|
64
|
+
throw new Error('Target path (to) is required');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Variable interpolation
|
|
68
|
+
targetPath = this._interpolateVariables(targetPath, variables);
|
|
69
|
+
|
|
70
|
+
// Handle glob patterns
|
|
71
|
+
if (this._isGlobPattern(targetPath)) {
|
|
72
|
+
return await this._resolveGlobTargets(targetPath, targetConfig, variables);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Resolve single path
|
|
76
|
+
const resolvedPath = this._resolvePath(targetPath);
|
|
77
|
+
|
|
78
|
+
// Security validation
|
|
79
|
+
this._validatePath(resolvedPath);
|
|
80
|
+
|
|
81
|
+
// Create target object
|
|
82
|
+
const target = {
|
|
83
|
+
originalPath: targetConfig.to,
|
|
84
|
+
resolvedPath,
|
|
85
|
+
mode: targetConfig.inject ? targetConfig.mode || 'append' : 'create',
|
|
86
|
+
...targetConfig
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Apply content-based filtering if configured
|
|
90
|
+
if (targetConfig.targetIf) {
|
|
91
|
+
const shouldTarget = await this._evaluateTargetCondition(target, targetConfig.targetIf);
|
|
92
|
+
if (!shouldTarget) {
|
|
93
|
+
return null; // Skip this target
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return target;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async _resolveGlobTargets(globPattern, targetConfig, variables) {
|
|
101
|
+
// Use glob to find matching files
|
|
102
|
+
const matches = await glob(globPattern, {
|
|
103
|
+
cwd: this.projectRoot,
|
|
104
|
+
absolute: true,
|
|
105
|
+
dot: false // Don't match hidden files by default
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Apply exclusion patterns
|
|
109
|
+
let filteredMatches = matches;
|
|
110
|
+
if (targetConfig.exclude) {
|
|
111
|
+
filteredMatches = this._applyExclusions(matches, targetConfig.exclude);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Sort for deterministic order
|
|
115
|
+
if (this.config.sortGlobResults) {
|
|
116
|
+
filteredMatches.sort();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Convert to target objects
|
|
120
|
+
const targets = [];
|
|
121
|
+
for (const matchPath of filteredMatches) {
|
|
122
|
+
this._validatePath(matchPath);
|
|
123
|
+
|
|
124
|
+
const target = {
|
|
125
|
+
originalPath: targetConfig.to,
|
|
126
|
+
resolvedPath: matchPath,
|
|
127
|
+
mode: targetConfig.inject ? targetConfig.mode || 'append' : 'create',
|
|
128
|
+
isGlobMatch: true,
|
|
129
|
+
...targetConfig
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Apply content-based filtering
|
|
133
|
+
if (targetConfig.targetIf) {
|
|
134
|
+
const shouldTarget = await this._evaluateTargetCondition(target, targetConfig.targetIf);
|
|
135
|
+
if (shouldTarget) {
|
|
136
|
+
targets.push(target);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
targets.push(target);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return targets;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_interpolateVariables(template, variables) {
|
|
147
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
|
|
148
|
+
const value = variables[variable];
|
|
149
|
+
if (value === undefined) {
|
|
150
|
+
throw new Error(`Variable '${variable}' not provided for path interpolation`);
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_isGlobPattern(path) {
|
|
157
|
+
return /[*?{}\[\]]/.test(path);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_resolvePath(targetPath) {
|
|
161
|
+
// Convert to absolute path
|
|
162
|
+
if (!targetPath.startsWith('/')) {
|
|
163
|
+
return resolve(this.projectRoot, targetPath);
|
|
164
|
+
}
|
|
165
|
+
return resolve(targetPath);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_validatePath(resolvedPath) {
|
|
169
|
+
// Security: Prevent path traversal
|
|
170
|
+
if (this.config.preventPathTraversal) {
|
|
171
|
+
const relativePath = relative(this.projectRoot, resolvedPath);
|
|
172
|
+
if (relativePath.startsWith('..') || resolve(relativePath) === resolve('.')) {
|
|
173
|
+
throw new Error(`Path traversal blocked: ${resolvedPath}`, ERROR_CODES.PATH_TRAVERSAL);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Validate file extension if configured
|
|
178
|
+
if (this.config.allowedExtensions && this.config.allowedExtensions.length > 0) {
|
|
179
|
+
const ext = this._getFileExtension(resolvedPath);
|
|
180
|
+
if (!this.config.allowedExtensions.includes(ext)) {
|
|
181
|
+
throw new Error(`File extension not allowed: ${ext}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_applyExclusions(matches, exclusions) {
|
|
187
|
+
const exclusionPatterns = Array.isArray(exclusions) ? exclusions : [exclusions];
|
|
188
|
+
|
|
189
|
+
return matches.filter(match => {
|
|
190
|
+
const relativePath = relative(this.projectRoot, match);
|
|
191
|
+
|
|
192
|
+
return !exclusionPatterns.some(pattern => {
|
|
193
|
+
if (this._isGlobPattern(pattern)) {
|
|
194
|
+
// Use minimatch for glob pattern exclusions
|
|
195
|
+
return this._matchesGlob(relativePath, pattern);
|
|
196
|
+
} else {
|
|
197
|
+
// Simple string includes
|
|
198
|
+
return relativePath.includes(pattern);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
_matchesGlob(path, pattern) {
|
|
205
|
+
// Simple glob matching - in production, use minimatch or similar
|
|
206
|
+
const regex = pattern
|
|
207
|
+
.replace(/\*\*/g, '.*')
|
|
208
|
+
.replace(/\*/g, '[^/]*')
|
|
209
|
+
.replace(/\?/g, '[^/]');
|
|
210
|
+
|
|
211
|
+
return new RegExp(`^${regex}$`).test(path);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async _evaluateTargetCondition(target, condition) {
|
|
215
|
+
try {
|
|
216
|
+
// Check if file exists first
|
|
217
|
+
const fileExists = await this._fileExists(target.resolvedPath);
|
|
218
|
+
if (!fileExists) {
|
|
219
|
+
return false; // Can't evaluate content conditions on non-existent files
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Read file content
|
|
223
|
+
const content = await fs.readFile(target.resolvedPath, 'utf8');
|
|
224
|
+
|
|
225
|
+
// Handle different condition types
|
|
226
|
+
if (typeof condition === 'string') {
|
|
227
|
+
// Simple string or regex match
|
|
228
|
+
if (condition.startsWith('/') && condition.endsWith('/')) {
|
|
229
|
+
// Regex pattern
|
|
230
|
+
const pattern = condition.slice(1, -1);
|
|
231
|
+
const regex = new RegExp(pattern, target.regexFlags || 'gm');
|
|
232
|
+
return regex.test(content);
|
|
233
|
+
} else {
|
|
234
|
+
// Simple string search
|
|
235
|
+
return content.includes(condition);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (typeof condition === 'function') {
|
|
240
|
+
// Custom function
|
|
241
|
+
return await condition(target, content);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (typeof condition === 'object') {
|
|
245
|
+
// Complex condition object
|
|
246
|
+
return await this._evaluateComplexCondition(target, content, condition);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return true;
|
|
250
|
+
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.warn(`Failed to evaluate target condition for ${target.resolvedPath}:`, error.message);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async _evaluateComplexCondition(target, content, condition) {
|
|
258
|
+
const { pattern, size, lines, encoding } = condition;
|
|
259
|
+
|
|
260
|
+
if (pattern) {
|
|
261
|
+
if (typeof pattern === 'string') {
|
|
262
|
+
return content.includes(pattern);
|
|
263
|
+
}
|
|
264
|
+
if (pattern instanceof RegExp) {
|
|
265
|
+
return pattern.test(content);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (size) {
|
|
270
|
+
const stats = await fs.stat(target.resolvedPath);
|
|
271
|
+
if (size.min !== undefined && stats.size < size.min) return false;
|
|
272
|
+
if (size.max !== undefined && stats.size > size.max) return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (lines) {
|
|
276
|
+
const lineCount = content.split('\n').length;
|
|
277
|
+
if (lines.min !== undefined && lineCount < lines.min) return false;
|
|
278
|
+
if (lines.max !== undefined && lineCount > lines.max) return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (encoding) {
|
|
282
|
+
// Check if file appears to be in expected encoding
|
|
283
|
+
const detectedEncoding = this._detectEncoding(content);
|
|
284
|
+
if (detectedEncoding !== encoding) return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async _fileExists(filePath) {
|
|
291
|
+
try {
|
|
292
|
+
await fs.access(filePath);
|
|
293
|
+
return true;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
_getFileExtension(filePath) {
|
|
300
|
+
const name = basename(filePath);
|
|
301
|
+
const dotIndex = name.lastIndexOf('.');
|
|
302
|
+
return dotIndex === -1 ? '' : name.slice(dotIndex);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_detectEncoding(content) {
|
|
306
|
+
// Simple encoding detection - in production use a proper library
|
|
307
|
+
try {
|
|
308
|
+
// Check for UTF-8 BOM
|
|
309
|
+
if (content.startsWith('\uFEFF')) {
|
|
310
|
+
return 'utf8-bom';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Check for non-ASCII characters
|
|
314
|
+
if (/[^\x00-\x7F]/.test(content)) {
|
|
315
|
+
return 'utf8';
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return 'ascii';
|
|
319
|
+
} catch {
|
|
320
|
+
return 'unknown';
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|