@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/.contextpackrc.example.json +167 -0
- package/.env.example +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +26 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +15 -0
- package/.github/pull_request_template.md +9 -0
- package/CODE_OF_CONDUCT.md +40 -0
- package/CONTRIBUTING.md +59 -0
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/SECURITY.md +21 -0
- package/index.js +428 -0
- package/lib/analyzer.js +547 -0
- package/lib/bundler.js +477 -0
- package/lib/config.js +269 -0
- package/lib/license.js +180 -0
- package/lib/premium/config-file.js +917 -0
- package/lib/premium/gate.js +13 -0
- package/lib/premium/html-report.js +1094 -0
- package/lib/premium/index.js +57 -0
- package/lib/premium/watch-mode.js +627 -0
- package/lib/scanner.js +480 -0
- package/lib/tokenizer.js +291 -0
- package/lib/validator.js +561 -0
- package/package.json +12 -0
- package/tests/analyzer.test.mjs +128 -0
- package/tests/bundler.test.mjs +126 -0
- package/tests/config.test.mjs +103 -0
- package/tests/gate.test.mjs +118 -0
- package/tests/index.test.mjs +103 -0
- package/tests/license.test.mjs +97 -0
- package/tests/scanner.test.mjs +110 -0
- package/tests/tokenizer.test.mjs +103 -0
- package/tests/validator.test.mjs +111 -0
- package/vitest.config.mjs +13 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { requirePro } = require('../premium/gate');
|
|
6
|
+
const { loadConfig, mergeWithFlags } = require('../config');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Supported config file names in order of precedence
|
|
10
|
+
*/
|
|
11
|
+
const CONFIG_FILE_NAMES = [
|
|
12
|
+
'.contextpackrc.json',
|
|
13
|
+
'.contextpackrc.js',
|
|
14
|
+
'.contextpackrc.cjs',
|
|
15
|
+
'contextpack.config.json',
|
|
16
|
+
'contextpack.config.js',
|
|
17
|
+
'contextpack.config.cjs',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Package.json key for embedded config
|
|
22
|
+
*/
|
|
23
|
+
const PACKAGE_JSON_KEY = 'contextpack';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deep merge two objects, with source overriding target
|
|
27
|
+
* @param {object} target
|
|
28
|
+
* @param {object} source
|
|
29
|
+
* @returns {object}
|
|
30
|
+
*/
|
|
31
|
+
function deepMerge(target, source) {
|
|
32
|
+
if (!source || typeof source !== 'object') return target;
|
|
33
|
+
if (!target || typeof target !== 'object') return source;
|
|
34
|
+
|
|
35
|
+
const result = Object.assign({}, target);
|
|
36
|
+
|
|
37
|
+
for (const key of Object.keys(source)) {
|
|
38
|
+
const srcVal = source[key];
|
|
39
|
+
const tgtVal = result[key];
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
srcVal !== null &&
|
|
43
|
+
typeof srcVal === 'object' &&
|
|
44
|
+
!Array.isArray(srcVal) &&
|
|
45
|
+
tgtVal !== null &&
|
|
46
|
+
typeof tgtVal === 'object' &&
|
|
47
|
+
!Array.isArray(tgtVal)
|
|
48
|
+
) {
|
|
49
|
+
result[key] = deepMerge(tgtVal, srcVal);
|
|
50
|
+
} else {
|
|
51
|
+
result[key] = srcVal;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate a config object for required types and known fields
|
|
60
|
+
* @param {object} config
|
|
61
|
+
* @returns {{ valid: boolean, errors: string[], warnings: string[] }}
|
|
62
|
+
*/
|
|
63
|
+
function validateConfigShape(config) {
|
|
64
|
+
const errors = [];
|
|
65
|
+
const warnings = [];
|
|
66
|
+
|
|
67
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
68
|
+
errors.push('Config must be a plain object.');
|
|
69
|
+
return { valid: false, errors, warnings };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const stringFields = ['rootDir', 'output', 'format'];
|
|
73
|
+
for (const field of stringFields) {
|
|
74
|
+
if (field in config && typeof config[field] !== 'string') {
|
|
75
|
+
errors.push(`Field "${field}" must be a string, got ${typeof config[field]}.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const numberFields = ['tokenLimit', 'maxFileSummaryLength'];
|
|
80
|
+
for (const field of numberFields) {
|
|
81
|
+
if (field in config) {
|
|
82
|
+
if (typeof config[field] !== 'number' || !isFinite(config[field])) {
|
|
83
|
+
errors.push(`Field "${field}" must be a finite number, got ${typeof config[field]}.`);
|
|
84
|
+
} else if (config[field] < 0) {
|
|
85
|
+
errors.push(`Field "${field}" must be non-negative.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const boolFields = ['verbose', 'includeDependencyMap', 'includeSymbolIndex'];
|
|
91
|
+
for (const field of boolFields) {
|
|
92
|
+
if (field in config && typeof config[field] !== 'boolean') {
|
|
93
|
+
warnings.push(`Field "${field}" should be a boolean, got ${typeof config[field]}.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const arrayFields = ['include', 'exclude'];
|
|
98
|
+
for (const field of arrayFields) {
|
|
99
|
+
if (field in config) {
|
|
100
|
+
if (!Array.isArray(config[field])) {
|
|
101
|
+
errors.push(`Field "${field}" must be an array.`);
|
|
102
|
+
} else {
|
|
103
|
+
for (let i = 0; i < config[field].length; i++) {
|
|
104
|
+
if (typeof config[field][i] !== 'string') {
|
|
105
|
+
errors.push(`Field "${field}[${i}]" must be a string.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if ('format' in config && typeof config.format === 'string') {
|
|
113
|
+
const validFormats = ['json', 'markdown', 'md'];
|
|
114
|
+
if (!validFormats.includes(config.format.toLowerCase())) {
|
|
115
|
+
warnings.push(
|
|
116
|
+
`Field "format" has unrecognized value "${config.format}". Expected one of: ${validFormats.join(', ')}.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if ('fileTypes' in config && config.fileTypes !== null && typeof config.fileTypes === 'object') {
|
|
122
|
+
const ft = config.fileTypes;
|
|
123
|
+
if ('extensions' in ft && !Array.isArray(ft.extensions)) {
|
|
124
|
+
errors.push('Field "fileTypes.extensions" must be an array.');
|
|
125
|
+
}
|
|
126
|
+
if ('excludeExtensions' in ft && !Array.isArray(ft.excludeExtensions)) {
|
|
127
|
+
errors.push('Field "fileTypes.excludeExtensions" must be an array.');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
'symbolExtraction' in config &&
|
|
133
|
+
config.symbolExtraction !== null &&
|
|
134
|
+
typeof config.symbolExtraction === 'object'
|
|
135
|
+
) {
|
|
136
|
+
const se = config.symbolExtraction;
|
|
137
|
+
const seBools = [
|
|
138
|
+
'enabled',
|
|
139
|
+
'extractFunctions',
|
|
140
|
+
'extractClasses',
|
|
141
|
+
'extractExports',
|
|
142
|
+
'extractImports',
|
|
143
|
+
'extractArrowFunctions',
|
|
144
|
+
'extractDefaultExports',
|
|
145
|
+
'excludePrivate',
|
|
146
|
+
];
|
|
147
|
+
for (const field of seBools) {
|
|
148
|
+
if (field in se && typeof se[field] !== 'boolean') {
|
|
149
|
+
warnings.push(`Field "symbolExtraction.${field}" should be a boolean.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if ('minSymbolLength' in se) {
|
|
153
|
+
if (typeof se.minSymbolLength !== 'number' || !isFinite(se.minSymbolLength)) {
|
|
154
|
+
errors.push('Field "symbolExtraction.minSymbolLength" must be a number.');
|
|
155
|
+
} else if (se.minSymbolLength < 1) {
|
|
156
|
+
errors.push('Field "symbolExtraction.minSymbolLength" must be at least 1.');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if ('languages' in se && !Array.isArray(se.languages)) {
|
|
160
|
+
errors.push('Field "symbolExtraction.languages" must be an array.');
|
|
161
|
+
}
|
|
162
|
+
if ('excludePatterns' in se && !Array.isArray(se.excludePatterns)) {
|
|
163
|
+
errors.push('Field "symbolExtraction.excludePatterns" must be an array.');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if ('directoryRules' in config) {
|
|
168
|
+
if (!Array.isArray(config.directoryRules)) {
|
|
169
|
+
errors.push('Field "directoryRules" must be an array.');
|
|
170
|
+
} else {
|
|
171
|
+
for (let i = 0; i < config.directoryRules.length; i++) {
|
|
172
|
+
const rule = config.directoryRules[i];
|
|
173
|
+
if (!rule || typeof rule !== 'object') {
|
|
174
|
+
errors.push(`Field "directoryRules[${i}]" must be an object.`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!('directory' in rule)) {
|
|
178
|
+
warnings.push(`Field "directoryRules[${i}]" is missing "directory" key.`);
|
|
179
|
+
} else if (typeof rule.directory !== 'string') {
|
|
180
|
+
errors.push(`Field "directoryRules[${i}].directory" must be a string.`);
|
|
181
|
+
}
|
|
182
|
+
if ('include' in rule && !Array.isArray(rule.include)) {
|
|
183
|
+
errors.push(`Field "directoryRules[${i}].include" must be an array.`);
|
|
184
|
+
}
|
|
185
|
+
if ('exclude' in rule && !Array.isArray(rule.exclude)) {
|
|
186
|
+
errors.push(`Field "directoryRules[${i}].exclude" must be an array.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if ('profiles' in config) {
|
|
193
|
+
if (!Array.isArray(config.profiles)) {
|
|
194
|
+
errors.push('Field "profiles" must be an array.');
|
|
195
|
+
} else {
|
|
196
|
+
for (let i = 0; i < config.profiles.length; i++) {
|
|
197
|
+
const profile = config.profiles[i];
|
|
198
|
+
if (!profile || typeof profile !== 'object') {
|
|
199
|
+
errors.push(`Field "profiles[${i}]" must be an object.`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!('name' in profile) || typeof profile.name !== 'string') {
|
|
203
|
+
errors.push(`Field "profiles[${i}].name" must be a string.`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Locate a config file by searching upward from startDir
|
|
214
|
+
* @param {string} startDir - Directory to begin search
|
|
215
|
+
* @param {number} maxDepth - Maximum number of parent directories to traverse
|
|
216
|
+
* @returns {{ filePath: string, fileName: string } | null}
|
|
217
|
+
*/
|
|
218
|
+
function findConfigFile(startDir, maxDepth) {
|
|
219
|
+
const opts = { startDir, maxDepth };
|
|
220
|
+
const { startDir: start = process.cwd(), maxDepth: depth = 5 } = opts || {};
|
|
221
|
+
|
|
222
|
+
let current = path.resolve(start);
|
|
223
|
+
let traversed = 0;
|
|
224
|
+
|
|
225
|
+
while (traversed <= depth) {
|
|
226
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
227
|
+
const candidate = path.join(current, name);
|
|
228
|
+
if (fs.existsSync(candidate)) {
|
|
229
|
+
return { filePath: candidate, fileName: name };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const pkgPath = path.join(current, 'package.json');
|
|
234
|
+
if (fs.existsSync(pkgPath)) {
|
|
235
|
+
try {
|
|
236
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
237
|
+
if (pkg && typeof pkg === 'object' && PACKAGE_JSON_KEY in pkg) {
|
|
238
|
+
return { filePath: pkgPath, fileName: 'package.json', embeddedKey: PACKAGE_JSON_KEY };
|
|
239
|
+
}
|
|
240
|
+
} catch (_) {
|
|
241
|
+
// ignore malformed package.json during discovery
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const parent = path.dirname(current);
|
|
246
|
+
if (parent === current) break;
|
|
247
|
+
current = parent;
|
|
248
|
+
traversed++;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Load raw config data from a file path
|
|
256
|
+
* @param {string} filePath - Absolute path to config file
|
|
257
|
+
* @param {string|undefined} embeddedKey - If set, read this key from the parsed object
|
|
258
|
+
* @returns {object}
|
|
259
|
+
*/
|
|
260
|
+
function loadRawConfig(filePath, embeddedKey) {
|
|
261
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
262
|
+
|
|
263
|
+
let raw;
|
|
264
|
+
|
|
265
|
+
if (ext === '.json') {
|
|
266
|
+
let content;
|
|
267
|
+
try {
|
|
268
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
269
|
+
} catch (err) {
|
|
270
|
+
throw new Error(`Cannot read config file "${filePath}": ${err.message}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Strip single-line comments before parsing (common in .contextpackrc.json)
|
|
274
|
+
const stripped = content.replace(/^\s*\/\/.*$/gm, '').replace(/,\s*([\]}])/g, '$1');
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
raw = JSON.parse(stripped);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
throw new Error(`Cannot parse JSON in "${filePath}": ${err.message}`);
|
|
280
|
+
}
|
|
281
|
+
} else if (ext === '.js' || ext === '.cjs') {
|
|
282
|
+
try {
|
|
283
|
+
// Clear require cache so edits are picked up in watch scenarios
|
|
284
|
+
delete require.cache[require.resolve(filePath)];
|
|
285
|
+
raw = require(filePath);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
throw new Error(`Cannot load JS config file "${filePath}": ${err.message}`);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
throw new Error(`Unsupported config file extension "${ext}" for file "${filePath}".`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (embeddedKey) {
|
|
294
|
+
if (!raw || typeof raw !== 'object' || !(embeddedKey in raw)) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Expected key "${embeddedKey}" in "${filePath}" but it was not found.`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
raw = raw[embeddedKey];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Strip internal comment keys before returning
|
|
303
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
304
|
+
raw = stripCommentKeys(raw);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return raw;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Recursively remove keys starting with "_comment" from a config object
|
|
312
|
+
* @param {object} obj
|
|
313
|
+
* @returns {object}
|
|
314
|
+
*/
|
|
315
|
+
function stripCommentKeys(obj) {
|
|
316
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
317
|
+
if (Array.isArray(obj)) return obj.map(stripCommentKeys);
|
|
318
|
+
|
|
319
|
+
const result = {};
|
|
320
|
+
for (const key of Object.keys(obj)) {
|
|
321
|
+
if (key.startsWith('_comment')) continue;
|
|
322
|
+
result[key] = stripCommentKeys(obj[key]);
|
|
323
|
+
}
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Resolve a named profile from a config object
|
|
329
|
+
* @param {object} config - Full config object
|
|
330
|
+
* @param {string} profileName - Name of the profile to resolve
|
|
331
|
+
* @returns {object} - Merged config with profile applied on top
|
|
332
|
+
*/
|
|
333
|
+
function resolveProfile(config, profileName) {
|
|
334
|
+
const { profiles } = config || {};
|
|
335
|
+
|
|
336
|
+
if (!Array.isArray(profiles) || profiles.length === 0) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Profile "${profileName}" requested but no "profiles" array found in config.`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const profile = profiles.find(
|
|
343
|
+
(p) => p && typeof p === 'object' && p.name === profileName
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (!profile) {
|
|
347
|
+
const available = profiles
|
|
348
|
+
.filter((p) => p && typeof p.name === 'string')
|
|
349
|
+
.map((p) => `"${p.name}"`)
|
|
350
|
+
.join(', ');
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Profile "${profileName}" not found. Available profiles: ${available || 'none'}.`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Clone profile without the name key, then deep merge onto base config
|
|
357
|
+
const { name: _name, ...profileOverrides } = profile;
|
|
358
|
+
const base = Object.assign({}, config);
|
|
359
|
+
delete base.profiles;
|
|
360
|
+
|
|
361
|
+
return deepMerge(base, profileOverrides);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Write a config file to disk
|
|
366
|
+
* @param {string} filePath - Destination path
|
|
367
|
+
* @param {object} config - Config object to write
|
|
368
|
+
* @param {{ format?: 'json'|'js', pretty?: boolean }} opts
|
|
369
|
+
*/
|
|
370
|
+
function writeConfigFile(filePath, config, opts) {
|
|
371
|
+
const { format = 'json', pretty = true } = opts || {};
|
|
372
|
+
|
|
373
|
+
let content;
|
|
374
|
+
|
|
375
|
+
if (format === 'js' || filePath.endsWith('.js') || filePath.endsWith('.cjs')) {
|
|
376
|
+
const serialized = JSON.stringify(config, null, pretty ? 2 : 0);
|
|
377
|
+
content = `'use strict';\n\nmodule.exports = ${serialized};\n`;
|
|
378
|
+
} else {
|
|
379
|
+
content = JSON.stringify(config, null, pretty ? 2 : 0);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
384
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
385
|
+
} catch (err) {
|
|
386
|
+
throw new Error(`Cannot write config file "${filePath}": ${err.message}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Generate a default config scaffold with all documented fields
|
|
392
|
+
* @param {object} overrides - Optional field overrides
|
|
393
|
+
* @returns {object}
|
|
394
|
+
*/
|
|
395
|
+
function generateDefaultConfig(overrides) {
|
|
396
|
+
const base = {
|
|
397
|
+
rootDir: '.',
|
|
398
|
+
include: ['**/*'],
|
|
399
|
+
exclude: [
|
|
400
|
+
'node_modules/**',
|
|
401
|
+
'.git/**',
|
|
402
|
+
'dist/**',
|
|
403
|
+
'build/**',
|
|
404
|
+
'coverage/**',
|
|
405
|
+
'.nyc_output/**',
|
|
406
|
+
'**/*.min.js',
|
|
407
|
+
'**/*.map',
|
|
408
|
+
'**/*.lock',
|
|
409
|
+
'**/*.log',
|
|
410
|
+
'tmp/**',
|
|
411
|
+
'.cache/**',
|
|
412
|
+
],
|
|
413
|
+
fileTypes: {
|
|
414
|
+
extensions: [
|
|
415
|
+
'.js', '.mjs', '.cjs', '.jsx',
|
|
416
|
+
'.ts', '.tsx',
|
|
417
|
+
'.json', '.md',
|
|
418
|
+
'.py', '.rb', '.go', '.java', '.cs', '.php', '.swift', '.kt',
|
|
419
|
+
],
|
|
420
|
+
excludeExtensions: [
|
|
421
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico',
|
|
422
|
+
'.woff', '.woff2', '.ttf', '.eot',
|
|
423
|
+
'.mp4', '.mp3', '.zip', '.tar', '.gz',
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
output: 'contextpack-output.json',
|
|
427
|
+
format: 'json',
|
|
428
|
+
tokenLimit: 100000,
|
|
429
|
+
maxFileSummaryLength: 500,
|
|
430
|
+
includeDependencyMap: true,
|
|
431
|
+
includeSymbolIndex: true,
|
|
432
|
+
verbose: false,
|
|
433
|
+
symbolExtraction: {
|
|
434
|
+
enabled: true,
|
|
435
|
+
extractFunctions: true,
|
|
436
|
+
extractClasses: true,
|
|
437
|
+
extractExports: true,
|
|
438
|
+
extractImports: true,
|
|
439
|
+
extractArrowFunctions: true,
|
|
440
|
+
extractDefaultExports: true,
|
|
441
|
+
languages: ['javascript', 'typescript'],
|
|
442
|
+
minSymbolLength: 2,
|
|
443
|
+
excludePrivate: false,
|
|
444
|
+
excludePatterns: ['^_', '^test', '^spec', '^mock'],
|
|
445
|
+
},
|
|
446
|
+
directoryRules: [],
|
|
447
|
+
profiles: [],
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
return deepMerge(base, overrides || {});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Diff two config objects and return a structured change report
|
|
455
|
+
* @param {object} baseConfig
|
|
456
|
+
* @param {object} newConfig
|
|
457
|
+
* @param {string} [keyPath]
|
|
458
|
+
* @returns {Array<{ path: string, type: 'added'|'removed'|'changed', from: any, to: any }>}
|
|
459
|
+
*/
|
|
460
|
+
function diffConfigs(baseConfig, newConfig, keyPath) {
|
|
461
|
+
const prefix = keyPath ? keyPath + '.' : '';
|
|
462
|
+
const changes = [];
|
|
463
|
+
|
|
464
|
+
const allKeys = new Set([
|
|
465
|
+
...Object.keys(baseConfig || {}),
|
|
466
|
+
...Object.keys(newConfig || {}),
|
|
467
|
+
]);
|
|
468
|
+
|
|
469
|
+
for (const key of allKeys) {
|
|
470
|
+
if (key.startsWith('_comment')) continue;
|
|
471
|
+
|
|
472
|
+
const fullKey = prefix + key;
|
|
473
|
+
const baseVal = (baseConfig || {})[key];
|
|
474
|
+
const newVal = (newConfig || {})[key];
|
|
475
|
+
|
|
476
|
+
if (!(key in (baseConfig || {}))) {
|
|
477
|
+
changes.push({ path: fullKey, type: 'added', from: undefined, to: newVal });
|
|
478
|
+
} else if (!(key in (newConfig || {}))) {
|
|
479
|
+
changes.push({ path: fullKey, type: 'removed', from: baseVal, to: undefined });
|
|
480
|
+
} else if (
|
|
481
|
+
baseVal !== null &&
|
|
482
|
+
typeof baseVal === 'object' &&
|
|
483
|
+
!Array.isArray(baseVal) &&
|
|
484
|
+
newVal !== null &&
|
|
485
|
+
typeof newVal === 'object' &&
|
|
486
|
+
!Array.isArray(newVal)
|
|
487
|
+
) {
|
|
488
|
+
const nested = diffConfigs(baseVal, newVal, fullKey);
|
|
489
|
+
changes.push(...nested);
|
|
490
|
+
} else if (JSON.stringify(baseVal) !== JSON.stringify(newVal)) {
|
|
491
|
+
changes.push({ path: fullKey, type: 'changed', from: baseVal, to: newVal });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return changes;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Format a diff report as a human-readable string
|
|
500
|
+
* @param {Array} changes
|
|
501
|
+
* @returns {string}
|
|
502
|
+
*/
|
|
503
|
+
function formatDiff(changes) {
|
|
504
|
+
if (!Array.isArray(changes) || changes.length === 0) {
|
|
505
|
+
return 'No differences found.';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const lines = [];
|
|
509
|
+
|
|
510
|
+
for (const change of changes) {
|
|
511
|
+
if (change.type === 'added') {
|
|
512
|
+
lines.push(` + ${change.path}: ${JSON.stringify(change.to)}`);
|
|
513
|
+
} else if (change.type === 'removed') {
|
|
514
|
+
lines.push(` - ${change.path}: ${JSON.stringify(change.from)}`);
|
|
515
|
+
} else if (change.type === 'changed') {
|
|
516
|
+
lines.push(
|
|
517
|
+
` ~ ${change.path}: ${JSON.stringify(change.from)} → ${JSON.stringify(change.to)}`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return lines.join('\n');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Merge multiple config files in order, with later files taking precedence
|
|
527
|
+
* @param {string[]} filePaths - Ordered list of config file paths
|
|
528
|
+
* @param {object} [cliFlags] - CLI flags to apply last
|
|
529
|
+
* @returns {{ config: object, sources: string[] }}
|
|
530
|
+
*/
|
|
531
|
+
function mergeConfigFiles(filePaths, cliFlags) {
|
|
532
|
+
const paths = Array.isArray(filePaths) ? filePaths : [];
|
|
533
|
+
const sources = [];
|
|
534
|
+
let merged = generateDefaultConfig();
|
|
535
|
+
|
|
536
|
+
for (const filePath of paths) {
|
|
537
|
+
if (!filePath || typeof filePath !== 'string') continue;
|
|
538
|
+
|
|
539
|
+
const resolved = path.resolve(filePath);
|
|
540
|
+
|
|
541
|
+
if (!fs.existsSync(resolved)) {
|
|
542
|
+
throw new Error(`Config file not found: "${resolved}"`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const raw = loadRawConfig(resolved, undefined);
|
|
546
|
+
merged = deepMerge(merged, raw);
|
|
547
|
+
sources.push(resolved);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (cliFlags && typeof cliFlags === 'object') {
|
|
551
|
+
merged = mergeWithFlags(merged, cliFlags);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return { config: merged, sources };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Main entry point for the config-file premium feature
|
|
559
|
+
*
|
|
560
|
+
* Supported actions:
|
|
561
|
+
* - 'find' : Locate the config file from a given directory
|
|
562
|
+
* - 'load' : Load, validate, and return the resolved config
|
|
563
|
+
* - 'validate' : Validate a config file and return a report
|
|
564
|
+
* - 'init' : Scaffold a new config file at a given path
|
|
565
|
+
* - 'profile' : Load config and apply a named profile
|
|
566
|
+
* - 'diff' : Compare two config files and report differences
|
|
567
|
+
* - 'merge' : Merge multiple config files into one resolved config
|
|
568
|
+
* - 'show' : Pretty-print the resolved config to stdout
|
|
569
|
+
*
|
|
570
|
+
* @param {object} opts
|
|
571
|
+
* @param {string} [opts.action='load']
|
|
572
|
+
* @param {string} [opts.cwd] - Working directory for file discovery
|
|
573
|
+
* @param {string} [opts.configPath] - Explicit config file path
|
|
574
|
+
* @param {string} [opts.profileName] - Profile name to activate
|
|
575
|
+
* @param {string} [opts.outputPath] - Destination for 'init' action
|
|
576
|
+
* @param {string} [opts.compareWith] - Second config path for 'diff' action
|
|
577
|
+
* @param {string[]} [opts.mergeFiles] - Ordered config paths for 'merge' action
|
|
578
|
+
* @param {object} [opts.cliFlags] - CLI flags to merge last
|
|
579
|
+
* @param {boolean} [opts.strict] - Treat validation warnings as errors
|
|
580
|
+
* @param {boolean} [opts.verbose] - Print extra diagnostic info
|
|
581
|
+
* @param {number} [opts.maxSearchDepth] - Max parent dirs to search (default 5)
|
|
582
|
+
* @returns {object} - Result object with action-specific fields
|
|
583
|
+
*/
|
|
584
|
+
function configFile(opts) {
|
|
585
|
+
requirePro('Config File Management');
|
|
586
|
+
|
|
587
|
+
const {
|
|
588
|
+
action = 'load',
|
|
589
|
+
cwd = process.cwd(),
|
|
590
|
+
configPath,
|
|
591
|
+
profileName,
|
|
592
|
+
outputPath,
|
|
593
|
+
compareWith,
|
|
594
|
+
mergeFiles,
|
|
595
|
+
cliFlags,
|
|
596
|
+
strict = false,
|
|
597
|
+
verbose = false,
|
|
598
|
+
maxSearchDepth = 5,
|
|
599
|
+
} = opts || {};
|
|
600
|
+
|
|
601
|
+
const log = verbose
|
|
602
|
+
? (...args) => console.log('[config-file]', ...args)
|
|
603
|
+
: () => {};
|
|
604
|
+
|
|
605
|
+
// ── FIND ──────────────────────────────────────────────────────────────────
|
|
606
|
+
if (action === 'find') {
|
|
607
|
+
log('Searching for config file from:', cwd);
|
|
608
|
+
|
|
609
|
+
const found = findConfigFile(cwd, maxSearchDepth);
|
|
610
|
+
|
|
611
|
+
if (!found) {
|
|
612
|
+
return {
|
|
613
|
+
action,
|
|
614
|
+
found: false,
|
|
615
|
+
filePath: null,
|
|
616
|
+
fileName: null,
|
|
617
|
+
message: 'No config file found.',
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
log('Found config file:', found.filePath);
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
action,
|
|
625
|
+
found: true,
|
|
626
|
+
filePath: found.filePath,
|
|
627
|
+
fileName: found.fileName,
|
|
628
|
+
embeddedKey: found.embeddedKey || null,
|
|
629
|
+
message: `Config file found: ${found.filePath}`,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── VALIDATE ──────────────────────────────────────────────────────────────
|
|
634
|
+
if (action === 'validate') {
|
|
635
|
+
const targetPath = configPath
|
|
636
|
+
? path.resolve(configPath)
|
|
637
|
+
: (() => {
|
|
638
|
+
const found = findConfigFile(cwd, maxSearchDepth);
|
|
639
|
+
if (!found) throw new Error('No config file found to validate.');
|
|
640
|
+
return found.filePath;
|
|
641
|
+
})();
|
|
642
|
+
|
|
643
|
+
log('Validating config file:', targetPath);
|
|
644
|
+
|
|
645
|
+
let raw;
|
|
646
|
+
try {
|
|
647
|
+
raw = loadRawConfig(targetPath, undefined);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
return {
|
|
650
|
+
action,
|
|
651
|
+
valid: false,
|
|
652
|
+
filePath: targetPath,
|
|
653
|
+
errors: [err.message],
|
|
654
|
+
warnings: [],
|
|
655
|
+
message: `Validation failed: ${err.message}`,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const report = validateConfigShape(raw);
|
|
660
|
+
|
|
661
|
+
if (strict && report.warnings.length > 0) {
|
|
662
|
+
report.errors.push(...report.warnings.map((w) => `[strict] ${w}`));
|
|
663
|
+
report.warnings = [];
|
|
664
|
+
report.valid = false;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
log('Validation result:', report.valid ? 'PASS' : 'FAIL');
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
action,
|
|
671
|
+
valid: report.valid,
|
|
672
|
+
filePath: targetPath,
|
|
673
|
+
errors: report.errors,
|
|
674
|
+
warnings: report.warnings,
|
|
675
|
+
message: report.valid
|
|
676
|
+
? `Config file is valid: ${targetPath}`
|
|
677
|
+
: `Config file has ${report.errors.length} error(s).`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── INIT ──────────────────────────────────────────────────────────────────
|
|
682
|
+
if (action === 'init') {
|
|
683
|
+
const dest = outputPath
|
|
684
|
+
? path.resolve(outputPath)
|
|
685
|
+
: path.join(path.resolve(cwd), '.contextpackrc.json');
|
|
686
|
+
|
|
687
|
+
if (fs.existsSync(dest)) {
|
|
688
|
+
throw new Error(
|
|
689
|
+
`Config file already exists at "${dest}". Delete it first or specify a different --output-path.`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
log('Scaffolding config file at:', dest);
|
|
694
|
+
|
|
695
|
+
const defaultConfig = generateDefaultConfig(cliFlags || {});
|
|
696
|
+
writeConfigFile(dest, defaultConfig, { format: 'json', pretty: true });
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
action,
|
|
700
|
+
filePath: dest,
|
|
701
|
+
config: defaultConfig,
|
|
702
|
+
message: `Config file created: ${dest}`,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ── DIFF ──────────────────────────────────────────────────────────────────
|
|
707
|
+
if (action === 'diff') {
|
|
708
|
+
if (!configPath) {
|
|
709
|
+
throw new Error('action "diff" requires opts.configPath (base config).');
|
|
710
|
+
}
|
|
711
|
+
if (!compareWith) {
|
|
712
|
+
throw new Error('action "diff" requires opts.compareWith (second config path).');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const basePath = path.resolve(configPath);
|
|
716
|
+
const comparePath = path.resolve(compareWith);
|
|
717
|
+
|
|
718
|
+
log('Diffing configs:', basePath, '↔', comparePath);
|
|
719
|
+
|
|
720
|
+
const baseRaw = loadRawConfig(basePath, undefined);
|
|
721
|
+
const compareRaw = loadRawConfig(comparePath, undefined);
|
|
722
|
+
|
|
723
|
+
const changes = diffConfigs(baseRaw, compareRaw);
|
|
724
|
+
const formatted = formatDiff(changes);
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
action,
|
|
728
|
+
baseFile: basePath,
|
|
729
|
+
compareFile: comparePath,
|
|
730
|
+
changes,
|
|
731
|
+
changeCount: changes.length,
|
|
732
|
+
formatted,
|
|
733
|
+
message:
|
|
734
|
+
changes.length === 0
|
|
735
|
+
? 'Configs are identical.'
|
|
736
|
+
: `Found ${changes.length} difference(s).`,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ── MERGE ─────────────────────────────────────────────────────────────────
|
|
741
|
+
if (action === 'merge') {
|
|
742
|
+
const files = Array.isArray(mergeFiles) && mergeFiles.length > 0
|
|
743
|
+
? mergeFiles
|
|
744
|
+
: (() => {
|
|
745
|
+
if (!configPath) {
|
|
746
|
+
throw new Error(
|
|
747
|
+
'action "merge" requires opts.mergeFiles (array) or opts.configPath.'
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
return [configPath];
|
|
751
|
+
})();
|
|
752
|
+
|
|
753
|
+
log('Merging config files:', files);
|
|
754
|
+
|
|
755
|
+
const { config: merged, sources } = mergeConfigFiles(files, cliFlags);
|
|
756
|
+
const report = validateConfigShape(merged);
|
|
757
|
+
|
|
758
|
+
if (outputPath) {
|
|
759
|
+
const dest = path.resolve(outputPath);
|
|
760
|
+
writeConfigFile(dest, merged, { format: 'json', pretty: true });
|
|
761
|
+
log('Merged config written to:', dest);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
action,
|
|
766
|
+
config: merged,
|
|
767
|
+
sources,
|
|
768
|
+
valid: report.valid,
|
|
769
|
+
errors: report.errors,
|
|
770
|
+
warnings: report.warnings,
|
|
771
|
+
outputPath: outputPath ? path.resolve(outputPath) : null,
|
|
772
|
+
message: `Merged ${sources.length} config file(s).`,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ── PROFILE ───────────────────────────────────────────────────────────────
|
|
777
|
+
if (action === 'profile') {
|
|
778
|
+
if (!profileName) {
|
|
779
|
+
throw new Error('action "profile" requires opts.profileName.');
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const targetPath = configPath
|
|
783
|
+
? path.resolve(configPath)
|
|
784
|
+
: (() => {
|
|
785
|
+
const found = findConfigFile(cwd, maxSearchDepth);
|
|
786
|
+
if (!found) throw new Error('No config file found for profile resolution.');
|
|
787
|
+
return found.filePath;
|
|
788
|
+
})();
|
|
789
|
+
|
|
790
|
+
log('Loading config for profile:', profileName, 'from:', targetPath);
|
|
791
|
+
|
|
792
|
+
const raw = loadRawConfig(targetPath, undefined);
|
|
793
|
+
const resolved = resolveProfile(raw, profileName);
|
|
794
|
+
|
|
795
|
+
if (cliFlags && typeof cliFlags === 'object') {
|
|
796
|
+
Object.assign(resolved, mergeWithFlags(resolved, cliFlags));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const report = validateConfigShape(resolved);
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
action,
|
|
803
|
+
profileName,
|
|
804
|
+
filePath: targetPath,
|
|
805
|
+
config: resolved,
|
|
806
|
+
valid: report.valid,
|
|
807
|
+
errors: report.errors,
|
|
808
|
+
warnings: report.warnings,
|
|
809
|
+
message: `Profile "${profileName}" resolved from "${targetPath}".`,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ── SHOW ──────────────────────────────────────────────────────────────────
|
|
814
|
+
if (action === 'show') {
|
|
815
|
+
const targetPath = configPath
|
|
816
|
+
? path.resolve(configPath)
|
|
817
|
+
: (() => {
|
|
818
|
+
const found = findConfigFile(cwd, maxSearchDepth);
|
|
819
|
+
if (!found) throw new Error('No config file found to show.');
|
|
820
|
+
return found.filePath;
|
|
821
|
+
})();
|
|
822
|
+
|
|
823
|
+
log('Showing config from:', targetPath);
|
|
824
|
+
|
|
825
|
+
const raw = loadRawConfig(targetPath, undefined);
|
|
826
|
+
let resolved = raw;
|
|
827
|
+
|
|
828
|
+
if (cliFlags && typeof cliFlags === 'object') {
|
|
829
|
+
resolved = mergeWithFlags(raw, cliFlags);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const report = validateConfigShape(resolved);
|
|
833
|
+
|
|
834
|
+
console.log('\n── ContextPack Config ──────────────────────────────');
|
|
835
|
+
console.log(' File:', targetPath);
|
|
836
|
+
console.log(' Valid:', report.valid ? '✓ Yes' : '✗ No');
|
|
837
|
+
if (report.errors.length > 0) {
|
|
838
|
+
console.log(' Errors:');
|
|
839
|
+
report.errors.forEach((e) => console.log(' ✗', e));
|
|
840
|
+
}
|
|
841
|
+
if (report.warnings.length > 0) {
|
|
842
|
+
console.log(' Warnings:');
|
|
843
|
+
report.warnings.forEach((w) => console.log(' ⚠', w));
|
|
844
|
+
}
|
|
845
|
+
console.log('────────────────────────────────────────────────────');
|
|
846
|
+
console.log(JSON.stringify(resolved, null, 2));
|
|
847
|
+
console.log('────────────────────────────────────────────────────\n');
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
action,
|
|
851
|
+
filePath: targetPath,
|
|
852
|
+
config: resolved,
|
|
853
|
+
valid: report.valid,
|
|
854
|
+
errors: report.errors,
|
|
855
|
+
warnings: report.warnings,
|
|
856
|
+
message: `Config shown from "${targetPath}".`,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ── LOAD (default) ────────────────────────────────────────────────────────
|
|
861
|
+
{
|
|
862
|
+
const targetPath = configPath
|
|
863
|
+
? path.resolve(configPath)
|
|
864
|
+
: (() => {
|
|
865
|
+
const found = findConfigFile(cwd, maxSearchDepth);
|
|
866
|
+
if (!found) {
|
|
867
|
+
log('No config file found; using defaults.');
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
return found.filePath;
|
|
871
|
+
})();
|
|
872
|
+
|
|
873
|
+
let raw;
|
|
874
|
+
|
|
875
|
+
if (targetPath) {
|
|
876
|
+
log('Loading config from:', targetPath);
|
|
877
|
+
raw = loadRawConfig(targetPath, undefined);
|
|
878
|
+
} else {
|
|
879
|
+
raw = generateDefaultConfig();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
let resolved = raw;
|
|
883
|
+
|
|
884
|
+
if (profileName) {
|
|
885
|
+
log('Applying profile:', profileName);
|
|
886
|
+
resolved = resolveProfile(raw, profileName);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (cliFlags && typeof cliFlags === 'object') {
|
|
890
|
+
resolved = mergeWithFlags(resolved, cliFlags);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const report = validateConfigShape(resolved);
|
|
894
|
+
|
|
895
|
+
if (strict && !report.valid) {
|
|
896
|
+
const summary = report.errors.join('; ');
|
|
897
|
+
throw new Error(`Config validation failed (strict mode): ${summary}`);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
log('Config loaded successfully. Valid:', report.valid);
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
action: 'load',
|
|
904
|
+
filePath: targetPath,
|
|
905
|
+
config: resolved,
|
|
906
|
+
valid: report.valid,
|
|
907
|
+
errors: report.errors,
|
|
908
|
+
warnings: report.warnings,
|
|
909
|
+
usingDefaults: !targetPath,
|
|
910
|
+
message: targetPath
|
|
911
|
+
? `Config loaded from "${targetPath}".`
|
|
912
|
+
: 'No config file found; using built-in defaults.',
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
module.exports = { configFile };
|