contextforge-cli-harshil 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/.env.example +37 -0
- package/README.md +170 -0
- package/bin/index.js +55 -0
- package/package.json +44 -0
- package/src/ai.js +399 -0
- package/src/analyze.js +432 -0
- package/src/commands/init.js +275 -0
- package/src/context.js +107 -0
- package/src/generate.js +211 -0
- package/src/scan.js +206 -0
- package/src/utils.js +93 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/commands/init.js
|
|
3
|
+
* Orchestrates the full contextforge init flow:
|
|
4
|
+
* 1. Scan repository
|
|
5
|
+
* 2. Analyze architecture
|
|
6
|
+
* 3. Detect AI provider (Groq / OpenAI / none)
|
|
7
|
+
* 4. Generate context (AI or structural fallback)
|
|
8
|
+
* 5. Write context.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import ora from 'ora';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { scanRepository, buildFolderTree } from '../scan.js';
|
|
14
|
+
import { analyzeRepository } from '../analyze.js';
|
|
15
|
+
import { generateStructuralContext } from '../generate.js';
|
|
16
|
+
import { writeContextFile, addTimestamp, isContextUpToDate } from '../context.js';
|
|
17
|
+
import { logger, formatDuration, sanitizeErrorMessage } from '../utils.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Main init command handler.
|
|
21
|
+
* @param {{ output: string, model?: string, ai: boolean, provider: string }} options
|
|
22
|
+
*/
|
|
23
|
+
export async function runInit(options) {
|
|
24
|
+
const { output, model: modelFlag, ai, provider: providerHint, force } = options;
|
|
25
|
+
const cwd = process.cwd();
|
|
26
|
+
const startTime = Date.now();
|
|
27
|
+
|
|
28
|
+
// ── Banner ─────────────────────────────────────────────────────────────────
|
|
29
|
+
console.log('');
|
|
30
|
+
console.log(
|
|
31
|
+
chalk.bold.cyan(' ⚡ ContextForge') +
|
|
32
|
+
chalk.dim(' — AI memory generation for repositories')
|
|
33
|
+
);
|
|
34
|
+
console.log(chalk.dim(` Scanning: ${chalk.white(cwd)}`));
|
|
35
|
+
console.log('');
|
|
36
|
+
|
|
37
|
+
// ── Resolve AI provider ────────────────────────────────────────────────────
|
|
38
|
+
let provider = null;
|
|
39
|
+
let resolvedModel = modelFlag;
|
|
40
|
+
|
|
41
|
+
if (ai) {
|
|
42
|
+
// Fix #8: validate --provider value early with a clear error message
|
|
43
|
+
const VALID_PROVIDER_HINTS = ['openai', 'groq', 'auto'];
|
|
44
|
+
if (providerHint && !VALID_PROVIDER_HINTS.includes(providerHint)) {
|
|
45
|
+
logger.error(
|
|
46
|
+
`Invalid provider "${providerHint}". Valid options: groq | openai | auto`
|
|
47
|
+
);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
detectProvider,
|
|
53
|
+
validateProviderKey,
|
|
54
|
+
resolveModel,
|
|
55
|
+
} = await import('../ai.js');
|
|
56
|
+
|
|
57
|
+
provider = detectProvider(providerHint ?? 'auto');
|
|
58
|
+
|
|
59
|
+
if (!provider) {
|
|
60
|
+
logger.warn('No API key found — falling back to structural context.');
|
|
61
|
+
logger.dim(
|
|
62
|
+
'Add GROQ_API_KEY (free at console.groq.com) or OPENAI_API_KEY to your .env file.'
|
|
63
|
+
);
|
|
64
|
+
console.log('');
|
|
65
|
+
} else {
|
|
66
|
+
const { valid, message } = validateProviderKey(provider);
|
|
67
|
+
if (!valid) {
|
|
68
|
+
logger.warn(message);
|
|
69
|
+
logger.dim('Falling back to structural context generation.');
|
|
70
|
+
console.log('');
|
|
71
|
+
provider = null;
|
|
72
|
+
} else {
|
|
73
|
+
resolvedModel = resolveModel(modelFlag, provider);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Step 1: Scan repository ────────────────────────────────────────────────
|
|
79
|
+
const spinnerScan = ora({
|
|
80
|
+
text: chalk.white('Scanning repository...'),
|
|
81
|
+
prefixText: ' ',
|
|
82
|
+
color: 'cyan',
|
|
83
|
+
stream: process.stdout,
|
|
84
|
+
}).start();
|
|
85
|
+
|
|
86
|
+
let files;
|
|
87
|
+
try {
|
|
88
|
+
files = await scanRepository(cwd);
|
|
89
|
+
spinnerScan.succeed(
|
|
90
|
+
chalk.green('Scanning repository') +
|
|
91
|
+
chalk.dim(` — ${files.length} files found`)
|
|
92
|
+
);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
spinnerScan.fail(chalk.red('Failed to scan repository'));
|
|
95
|
+
logger.error(err.message);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (files.length === 0) {
|
|
100
|
+
logger.warn('No relevant files found. Run contextforge from your project root.');
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Up-to-date check ──────────────────────────────────────────────────────
|
|
105
|
+
// Skip regeneration if context.md exists and no source files have changed.
|
|
106
|
+
// Use --force to bypass this check and always regenerate.
|
|
107
|
+
if (!force) {
|
|
108
|
+
const { upToDate, changedFile } = isContextUpToDate(output, files, cwd);
|
|
109
|
+
if (upToDate) {
|
|
110
|
+
console.log('');
|
|
111
|
+
console.log(chalk.dim(' ' + '─'.repeat(50)));
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log(
|
|
114
|
+
chalk.bold.green(' ✅ Already up to date!') +
|
|
115
|
+
chalk.dim(' No files changed since last run.')
|
|
116
|
+
);
|
|
117
|
+
console.log('');
|
|
118
|
+
console.log(
|
|
119
|
+
chalk.dim(' ') + chalk.bold.cyan(output) +
|
|
120
|
+
chalk.dim(' is current — nothing to regenerate.')
|
|
121
|
+
);
|
|
122
|
+
console.log(chalk.dim(' Run with ') + chalk.white('--force') + chalk.dim(' to regenerate anyway.'));
|
|
123
|
+
console.log('');
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
if (changedFile) {
|
|
127
|
+
logger.dim(`Change detected in: ${changedFile}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Step 2: Analyze architecture ──────────────────────────────────────────
|
|
132
|
+
const spinnerAnalyze = ora({
|
|
133
|
+
text: chalk.white('Detecting architecture...'),
|
|
134
|
+
prefixText: ' ',
|
|
135
|
+
color: 'cyan',
|
|
136
|
+
stream: process.stdout,
|
|
137
|
+
}).start();
|
|
138
|
+
|
|
139
|
+
let analysis, folderTree;
|
|
140
|
+
try {
|
|
141
|
+
analysis = analyzeRepository(files, cwd);
|
|
142
|
+
folderTree = buildFolderTree(files);
|
|
143
|
+
spinnerAnalyze.succeed(
|
|
144
|
+
chalk.green('Detecting architecture') +
|
|
145
|
+
chalk.dim(
|
|
146
|
+
` — ${analysis.stack.length > 0 ? analysis.stack.slice(0, 3).join(', ') : analysis.language}`
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
spinnerAnalyze.fail(chalk.red('Failed to analyze repository'));
|
|
151
|
+
logger.error(err.message);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Step 3: Identify important files ──────────────────────────────────────
|
|
156
|
+
const spinnerFiles = ora({
|
|
157
|
+
text: chalk.white('Finding important files...'),
|
|
158
|
+
prefixText: ' ',
|
|
159
|
+
color: 'cyan',
|
|
160
|
+
stream: process.stdout,
|
|
161
|
+
}).start();
|
|
162
|
+
|
|
163
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
164
|
+
|
|
165
|
+
spinnerFiles.succeed(
|
|
166
|
+
chalk.green('Finding important files') +
|
|
167
|
+
chalk.dim(` — ${analysis.importantFiles.length} key files identified`)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// ── Step 4: Generate context ───────────────────────────────────────────────
|
|
171
|
+
const providerLabel = provider
|
|
172
|
+
? chalk.cyan(`${provider === 'groq' ? 'Groq' : 'OpenAI'} / ${resolvedModel}`)
|
|
173
|
+
: null;
|
|
174
|
+
|
|
175
|
+
const spinnerGenerate = ora({
|
|
176
|
+
text: chalk.white(
|
|
177
|
+
provider
|
|
178
|
+
? `Generating AI context with ${providerLabel}...`
|
|
179
|
+
: 'Generating structural context...'
|
|
180
|
+
),
|
|
181
|
+
prefixText: ' ',
|
|
182
|
+
color: 'cyan',
|
|
183
|
+
stream: process.stdout,
|
|
184
|
+
}).start();
|
|
185
|
+
|
|
186
|
+
let contextContent;
|
|
187
|
+
|
|
188
|
+
if (provider) {
|
|
189
|
+
try {
|
|
190
|
+
const { createClient, buildPrompt, generateContextWithAI } = await import('../ai.js');
|
|
191
|
+
const client = createClient(provider);
|
|
192
|
+
const prompt = buildPrompt(analysis, folderTree);
|
|
193
|
+
|
|
194
|
+
contextContent = await generateContextWithAI(client, prompt, resolvedModel, provider);
|
|
195
|
+
|
|
196
|
+
// Strip wrapping markdown fence if model added one
|
|
197
|
+
if (contextContent.startsWith('```markdown')) {
|
|
198
|
+
contextContent = contextContent.replace(/^```markdown\n/, '').replace(/\n```$/, '');
|
|
199
|
+
} else if (contextContent.startsWith('```')) {
|
|
200
|
+
contextContent = contextContent.replace(/^```\n/, '').replace(/\n```$/, '');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
spinnerGenerate.succeed(
|
|
204
|
+
chalk.green('Generating AI context') +
|
|
205
|
+
chalk.dim(` — via ${provider === 'groq' ? 'Groq' : 'OpenAI'} (${resolvedModel})`)
|
|
206
|
+
);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
spinnerGenerate.warn(
|
|
209
|
+
chalk.yellow('AI generation failed') +
|
|
210
|
+
chalk.dim(' — falling back to structural context')
|
|
211
|
+
);
|
|
212
|
+
logger.dim(`Reason: ${sanitizeErrorMessage(err.message)}`);
|
|
213
|
+
contextContent = generateStructuralContext(analysis, folderTree);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
contextContent = generateStructuralContext(analysis, folderTree);
|
|
217
|
+
spinnerGenerate.succeed(chalk.green('Generating structural context'));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Step 5: Write context.md ───────────────────────────────────────────────
|
|
221
|
+
const spinnerWrite = ora({
|
|
222
|
+
text: chalk.white(`Writing ${output}...`),
|
|
223
|
+
prefixText: ' ',
|
|
224
|
+
color: 'cyan',
|
|
225
|
+
stream: process.stdout,
|
|
226
|
+
}).start();
|
|
227
|
+
|
|
228
|
+
let absoluteOutputPath;
|
|
229
|
+
try {
|
|
230
|
+
const finalContent = addTimestamp(contextContent);
|
|
231
|
+
absoluteOutputPath = writeContextFile(finalContent, output, cwd);
|
|
232
|
+
spinnerWrite.succeed(chalk.green(`${output} created successfully`));
|
|
233
|
+
} catch (err) {
|
|
234
|
+
spinnerWrite.fail(chalk.red(`Failed to write ${output}`));
|
|
235
|
+
logger.error(err.message);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Summary ────────────────────────────────────────────────────────────────
|
|
240
|
+
const elapsed = formatDuration(Date.now() - startTime);
|
|
241
|
+
|
|
242
|
+
console.log('');
|
|
243
|
+
console.log(chalk.dim(' ' + '─'.repeat(50)));
|
|
244
|
+
console.log('');
|
|
245
|
+
console.log(chalk.bold.green(' ✅ Done!') + chalk.dim(` in ${elapsed}`));
|
|
246
|
+
console.log('');
|
|
247
|
+
|
|
248
|
+
if (analysis.stack.length > 0) {
|
|
249
|
+
console.log(chalk.dim(' Detected stack:'));
|
|
250
|
+
analysis.stack.slice(0, 8).forEach((s) => {
|
|
251
|
+
console.log(chalk.dim(' · ') + chalk.white(s));
|
|
252
|
+
});
|
|
253
|
+
if (analysis.stack.length > 8) {
|
|
254
|
+
console.log(chalk.dim(` · ...and ${analysis.stack.length - 8} more`));
|
|
255
|
+
}
|
|
256
|
+
console.log('');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (analysis.architecturePatterns.length > 0) {
|
|
260
|
+
console.log(chalk.dim(' Detected patterns:'));
|
|
261
|
+
analysis.architecturePatterns.slice(0, 5).forEach((p) => {
|
|
262
|
+
console.log(chalk.dim(' · ') + chalk.white(p));
|
|
263
|
+
});
|
|
264
|
+
console.log('');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
console.log(chalk.dim(' Output: ') + chalk.bold.cyan(absoluteOutputPath));
|
|
268
|
+
console.log('');
|
|
269
|
+
console.log(
|
|
270
|
+
chalk.dim(' Paste ') +
|
|
271
|
+
chalk.bold.white(output) +
|
|
272
|
+
chalk.dim(' into your AI assistant to give it full project context.')
|
|
273
|
+
);
|
|
274
|
+
console.log('');
|
|
275
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context.js
|
|
3
|
+
* Handles writing (and reading) the context.md file.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, statSync } from 'fs';
|
|
7
|
+
import { resolve, relative, dirname, isAbsolute } from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check whether context.md is already up to date.
|
|
11
|
+
*
|
|
12
|
+
* Strategy: compare the last-modified time (mtime) of the output file
|
|
13
|
+
* against every scanned source file. If context.md is newer than ALL of
|
|
14
|
+
* them, nothing has changed and we can safely skip regeneration.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} outputPath - relative path to context.md
|
|
17
|
+
* @param {Array<{ absolutePath: string }>} files - scanned files
|
|
18
|
+
* @param {string} cwd
|
|
19
|
+
* @returns {{ upToDate: boolean, changedFile: string|null }}
|
|
20
|
+
*/
|
|
21
|
+
export function isContextUpToDate(outputPath, files, cwd) {
|
|
22
|
+
const absolutePath = resolve(cwd, outputPath);
|
|
23
|
+
|
|
24
|
+
if (!existsSync(absolutePath)) {
|
|
25
|
+
return { upToDate: false, changedFile: null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let contextMtime;
|
|
29
|
+
try {
|
|
30
|
+
contextMtime = statSync(absolutePath).mtimeMs;
|
|
31
|
+
} catch {
|
|
32
|
+
return { upToDate: false, changedFile: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Find the first source file that is newer than context.md
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
try {
|
|
38
|
+
const mtime = statSync(file.absolutePath).mtimeMs;
|
|
39
|
+
if (mtime > contextMtime) {
|
|
40
|
+
return { upToDate: false, changedFile: file.path };
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Unreadable file — treat as changed to be safe
|
|
44
|
+
return { upToDate: false, changedFile: file.path };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { upToDate: true, changedFile: null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Write the generated context markdown to a file.
|
|
54
|
+
*
|
|
55
|
+
* Security: validates that the resolved output path stays inside cwd
|
|
56
|
+
* to prevent path traversal via a malicious --output flag.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} content - Markdown content
|
|
59
|
+
* @param {string} outputPath - Relative path for the output file (e.g. "context.md")
|
|
60
|
+
* @param {string} cwd - Working directory (project root)
|
|
61
|
+
* @returns {string} Absolute path of the written file
|
|
62
|
+
*/
|
|
63
|
+
export function writeContextFile(content, outputPath, cwd) {
|
|
64
|
+
const absolutePath = resolve(cwd, outputPath);
|
|
65
|
+
|
|
66
|
+
// ── Fix #4: Path traversal guard ─────────────────────────────────────────
|
|
67
|
+
// Ensure the resolved path is inside cwd, not a parent or sibling directory.
|
|
68
|
+
const rel = relative(cwd, absolutePath);
|
|
69
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Output path "${outputPath}" resolves outside the project directory.\n` +
|
|
72
|
+
`Use a relative path inside your project (e.g., context.md or docs/context.md).`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Create parent directories if needed (e.g., --output docs/ai/context.md)
|
|
77
|
+
const dir = dirname(absolutePath);
|
|
78
|
+
mkdirSync(dir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
writeFileSync(absolutePath, content, 'utf-8');
|
|
81
|
+
return absolutePath;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a context.md already exists and return its content.
|
|
86
|
+
* @param {string} outputPath
|
|
87
|
+
* @param {string} cwd
|
|
88
|
+
* @returns {string|null}
|
|
89
|
+
*/
|
|
90
|
+
export function readExistingContext(outputPath, cwd) {
|
|
91
|
+
const absolutePath = resolve(cwd, outputPath);
|
|
92
|
+
if (existsSync(absolutePath)) {
|
|
93
|
+
return readFileSync(absolutePath, 'utf-8');
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Add a timestamp header to the context markdown.
|
|
100
|
+
* @param {string} content
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function addTimestamp(content) {
|
|
104
|
+
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
|
105
|
+
const header = `<!-- Generated by ContextForge on ${timestamp} -->\n\n`;
|
|
106
|
+
return header + content;
|
|
107
|
+
}
|
package/src/generate.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generate.js
|
|
3
|
+
* Generates the fallback structural context.md when AI is skipped or unavailable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate a purely structural context.md from analysis data (no AI).
|
|
8
|
+
* @param {Object} analysis - Result from analyzeRepository()
|
|
9
|
+
* @param {string} folderTree
|
|
10
|
+
* @returns {string} Markdown content
|
|
11
|
+
*/
|
|
12
|
+
export function generateStructuralContext(analysis, folderTree) {
|
|
13
|
+
const {
|
|
14
|
+
projectName,
|
|
15
|
+
projectVersion,
|
|
16
|
+
projectDescription,
|
|
17
|
+
language,
|
|
18
|
+
moduleSystem,
|
|
19
|
+
stack,
|
|
20
|
+
byCategory,
|
|
21
|
+
architecturePatterns,
|
|
22
|
+
authApproach,
|
|
23
|
+
codingConventions,
|
|
24
|
+
scripts,
|
|
25
|
+
importantFiles,
|
|
26
|
+
totalFiles,
|
|
27
|
+
} = analysis;
|
|
28
|
+
|
|
29
|
+
const now = new Date().toISOString().split('T')[0];
|
|
30
|
+
|
|
31
|
+
const sections = [];
|
|
32
|
+
|
|
33
|
+
// Header
|
|
34
|
+
sections.push(`# Project Context`);
|
|
35
|
+
sections.push(
|
|
36
|
+
`> **${projectName}**${projectDescription ? ` — ${projectDescription}` : ''}\n` +
|
|
37
|
+
`> Generated by [ContextForge](https://github.com/contextforge) on ${now}`
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Stack
|
|
41
|
+
sections.push(`## Stack`);
|
|
42
|
+
if (stack.length > 0) {
|
|
43
|
+
sections.push(stack.map((s) => `- ${s}`).join('\n'));
|
|
44
|
+
} else {
|
|
45
|
+
sections.push(`- Language: ${language}\n- Module system: ${moduleSystem}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Metadata
|
|
49
|
+
sections.push(`## Project Metadata`);
|
|
50
|
+
const meta = [
|
|
51
|
+
`| Field | Value |`,
|
|
52
|
+
`|-------|-------|`,
|
|
53
|
+
`| Name | \`${projectName}\` |`,
|
|
54
|
+
`| Version | \`${projectVersion || 'N/A'}\` |`,
|
|
55
|
+
`| Language | ${language} |`,
|
|
56
|
+
`| Module System | ${moduleSystem} |`,
|
|
57
|
+
`| Total Files Scanned | ${totalFiles} |`,
|
|
58
|
+
];
|
|
59
|
+
sections.push(meta.join('\n'));
|
|
60
|
+
|
|
61
|
+
// Architecture
|
|
62
|
+
if (architecturePatterns.length > 0) {
|
|
63
|
+
sections.push(`## Architecture`);
|
|
64
|
+
sections.push(architecturePatterns.map((p) => `- ${p}`).join('\n'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Auth
|
|
68
|
+
if (authApproach.length > 0) {
|
|
69
|
+
sections.push(`## Authentication`);
|
|
70
|
+
sections.push(authApproach.map((a) => `- ${a}`).join('\n'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Tech by category
|
|
74
|
+
const categoryOrder = ['backend', 'frontend', 'database', 'auth', 'state', 'validation', 'testing', 'styling', 'api', 'build', 'http', 'realtime'];
|
|
75
|
+
const hasCategories = Object.keys(byCategory).length > 0;
|
|
76
|
+
if (hasCategories) {
|
|
77
|
+
sections.push(`## Technology by Category`);
|
|
78
|
+
categoryOrder.forEach((cat) => {
|
|
79
|
+
if (byCategory[cat]) {
|
|
80
|
+
sections.push(`### ${capitalize(cat)}\n${byCategory[cat].map((t) => `- ${t}`).join('\n')}`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// Any remaining categories not in order
|
|
84
|
+
Object.keys(byCategory).forEach((cat) => {
|
|
85
|
+
if (!categoryOrder.includes(cat)) {
|
|
86
|
+
sections.push(`### ${capitalize(cat)}\n${byCategory[cat].map((t) => `- ${t}`).join('\n')}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Coding Patterns
|
|
92
|
+
if (codingConventions.length > 0) {
|
|
93
|
+
sections.push(`## Coding Patterns`);
|
|
94
|
+
sections.push(codingConventions.map((c) => `- ${c}`).join('\n'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Important Files
|
|
98
|
+
if (importantFiles.length > 0) {
|
|
99
|
+
sections.push(`## Important Modules`);
|
|
100
|
+
const grouped = groupFilesByTopDir(importantFiles);
|
|
101
|
+
Object.entries(grouped).forEach(([dir, files]) => {
|
|
102
|
+
sections.push(`### ${dir === '_root' ? 'Root' : `\`${dir}/\``}`);
|
|
103
|
+
sections.push(files.map((f) => `- \`${f.path}\``).join('\n'));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Folder structure
|
|
108
|
+
sections.push(`## Folder Structure`);
|
|
109
|
+
sections.push('```\n' + folderTree + '```');
|
|
110
|
+
|
|
111
|
+
// Scripts
|
|
112
|
+
if (scripts.length > 0) {
|
|
113
|
+
sections.push(`## Development Workflow`);
|
|
114
|
+
const pkgScripts = analysis.pkgJson?.scripts || {};
|
|
115
|
+
const scriptLines = scripts.map((s) => {
|
|
116
|
+
const cmd = pkgScripts[s] || '';
|
|
117
|
+
return `- \`npm run ${s}\`${cmd ? ` — \`${cmd}\`` : ''}`;
|
|
118
|
+
});
|
|
119
|
+
sections.push(scriptLines.join('\n'));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// AI Instructions (structural version)
|
|
123
|
+
sections.push(`## AI Instructions`);
|
|
124
|
+
const instructions = buildAIInstructions(analysis);
|
|
125
|
+
sections.push(instructions.map((i) => `- ${i}`).join('\n'));
|
|
126
|
+
|
|
127
|
+
return sections.join('\n\n') + '\n';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build AI instructions from the analysis.
|
|
132
|
+
* @param {Object} analysis
|
|
133
|
+
* @returns {string[]}
|
|
134
|
+
*/
|
|
135
|
+
function buildAIInstructions(analysis) {
|
|
136
|
+
const instructions = [];
|
|
137
|
+
const { byCategory, architecturePatterns, codingConventions, authApproach, language, moduleSystem } = analysis;
|
|
138
|
+
|
|
139
|
+
instructions.push(`This is a **${language}** project using **${moduleSystem}**`);
|
|
140
|
+
|
|
141
|
+
if (byCategory.backend?.length) {
|
|
142
|
+
instructions.push(`Backend framework: ${byCategory.backend.join(', ')} — follow its conventions for routing and middleware`);
|
|
143
|
+
}
|
|
144
|
+
if (byCategory.frontend?.length) {
|
|
145
|
+
instructions.push(`Frontend: ${byCategory.frontend.join(', ')} — follow component and hook patterns already established`);
|
|
146
|
+
}
|
|
147
|
+
if (byCategory.database?.length) {
|
|
148
|
+
instructions.push(`Database layer: ${byCategory.database.join(', ')} — use existing query patterns, do not introduce new ORM`);
|
|
149
|
+
}
|
|
150
|
+
if (byCategory.auth?.length) {
|
|
151
|
+
instructions.push(`Auth: ${byCategory.auth.join(', ')} — do not change the auth mechanism without explicit instruction`);
|
|
152
|
+
}
|
|
153
|
+
if (byCategory.validation?.length) {
|
|
154
|
+
instructions.push(`Validation: ${byCategory.validation.join(', ')} — validate all inputs using existing schema patterns`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
architecturePatterns.forEach((p) => {
|
|
158
|
+
instructions.push(`Respect the existing pattern: ${p}`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (codingConventions.includes('async/await pattern')) {
|
|
162
|
+
instructions.push('Use async/await for all asynchronous operations — avoid .then() chains');
|
|
163
|
+
}
|
|
164
|
+
if (codingConventions.includes('ES Modules (import/export)')) {
|
|
165
|
+
instructions.push('Use ES module import/export syntax — never use require()');
|
|
166
|
+
}
|
|
167
|
+
if (codingConventions.includes('CommonJS (require/exports)')) {
|
|
168
|
+
instructions.push('Use CommonJS require/exports — avoid ES module import/export');
|
|
169
|
+
}
|
|
170
|
+
if (codingConventions.includes('TypeScript with strict typing')) {
|
|
171
|
+
instructions.push('Maintain strict TypeScript types — no implicit any, define interfaces for all data structures');
|
|
172
|
+
}
|
|
173
|
+
if (codingConventions.includes('try/catch error handling')) {
|
|
174
|
+
instructions.push('Wrap all async operations in try/catch and propagate errors appropriately');
|
|
175
|
+
}
|
|
176
|
+
if (codingConventions.includes('Schema validation')) {
|
|
177
|
+
instructions.push('Validate all external inputs with the existing validation library (Zod/Joi/Yup)');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (authApproach.includes('JWT token authentication')) {
|
|
181
|
+
instructions.push('Authentication uses JWT — always verify tokens through the existing middleware, never bypass it');
|
|
182
|
+
}
|
|
183
|
+
if (authApproach.includes('Role-based access control (RBAC)')) {
|
|
184
|
+
instructions.push('RBAC is implemented — check user roles before authorizing sensitive operations');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
instructions.push('Match the existing code style, naming conventions, and file organization');
|
|
188
|
+
instructions.push('Do not introduce new dependencies without explicit user approval');
|
|
189
|
+
instructions.push('Write tests for any new feature following the existing test structure');
|
|
190
|
+
|
|
191
|
+
return instructions;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Group files by their top-level directory.
|
|
196
|
+
* Files at root are grouped under '_root'.
|
|
197
|
+
*/
|
|
198
|
+
function groupFilesByTopDir(files) {
|
|
199
|
+
const groups = {};
|
|
200
|
+
files.forEach((file) => {
|
|
201
|
+
const parts = file.path.split('/');
|
|
202
|
+
const dir = parts.length > 1 ? parts[0] : '_root';
|
|
203
|
+
if (!groups[dir]) groups[dir] = [];
|
|
204
|
+
groups[dir].push(file);
|
|
205
|
+
});
|
|
206
|
+
return groups;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function capitalize(str) {
|
|
210
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
211
|
+
}
|