@craftpipe/contextpack 1.0.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.
package/lib/scanner.js ADDED
@@ -0,0 +1,480 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Default patterns to ignore when scanning
8
+ */
9
+ const DEFAULT_IGNORE = [
10
+ 'node_modules/**',
11
+ '.git/**',
12
+ 'dist/**',
13
+ 'build/**',
14
+ 'coverage/**',
15
+ '.nyc_output/**',
16
+ '**/*.min.js',
17
+ '**/*.map',
18
+ '.DS_Store',
19
+ 'Thumbs.db',
20
+ ];
21
+
22
+ /**
23
+ * Recursively walk a directory and collect file paths
24
+ * @param {string} dir - Directory to walk
25
+ * @param {string} rootDir - Root directory for relative path calculation
26
+ * @param {string[]} ignorePatterns - Glob-style patterns to ignore
27
+ * @returns {string[]} Array of absolute file paths
28
+ */
29
+ function walkDir(dir, rootDir, ignorePatterns) {
30
+ const results = [];
31
+
32
+ let entries;
33
+ try {
34
+ entries = fs.readdirSync(dir, { withFileTypes: true });
35
+ } catch (err) {
36
+ return results;
37
+ }
38
+
39
+ for (const entry of entries) {
40
+ const fullPath = path.join(dir, entry.name);
41
+ const relPath = path.relative(rootDir, fullPath).replace(/\\/g, '/');
42
+
43
+ if (shouldIgnore(relPath, entry.name, ignorePatterns)) {
44
+ continue;
45
+ }
46
+
47
+ if (entry.isDirectory()) {
48
+ const nested = walkDir(fullPath, rootDir, ignorePatterns);
49
+ results.push(...nested);
50
+ } else if (entry.isFile()) {
51
+ results.push(fullPath);
52
+ }
53
+ }
54
+
55
+ return results;
56
+ }
57
+
58
+ /**
59
+ * Check if a path matches any ignore pattern
60
+ * @param {string} relPath - Relative path to check
61
+ * @param {string} name - File/directory name
62
+ * @param {string[]} patterns - Patterns to check against
63
+ * @returns {boolean}
64
+ */
65
+ function shouldIgnore(relPath, name, patterns) {
66
+ if (!patterns || !Array.isArray(patterns)) return false;
67
+
68
+ for (const pattern of patterns) {
69
+ if (matchGlob(pattern, relPath, name)) {
70
+ return true;
71
+ }
72
+ }
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * Simple glob pattern matcher supporting * and ** wildcards
78
+ * @param {string} pattern - Glob pattern
79
+ * @param {string} relPath - Relative path to test
80
+ * @param {string} name - File/directory name
81
+ * @returns {boolean}
82
+ */
83
+ function matchGlob(pattern, relPath, name) {
84
+ if (!pattern || typeof pattern !== 'string') return false;
85
+
86
+ // Normalize separators
87
+ const normalizedPattern = pattern.replace(/\\/g, '/');
88
+ const normalizedPath = relPath.replace(/\\/g, '/');
89
+
90
+ // Convert glob pattern to regex
91
+ const regexStr = normalizedPattern
92
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape special regex chars except * and ?
93
+ .replace(/\*\*\//g, '(?:.+/)?') // **/ matches zero or more directories
94
+ .replace(/\*\*/g, '.*') // ** matches anything
95
+ .replace(/\*/g, '[^/]*') // * matches anything except /
96
+ .replace(/\?/g, '[^/]'); // ? matches single char except /
97
+
98
+ try {
99
+ const regex = new RegExp(`^${regexStr}$`);
100
+ if (regex.test(normalizedPath)) return true;
101
+
102
+ // Also test just the filename for simple patterns without /
103
+ if (!normalizedPattern.includes('/') && regex.test(name)) return true;
104
+
105
+ // Test if path starts with pattern (for directory patterns like node_modules/**)
106
+ const prefixRegex = new RegExp(`(^|/)${regexStr}(/|$)`);
107
+ if (prefixRegex.test(normalizedPath)) return true;
108
+ } catch (e) {
109
+ // If regex fails, fall back to simple string comparison
110
+ return normalizedPath === normalizedPattern || name === normalizedPattern;
111
+ }
112
+
113
+ return false;
114
+ }
115
+
116
+ /**
117
+ * Get file metadata for a given path
118
+ * @param {string} filePath - Absolute path to file
119
+ * @param {string} rootDir - Root directory for relative path
120
+ * @returns {object|null} File metadata object or null on error
121
+ */
122
+ function getFileMetadata(filePath, rootDir) {
123
+ try {
124
+ const stats = fs.statSync(filePath);
125
+ const relPath = path.relative(rootDir, filePath).replace(/\\/g, '/');
126
+ const ext = path.extname(filePath).toLowerCase();
127
+ const name = path.basename(filePath);
128
+
129
+ return {
130
+ path: relPath,
131
+ absolutePath: filePath,
132
+ name,
133
+ ext,
134
+ size: stats.size,
135
+ sizeKB: Math.round((stats.size / 1024) * 100) / 100,
136
+ modified: stats.mtime.toISOString(),
137
+ created: stats.birthtime.toISOString(),
138
+ type: getFileType(ext),
139
+ directory: path.dirname(relPath).replace(/\\/g, '/'),
140
+ };
141
+ } catch (err) {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Determine file type category from extension
148
+ * @param {string} ext - File extension including dot
149
+ * @returns {string} File type category
150
+ */
151
+ function getFileType(ext) {
152
+ const typeMap = {
153
+ '.js': 'javascript',
154
+ '.mjs': 'javascript',
155
+ '.cjs': 'javascript',
156
+ '.jsx': 'javascript',
157
+ '.ts': 'typescript',
158
+ '.tsx': 'typescript',
159
+ '.json': 'json',
160
+ '.md': 'markdown',
161
+ '.mdx': 'markdown',
162
+ '.html': 'html',
163
+ '.htm': 'html',
164
+ '.css': 'css',
165
+ '.scss': 'css',
166
+ '.sass': 'css',
167
+ '.less': 'css',
168
+ '.py': 'python',
169
+ '.rb': 'ruby',
170
+ '.go': 'go',
171
+ '.rs': 'rust',
172
+ '.java': 'java',
173
+ '.c': 'c',
174
+ '.cpp': 'cpp',
175
+ '.h': 'c',
176
+ '.hpp': 'cpp',
177
+ '.sh': 'shell',
178
+ '.bash': 'shell',
179
+ '.zsh': 'shell',
180
+ '.yaml': 'yaml',
181
+ '.yml': 'yaml',
182
+ '.toml': 'toml',
183
+ '.xml': 'xml',
184
+ '.svg': 'svg',
185
+ '.txt': 'text',
186
+ '.env': 'env',
187
+ '.gitignore': 'config',
188
+ '.eslintrc': 'config',
189
+ '.prettierrc': 'config',
190
+ };
191
+
192
+ return typeMap[ext] || 'other';
193
+ }
194
+
195
+ /**
196
+ * Scans a project directory and returns a raw file inventory with paths and metadata.
197
+ * @param {string} projectDir - Root directory to scan
198
+ * @param {object} options - Scan options
199
+ * @param {string[]} [options.ignore] - Additional patterns to ignore
200
+ * @param {string[]} [options.extensions] - Only include files with these extensions
201
+ * @param {boolean} [options.includeHidden] - Whether to include hidden files (default: false)
202
+ * @returns {object} File inventory with files array and summary stats
203
+ */
204
+ function scanProject(projectDir, options) {
205
+ const opts = options || {};
206
+ const rootDir = projectDir || process.cwd();
207
+
208
+ // Validate directory exists
209
+ try {
210
+ const stat = fs.statSync(rootDir);
211
+ if (!stat.isDirectory()) {
212
+ return {
213
+ success: false,
214
+ error: `Path is not a directory: ${rootDir}`,
215
+ files: [],
216
+ summary: { total: 0, totalSize: 0, byType: {}, byExtension: {} },
217
+ };
218
+ }
219
+ } catch (err) {
220
+ return {
221
+ success: false,
222
+ error: `Cannot access directory: ${rootDir} — ${err.message}`,
223
+ files: [],
224
+ summary: { total: 0, totalSize: 0, byType: {}, byExtension: {} },
225
+ };
226
+ }
227
+
228
+ const ignorePatterns = [...DEFAULT_IGNORE, ...(opts.ignore || [])];
229
+
230
+ // Optionally exclude hidden files/dirs
231
+ if (!opts.includeHidden) {
232
+ ignorePatterns.push('.*', '.*/**');
233
+ }
234
+
235
+ // Walk the directory
236
+ const absolutePaths = walkDir(rootDir, rootDir, ignorePatterns);
237
+
238
+ // Gather metadata for each file
239
+ let files = [];
240
+ for (const absPath of absolutePaths) {
241
+ const meta = getFileMetadata(absPath, rootDir);
242
+ if (meta) {
243
+ files.push(meta);
244
+ }
245
+ }
246
+
247
+ // Filter by extension if specified
248
+ if (opts.extensions && Array.isArray(opts.extensions) && opts.extensions.length > 0) {
249
+ const exts = opts.extensions.map(e => (e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`));
250
+ files = files.filter(f => exts.includes(f.ext));
251
+ }
252
+
253
+ // Build summary stats
254
+ const summary = buildSummary(files);
255
+
256
+ return {
257
+ success: true,
258
+ rootDir,
259
+ files,
260
+ summary,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Build summary statistics from file list
266
+ * @param {object[]} files - Array of file metadata objects
267
+ * @returns {object} Summary statistics
268
+ */
269
+ function buildSummary(files) {
270
+ const byType = {};
271
+ const byExtension = {};
272
+ let totalSize = 0;
273
+
274
+ for (const file of files) {
275
+ totalSize += file.size || 0;
276
+
277
+ byType[file.type] = (byType[file.type] || 0) + 1;
278
+
279
+ const ext = file.ext || '(none)';
280
+ byExtension[ext] = (byExtension[ext] || 0) + 1;
281
+ }
282
+
283
+ return {
284
+ total: files.length,
285
+ totalSize,
286
+ totalSizeKB: Math.round((totalSize / 1024) * 100) / 100,
287
+ byType,
288
+ byExtension,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Builds a hierarchical file tree structure from a flat list of file metadata.
294
+ * @param {object[]} files - Array of file metadata objects (from scanProject)
295
+ * @param {object} options - Options
296
+ * @param {boolean} [options.includeMetadata] - Include full metadata on leaf nodes (default: true)
297
+ * @returns {object} Nested tree structure representing the file hierarchy
298
+ */
299
+ function buildFileTree(files, options) {
300
+ const opts = options || {};
301
+ const includeMetadata = opts.includeMetadata !== false;
302
+
303
+ if (!files || !Array.isArray(files)) {
304
+ return { name: '.', type: 'directory', children: {} };
305
+ }
306
+
307
+ const tree = {
308
+ name: '.',
309
+ type: 'directory',
310
+ children: {},
311
+ };
312
+
313
+ for (const file of files) {
314
+ if (!file || !file.path) continue;
315
+
316
+ const parts = file.path.replace(/\\/g, '/').split('/').filter(Boolean);
317
+ let node = tree;
318
+
319
+ // Traverse/create directory nodes
320
+ for (let i = 0; i < parts.length - 1; i++) {
321
+ const part = parts[i];
322
+ if (!node.children[part]) {
323
+ node.children[part] = {
324
+ name: part,
325
+ type: 'directory',
326
+ path: parts.slice(0, i + 1).join('/'),
327
+ children: {},
328
+ };
329
+ }
330
+ node = node.children[part];
331
+ }
332
+
333
+ // Add the file leaf node
334
+ const fileName = parts[parts.length - 1];
335
+ const leafNode = {
336
+ name: fileName,
337
+ type: 'file',
338
+ path: file.path,
339
+ };
340
+
341
+ if (includeMetadata) {
342
+ leafNode.size = file.size;
343
+ leafNode.sizeKB = file.sizeKB;
344
+ leafNode.ext = file.ext;
345
+ leafNode.fileType = file.type;
346
+ leafNode.modified = file.modified;
347
+ }
348
+
349
+ node.children[fileName] = leafNode;
350
+ }
351
+
352
+ return tree;
353
+ }
354
+
355
+ /**
356
+ * Filters a file inventory by type, extension, directory, or custom predicate.
357
+ * @param {object[]|object} filesOrInventory - Array of file metadata objects, or full scanProject result
358
+ * @param {object} filterOptions - Filter criteria
359
+ * @param {string|string[]} [filterOptions.types] - File type(s) to include (e.g. 'javascript', 'json')
360
+ * @param {string|string[]} [filterOptions.extensions] - Extension(s) to include (e.g. '.js', '.ts')
361
+ * @param {string|string[]} [filterOptions.directories] - Only include files under these directories
362
+ * @param {string|string[]} [filterOptions.excludeDirectories] - Exclude files under these directories
363
+ * @param {string|string[]} [filterOptions.excludeTypes] - File type(s) to exclude
364
+ * @param {string|string[]} [filterOptions.excludeExtensions] - Extension(s) to exclude
365
+ * @param {number} [filterOptions.maxSize] - Maximum file size in bytes
366
+ * @param {number} [filterOptions.minSize] - Minimum file size in bytes
367
+ * @param {Function} [filterOptions.predicate] - Custom filter function (file) => boolean
368
+ * @returns {object[]} Filtered array of file metadata objects
369
+ */
370
+ function filterFiles(filesOrInventory, filterOptions) {
371
+ const opts = filterOptions || {};
372
+
373
+ // Accept either a raw array or a scanProject result object
374
+ let files;
375
+ if (Array.isArray(filesOrInventory)) {
376
+ files = filesOrInventory;
377
+ } else if (filesOrInventory && Array.isArray(filesOrInventory.files)) {
378
+ files = filesOrInventory.files;
379
+ } else {
380
+ return [];
381
+ }
382
+
383
+ // Normalize filter values to arrays
384
+ const types = normalizeToArray(opts.types);
385
+ const extensions = normalizeToArray(opts.extensions).map(e =>
386
+ e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
387
+ );
388
+ const directories = normalizeToArray(opts.directories).map(d => d.replace(/\\/g, '/'));
389
+ const excludeDirectories = normalizeToArray(opts.excludeDirectories).map(d => d.replace(/\\/g, '/'));
390
+ const excludeTypes = normalizeToArray(opts.excludeTypes);
391
+ const excludeExtensions = normalizeToArray(opts.excludeExtensions).map(e =>
392
+ e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
393
+ );
394
+
395
+ return files.filter(file => {
396
+ if (!file) return false;
397
+
398
+ // Include by type
399
+ if (types.length > 0 && !types.includes(file.type)) {
400
+ return false;
401
+ }
402
+
403
+ // Include by extension
404
+ if (extensions.length > 0 && !extensions.includes(file.ext)) {
405
+ return false;
406
+ }
407
+
408
+ // Include by directory (file must be under one of these dirs)
409
+ if (directories.length > 0) {
410
+ const fileDir = (file.directory || '').replace(/\\/g, '/');
411
+ const filePath = (file.path || '').replace(/\\/g, '/');
412
+ const inDir = directories.some(dir => {
413
+ const normalDir = dir.endsWith('/') ? dir : `${dir}/`;
414
+ return fileDir === dir ||
415
+ fileDir.startsWith(normalDir) ||
416
+ filePath.startsWith(normalDir) ||
417
+ filePath === dir;
418
+ });
419
+ if (!inDir) return false;
420
+ }
421
+
422
+ // Exclude by directory
423
+ if (excludeDirectories.length > 0) {
424
+ const fileDir = (file.directory || '').replace(/\\/g, '/');
425
+ const filePath = (file.path || '').replace(/\\/g, '/');
426
+ const inExcluded = excludeDirectories.some(dir => {
427
+ const normalDir = dir.endsWith('/') ? dir : `${dir}/`;
428
+ return fileDir === dir ||
429
+ fileDir.startsWith(normalDir) ||
430
+ filePath.startsWith(normalDir);
431
+ });
432
+ if (inExcluded) return false;
433
+ }
434
+
435
+ // Exclude by type
436
+ if (excludeTypes.length > 0 && excludeTypes.includes(file.type)) {
437
+ return false;
438
+ }
439
+
440
+ // Exclude by extension
441
+ if (excludeExtensions.length > 0 && excludeExtensions.includes(file.ext)) {
442
+ return false;
443
+ }
444
+
445
+ // Filter by max size
446
+ if (opts.maxSize !== undefined && opts.maxSize !== null) {
447
+ if (file.size > opts.maxSize) return false;
448
+ }
449
+
450
+ // Filter by min size
451
+ if (opts.minSize !== undefined && opts.minSize !== null) {
452
+ if (file.size < opts.minSize) return false;
453
+ }
454
+
455
+ // Custom predicate
456
+ if (typeof opts.predicate === 'function') {
457
+ try {
458
+ if (!opts.predicate(file)) return false;
459
+ } catch (e) {
460
+ return false;
461
+ }
462
+ }
463
+
464
+ return true;
465
+ });
466
+ }
467
+
468
+ /**
469
+ * Normalize a value to an array, handling null/undefined/string/array
470
+ * @param {*} val
471
+ * @returns {string[]}
472
+ */
473
+ function normalizeToArray(val) {
474
+ if (!val) return [];
475
+ if (Array.isArray(val)) return val.filter(Boolean);
476
+ if (typeof val === 'string') return [val];
477
+ return [];
478
+ }
479
+
480
+ module.exports = { scanProject, buildFileTree, filterFiles };