contextforge-cli-ai-prompt-pirates 0.4.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.
Files changed (72) hide show
  1. package/.contextforge/config.ai.example.json +28 -0
  2. package/.contextforge/config.example.json +29 -0
  3. package/.contextforge/prompts/README.md +50 -0
  4. package/.env.example +73 -0
  5. package/LICENSE +21 -0
  6. package/README.md +223 -0
  7. package/bin/contextforge.js +14 -0
  8. package/bin/postinstall-worker.js +14 -0
  9. package/bin/postinstall.js +26 -0
  10. package/package.json +65 -0
  11. package/prompts/README.md +50 -0
  12. package/prompts/response-schema.md +22 -0
  13. package/prompts/retry-addon.md +1 -0
  14. package/prompts/system.md +10 -0
  15. package/prompts/user-template.md +22 -0
  16. package/src/analyzers/ast-parser.js +139 -0
  17. package/src/analyzers/bugs-lite.js +118 -0
  18. package/src/analyzers/changelog.js +190 -0
  19. package/src/analyzers/code-insights.js +225 -0
  20. package/src/analyzers/dependencies.js +110 -0
  21. package/src/analyzers/detection-quality.js +106 -0
  22. package/src/analyzers/eslint-runner.js +56 -0
  23. package/src/analyzers/folder-tree.js +60 -0
  24. package/src/analyzers/git-insights.js +94 -0
  25. package/src/analyzers/project-meta.js +98 -0
  26. package/src/cli/commands/changes.js +36 -0
  27. package/src/cli/commands/doctor.js +152 -0
  28. package/src/cli/commands/generate.js +7 -0
  29. package/src/cli/commands/init.js +98 -0
  30. package/src/cli/commands/prompt.js +53 -0
  31. package/src/cli/commands/stop.js +10 -0
  32. package/src/cli/commands/summary.js +22 -0
  33. package/src/cli/commands/watch.js +9 -0
  34. package/src/cli/index.js +120 -0
  35. package/src/core/confidence.js +51 -0
  36. package/src/core/config.js +183 -0
  37. package/src/core/cursor-rules.js +293 -0
  38. package/src/core/ensure-setup.js +45 -0
  39. package/src/core/env.js +113 -0
  40. package/src/core/package-meta.js +35 -0
  41. package/src/core/paths.js +39 -0
  42. package/src/core/pipeline.js +256 -0
  43. package/src/core/postinstall.js +168 -0
  44. package/src/core/setup-env.js +86 -0
  45. package/src/core/setup-prompts.js +31 -0
  46. package/src/core/snapshot-hash.js +70 -0
  47. package/src/detectors/architecture.js +117 -0
  48. package/src/detectors/auth-deploy.js +51 -0
  49. package/src/detectors/database.js +127 -0
  50. package/src/detectors/tech-stack.js +180 -0
  51. package/src/generators/artifacts.js +44 -0
  52. package/src/generators/changes-markdown.js +72 -0
  53. package/src/generators/context-json.js +91 -0
  54. package/src/generators/context-markdown.js +571 -0
  55. package/src/generators/mermaid.js +59 -0
  56. package/src/index.js +13 -0
  57. package/src/scanner/change-detector.js +24 -0
  58. package/src/scanner/config-files.js +52 -0
  59. package/src/scanner/file-index.js +55 -0
  60. package/src/scanner/package-deps.js +32 -0
  61. package/src/scanner/repo-scanner.js +143 -0
  62. package/src/services/ai/background.js +87 -0
  63. package/src/services/ai/config.js +48 -0
  64. package/src/services/ai/enrich.js +87 -0
  65. package/src/services/ai/index.js +3 -0
  66. package/src/services/ai/prompt-loader.js +104 -0
  67. package/src/services/ai/prompt.js +42 -0
  68. package/src/services/ai/providers/groq.js +36 -0
  69. package/src/services/ai/providers/openai.js +32 -0
  70. package/src/services/ai/redact.js +33 -0
  71. package/src/services/ai/validate.js +70 -0
  72. package/src/watcher/watcher.js +128 -0
@@ -0,0 +1,117 @@
1
+ const PATTERNS = [
2
+ {
3
+ pattern: 'mvc',
4
+ label: 'MVC',
5
+ confidence: 'high',
6
+ test: (dirs) =>
7
+ dirs.has('controllers') && dirs.has('models') && (dirs.has('routes') || dirs.has('views')),
8
+ },
9
+ {
10
+ pattern: 'feature-based',
11
+ label: 'Feature-based',
12
+ confidence: 'high',
13
+ test: (dirs) => dirs.has('features') || dirs.has('modules'),
14
+ },
15
+ {
16
+ pattern: 'next-app-router',
17
+ label: 'Next.js App Router',
18
+ confidence: 'high',
19
+ test: (dirs, paths) =>
20
+ dirs.has('app') && paths.some((p) => p.startsWith('app/') && /layout\.(tsx?|jsx?)$/.test(p)),
21
+ },
22
+ {
23
+ pattern: 'pages-router',
24
+ label: 'Next.js Pages Router',
25
+ confidence: 'medium',
26
+ test: (dirs) => dirs.has('pages') && !dirs.has('app'),
27
+ },
28
+ {
29
+ pattern: 'layered',
30
+ label: 'Layered (services/controllers)',
31
+ confidence: 'medium',
32
+ test: (dirs) =>
33
+ (dirs.has('services') && dirs.has('controllers')) ||
34
+ (dirs.has('services') && dirs.has('routes')),
35
+ },
36
+ {
37
+ pattern: 'monolith',
38
+ label: 'Monolithic single-package',
39
+ confidence: 'low',
40
+ test: (dirs) => dirs.has('src') && !dirs.has('packages'),
41
+ },
42
+ {
43
+ pattern: 'microservices',
44
+ label: 'Multi-package / microservices',
45
+ confidence: 'medium',
46
+ test: (_dirs, paths) => {
47
+ const servicePackages = paths.filter(
48
+ (p) => /^services\/[^/]+\/package\.json$/i.test(p) || /^packages\/[^/]+\/package\.json$/i.test(p)
49
+ );
50
+ return servicePackages.length >= 2;
51
+ },
52
+ },
53
+ {
54
+ pattern: 'clean-architecture',
55
+ label: 'Clean Architecture',
56
+ confidence: 'medium',
57
+ test: (dirs) =>
58
+ dirs.has('domain') || dirs.has('use-cases') || dirs.has('infrastructure'),
59
+ },
60
+ ];
61
+
62
+ /**
63
+ * @param {string[]} relativePaths
64
+ */
65
+ export function analyzeArchitecture(relativePaths) {
66
+ const dirs = new Set();
67
+ const normalized = relativePaths.map((p) => p.replace(/\\/g, '/'));
68
+
69
+ for (const p of normalized) {
70
+ const top = p.split('/')[0];
71
+ if (top && !top.includes('.')) dirs.add(top);
72
+ const parts = p.split('/');
73
+ if (parts.length >= 2 && !parts[1].includes('.')) {
74
+ dirs.add(parts[1]);
75
+ }
76
+ }
77
+
78
+ const matches = [];
79
+
80
+ for (const { pattern, label, confidence, test } of PATTERNS) {
81
+ if (test(dirs, normalized)) {
82
+ matches.push({ pattern, label, confidence });
83
+ }
84
+ }
85
+
86
+ const primary = matches[0] || {
87
+ pattern: 'unknown',
88
+ label: 'Standard src-based layout',
89
+ confidence: 'low',
90
+ };
91
+
92
+ const folderHierarchy = [...dirs].sort().slice(0, 20);
93
+
94
+ let apiFlow = null;
95
+ if (matches.some((m) => m.pattern === 'mvc' || m.pattern === 'layered')) {
96
+ apiFlow = 'Client → Route → Controller → Service → Database';
97
+ } else if (matches.some((m) => m.pattern === 'next-app-router')) {
98
+ apiFlow = 'Browser → app/ routes → Server Components / API routes → Data layer';
99
+ }
100
+
101
+ return {
102
+ primary: {
103
+ pattern: primary.pattern,
104
+ label: primary.label,
105
+ confidence: primary.confidence,
106
+ displayLabel: `Likely ${primary.label}`,
107
+ },
108
+ allMatches: matches.map((m) => ({
109
+ ...m,
110
+ displayLabel: `Likely ${m.label}`,
111
+ })),
112
+ folderHierarchy,
113
+ apiFlow,
114
+ dependencyFlow: null,
115
+ signals: matches.map((m) => m.pattern),
116
+ };
117
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @param {ReturnType<import('./tech-stack.js').detectTechStack>} techStack
3
+ * @param {Awaited<ReturnType<import('../scanner/config-files.js').findConfigFiles>>} configFiles
4
+ * @param {string[]} relativePaths
5
+ */
6
+ export function detectAuthAndDeploy(techStack, configFiles, relativePaths) {
7
+ const auth = [...(techStack.auth || [])];
8
+ const deploy = [...(techStack.deploy || [])];
9
+ const hints = [];
10
+
11
+ const paths = relativePaths.map((p) => p.replace(/\\/g, '/').toLowerCase());
12
+
13
+ if (paths.some((p) => p.includes('middleware') && p.includes('auth'))) {
14
+ hints.push('Auth middleware detected in project structure');
15
+ }
16
+
17
+ if (paths.some((p) => p.includes('passport'))) {
18
+ if (!auth.includes('Passport')) auth.push('Passport');
19
+ }
20
+
21
+ if (paths.some((p) => /vercel\.json/i.test(p))) {
22
+ deploy.push('Vercel');
23
+ hints.push('vercel.json configuration found');
24
+ }
25
+
26
+ if (paths.some((p) => /netlify\.toml/i.test(p))) {
27
+ deploy.push('Netlify');
28
+ }
29
+
30
+ if (configFiles.parsed.dockerfile || configFiles.parsed.dockerCompose) {
31
+ if (!deploy.includes('Docker')) deploy.push('Docker');
32
+ }
33
+
34
+ if (configFiles.parsed.nginx) {
35
+ if (!deploy.includes('Nginx')) deploy.push('Nginx');
36
+ }
37
+
38
+ const sessionPatterns = paths.filter((p) =>
39
+ p.includes('session') || p.includes('cookie')
40
+ );
41
+ if (sessionPatterns.length && !auth.length) {
42
+ auth.push('Session-based auth (possible)');
43
+ hints.push('Session/cookie related files detected');
44
+ }
45
+
46
+ return {
47
+ authentication: [...new Set(auth)],
48
+ deployment: [...new Set(deploy)],
49
+ hints,
50
+ };
51
+ }
@@ -0,0 +1,127 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+
4
+ function parsePrismaSchema(content) {
5
+ const models = [];
6
+ const enums = [];
7
+ let provider = null;
8
+
9
+ const datasourceMatch = content.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*"([^"]+)"/s);
10
+ if (datasourceMatch) provider = datasourceMatch[1];
11
+
12
+ const modelRegex = /model\s+(\w+)\s*\{([^}]*)\}/gs;
13
+ let match;
14
+ while ((match = modelRegex.exec(content)) !== null) {
15
+ const name = match[1];
16
+ const body = match[2];
17
+ const fields = [];
18
+ for (const line of body.split('\n')) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) continue;
21
+ const fieldMatch = trimmed.match(/^(\w+)\s+/);
22
+ if (fieldMatch && !['model', 'enum'].includes(fieldMatch[1])) {
23
+ fields.push(fieldMatch[1]);
24
+ }
25
+ }
26
+ const relationFields = [];
27
+ for (const line of body.split('\n')) {
28
+ const relMatch = line.match(/(\w+)\s+.*@relation/);
29
+ if (relMatch) relationFields.push(relMatch[1]);
30
+ }
31
+ const relations = body.match(/@relation/g) || [];
32
+ models.push({
33
+ name,
34
+ fields: fields.slice(0, 15),
35
+ relationCount: relations.length,
36
+ relations: relationFields,
37
+ });
38
+ }
39
+
40
+ const enumRegex = /enum\s+(\w+)/g;
41
+ let enumMatch;
42
+ while ((enumMatch = enumRegex.exec(content)) !== null) {
43
+ enums.push(enumMatch[1]);
44
+ }
45
+
46
+ return { provider, models, enums };
47
+ }
48
+
49
+ function parseMongooseStub(content, filename) {
50
+ const schemaMatch = content.match(/mongoose\.Schema\s*\(\s*\{([^}]+)\}/s);
51
+ if (!schemaMatch) return null;
52
+ const fields = [...schemaMatch[1].matchAll(/(\w+)\s*:/g)].map((m) => m[1]);
53
+ const name = path.basename(filename).replace(/\.(js|ts)$/, '');
54
+ return { name, fields: fields.slice(0, 15), source: filename };
55
+ }
56
+
57
+ /**
58
+ * @param {string} projectRoot
59
+ * @param {string[]} relativePaths
60
+ */
61
+ export async function extractDatabaseInfo(projectRoot, relativePaths) {
62
+ const result = {
63
+ orm: null,
64
+ provider: null,
65
+ models: [],
66
+ enums: [],
67
+ migrations: [],
68
+ textDiagram: null,
69
+ };
70
+
71
+ const prismaPaths = relativePaths.filter((p) =>
72
+ p.replace(/\\/g, '/').endsWith('schema.prisma')
73
+ );
74
+
75
+ for (const rel of prismaPaths) {
76
+ const full = path.join(projectRoot, rel);
77
+ try {
78
+ const content = await fs.readFile(full, 'utf8');
79
+ const parsed = parsePrismaSchema(content);
80
+ result.orm = 'Prisma';
81
+ result.provider = parsed.provider;
82
+ result.models.push(...parsed.models);
83
+ result.enums.push(...parsed.enums);
84
+ } catch {
85
+ /* skip */
86
+ }
87
+ }
88
+
89
+ const migrationDirs = relativePaths.filter((p) =>
90
+ /migrations?\/|prisma\/migrations/i.test(p)
91
+ );
92
+ result.migrations = migrationDirs.slice(0, 10);
93
+
94
+ const modelFiles = relativePaths.filter((p) =>
95
+ /models?\/.*\.(js|ts)$/i.test(p) && !p.includes('node_modules')
96
+ );
97
+
98
+ for (const rel of modelFiles.slice(0, 15)) {
99
+ const full = path.join(projectRoot, rel);
100
+ try {
101
+ const content = await fs.readFile(full, 'utf8');
102
+ if (content.includes('mongoose')) {
103
+ const stub = parseMongooseStub(content, rel);
104
+ if (stub) {
105
+ result.orm = result.orm || 'Mongoose';
106
+ result.models.push(stub);
107
+ }
108
+ }
109
+ } catch {
110
+ /* skip */
111
+ }
112
+ }
113
+
114
+ if (result.models.length > 0) {
115
+ result.textDiagram = result.models
116
+ .map((m) => {
117
+ const lines = (m.fields || []).map((f) => ` ├── ${f}`);
118
+ if (m.relations?.length) {
119
+ lines.push(` └── relations: ${m.relations.join(', ')}`);
120
+ }
121
+ return `${m.name}\n${lines.join('\n') || ' ├── (fields)'}`;
122
+ })
123
+ .join('\n\n');
124
+ }
125
+
126
+ return result;
127
+ }
@@ -0,0 +1,180 @@
1
+ const DEP_MAP = {
2
+ react: { label: 'React', category: 'frontend' },
3
+ 'react-dom': { label: 'React', category: 'frontend' },
4
+ next: { label: 'Next.js', category: 'frontend' },
5
+ vue: { label: 'Vue', category: 'frontend' },
6
+ nuxt: { label: 'Nuxt', category: 'frontend' },
7
+ '@angular/core': { label: 'Angular', category: 'frontend' },
8
+ vite: { label: 'Vite', category: 'frontend' },
9
+ express: { label: 'Express.js', category: 'backend' },
10
+ '@nestjs/core': { label: 'NestJS', category: 'backend' },
11
+ fastify: { label: 'Fastify', category: 'backend' },
12
+ koa: { label: 'Koa', category: 'backend' },
13
+ mongoose: { label: 'Mongoose', category: 'database' },
14
+ mongodb: { label: 'MongoDB', category: 'database' },
15
+ pg: { label: 'PostgreSQL', category: 'database' },
16
+ mysql2: { label: 'MySQL', category: 'database' },
17
+ 'better-sqlite3': { label: 'SQLite', category: 'database' },
18
+ ioredis: { label: 'Redis', category: 'database' },
19
+ redis: { label: 'Redis', category: 'database' },
20
+ '@prisma/client': { label: 'Prisma', category: 'orm' },
21
+ prisma: { label: 'Prisma', category: 'orm' },
22
+ sequelize: { label: 'Sequelize', category: 'orm' },
23
+ typeorm: { label: 'TypeORM', category: 'orm' },
24
+ tailwindcss: { label: 'TailwindCSS', category: 'styling' },
25
+ bootstrap: { label: 'Bootstrap', category: 'styling' },
26
+ '@mui/material': { label: 'Material UI', category: 'styling' },
27
+ '@chakra-ui/react': { label: 'Chakra UI', category: 'styling' },
28
+ '@reduxjs/toolkit': { label: 'Redux Toolkit', category: 'state' },
29
+ redux: { label: 'Redux', category: 'state' },
30
+ zustand: { label: 'Zustand', category: 'state' },
31
+ mobx: { label: 'MobX', category: 'state' },
32
+ jsonwebtoken: { label: 'JWT', category: 'auth' },
33
+ passport: { label: 'Passport', category: 'auth' },
34
+ '@clerk/clerk-sdk-node': { label: 'Clerk', category: 'auth' },
35
+ '@auth0/auth0-react': { label: 'Auth0', category: 'auth' },
36
+ 'firebase-admin': { label: 'Firebase Auth', category: 'auth' },
37
+ axios: { label: 'Axios', category: 'http' },
38
+ 'simple-git': { label: 'Git (simple-git)', category: 'backend' },
39
+ 'fs-extra': { label: 'fs-extra', category: 'backend' },
40
+ glob: { label: 'glob', category: 'backend' },
41
+ commander: { label: 'CLI (commander)', category: 'backend' },
42
+ chokidar: { label: 'File watcher (chokidar)', category: 'backend' },
43
+ vitest: { label: 'Vitest', category: 'testing' },
44
+ jest: { label: 'Jest', category: 'testing' },
45
+ mocha: { label: 'Mocha', category: 'testing' },
46
+ '@playwright/test': { label: 'Playwright', category: 'testing' },
47
+ cypress: { label: 'Cypress', category: 'testing' },
48
+ 'react-router-dom': { label: 'React Router', category: 'frontend' },
49
+ '@tanstack/react-query': { label: 'TanStack Query', category: 'frontend' },
50
+ hono: { label: 'Hono', category: 'backend' },
51
+ '@hono/node-server': { label: 'Hono', category: 'backend' },
52
+ 'socket.io': { label: 'Socket.io', category: 'backend' },
53
+ ws: { label: 'WebSocket (ws)', category: 'backend' },
54
+ cors: { label: 'CORS', category: 'backend' },
55
+ bcryptjs: { label: 'bcrypt', category: 'auth' },
56
+ bcrypt: { label: 'bcrypt', category: 'auth' },
57
+ 'cookie-parser': { label: 'Cookie sessions', category: 'auth' },
58
+ 'express-session': { label: 'Express sessions', category: 'auth' },
59
+ supabase: { label: 'Supabase', category: 'database' },
60
+ '@supabase/supabase-js': { label: 'Supabase', category: 'database' },
61
+ firebase: { label: 'Firebase', category: 'database' },
62
+ 'drizzle-orm': { label: 'Drizzle ORM', category: 'orm' },
63
+ '@drizzle-team/kit': { label: 'Drizzle ORM', category: 'orm' },
64
+ knex: { label: 'Knex', category: 'orm' },
65
+ 'react-native': { label: 'React Native', category: 'frontend' },
66
+ electron: { label: 'Electron', category: 'frontend' },
67
+ svelte: { label: 'Svelte', category: 'frontend' },
68
+ '@sveltejs/kit': { label: 'SvelteKit', category: 'frontend' },
69
+ remix: { label: 'Remix', category: 'frontend' },
70
+ '@remix-run/node': { label: 'Remix', category: 'frontend' },
71
+ turbo: { label: 'Turborepo', category: 'deploy' },
72
+ nx: { label: 'Nx monorepo', category: 'deploy' },
73
+ lerna: { label: 'Lerna monorepo', category: 'deploy' },
74
+ dotenv: { label: 'dotenv', category: 'backend' },
75
+ openai: { label: 'OpenAI SDK', category: 'backend' },
76
+ '@anthropic-ai/sdk': { label: 'Anthropic SDK', category: 'backend' },
77
+ };
78
+
79
+ const CONFIG_SIGNALS = [
80
+ { key: 'vite', label: 'Vite', category: 'frontend' },
81
+ { key: 'next', label: 'Next.js', category: 'frontend' },
82
+ { key: 'tailwind', label: 'TailwindCSS', category: 'styling' },
83
+ { key: 'prisma', label: 'Prisma', category: 'orm' },
84
+ { key: 'dockerfile', label: 'Docker', category: 'deploy' },
85
+ { key: 'dockerCompose', label: 'Docker Compose', category: 'deploy' },
86
+ { key: 'nginx', label: 'Nginx', category: 'deploy' },
87
+ ];
88
+
89
+ function addUnique(arr, item) {
90
+ if (!arr.includes(item)) arr.push(item);
91
+ }
92
+
93
+ /**
94
+ * @param {import('../scanner/config-files.js').findConfigFiles extends Function ? Awaited<ReturnType<typeof import('../scanner/config-files.js').findConfigFiles>> : never} configFiles
95
+ * @param {string[]} relativePaths
96
+ */
97
+ export function detectTechStack(configFiles, relativePaths) {
98
+ const stack = {
99
+ frontend: [],
100
+ backend: [],
101
+ database: [],
102
+ orm: [],
103
+ styling: [],
104
+ state: [],
105
+ auth: [],
106
+ deploy: [],
107
+ http: [],
108
+ testing: [],
109
+ confidence: 'medium',
110
+ signals: [],
111
+ packageJsonCount: 0,
112
+ };
113
+
114
+ const pkg = configFiles.packageJson;
115
+ const allDeps = { ...(configFiles.mergedDependencies || {}) };
116
+
117
+ if (!Object.keys(allDeps).length && pkg) {
118
+ Object.assign(allDeps, pkg.dependencies || {}, pkg.devDependencies || {}, pkg.peerDependencies || {});
119
+ }
120
+
121
+ const pkgJsonPaths = relativePaths.filter((p) =>
122
+ p.replace(/\\/g, '/').endsWith('package.json')
123
+ );
124
+ stack.packageJsonCount = Math.max(1, pkgJsonPaths.length);
125
+
126
+ if (pkg) {
127
+ if (pkg.name) stack.projectName = pkg.name;
128
+ if (pkg.description) stack.description = pkg.description;
129
+ stack.confidence = 'high';
130
+
131
+ if (pkg.bin || pkg.type === 'module') {
132
+ addUnique(stack.backend, 'Node.js');
133
+ stack.signals.push('package:node-cli');
134
+ }
135
+ if (pkg.engines?.node) {
136
+ addUnique(stack.backend, 'Node.js');
137
+ }
138
+ }
139
+
140
+ for (const [dep, meta] of Object.entries(DEP_MAP)) {
141
+ if (allDeps[dep]) {
142
+ const cat = meta.category in stack ? meta.category : 'backend';
143
+ addUnique(stack[cat], meta.label);
144
+ stack.signals.push(`dependency:${dep}`);
145
+ }
146
+ }
147
+
148
+ for (const depName of Object.keys(allDeps)) {
149
+ if (DEP_MAP[depName]) continue;
150
+ const lower = depName.toLowerCase();
151
+ if (/^@types\//.test(depName)) continue;
152
+ if (lower.includes('eslint') || lower.includes('prettier')) continue;
153
+ if (lower.includes('typescript') && lower !== 'typescript') continue;
154
+ stack.signals.push(`dependency:unmapped:${depName}`);
155
+ }
156
+
157
+ for (const { key, label, category } of CONFIG_SIGNALS) {
158
+ if (configFiles.parsed[key]) {
159
+ const cat = category in stack ? category : 'frontend';
160
+ addUnique(stack[cat], label);
161
+ stack.signals.push(`config:${key}`);
162
+ }
163
+ }
164
+
165
+ const normalizedPaths = relativePaths.map((p) => p.replace(/\\/g, '/'));
166
+ if (normalizedPaths.some((p) => p.startsWith('app/') && p.includes('layout.'))) {
167
+ addUnique(stack.frontend, 'Next.js App Router');
168
+ stack.signals.push('folder:app-router');
169
+ }
170
+
171
+ if (stack.frontend.length && stack.backend.length) {
172
+ stack.confidence = 'high';
173
+ } else if (stack.frontend.length || stack.backend.length) {
174
+ stack.confidence = 'medium';
175
+ } else {
176
+ stack.confidence = 'low';
177
+ }
178
+
179
+ return stack;
180
+ }
@@ -0,0 +1,44 @@
1
+ import fs from 'fs-extra';
2
+ import { getOutputPath, OUTPUT_FILES } from '../core/paths.js';
3
+
4
+ /**
5
+ * @param {string} projectRoot
6
+ * @param {ReturnType<import('./context-json.js').buildContextSnapshot>} snapshot
7
+ */
8
+ export async function writeArtifacts(projectRoot, snapshot) {
9
+ await fs.writeJson(
10
+ getOutputPath(projectRoot, OUTPUT_FILES.contextJson),
11
+ snapshot,
12
+ { spaces: 2 }
13
+ );
14
+
15
+ await fs.writeJson(
16
+ getOutputPath(projectRoot, OUTPUT_FILES.architectureJson),
17
+ {
18
+ generatedAt: snapshot.generatedAt,
19
+ primary: snapshot.architecture.primary,
20
+ allMatches: snapshot.architecture.allMatches,
21
+ apiFlow: snapshot.architecture.apiFlow,
22
+ folderHierarchy: snapshot.architecture.folderHierarchy,
23
+ },
24
+ { spaces: 2 }
25
+ );
26
+
27
+ await fs.writeJson(
28
+ getOutputPath(projectRoot, OUTPUT_FILES.gitInsightsJson),
29
+ {
30
+ generatedAt: snapshot.generatedAt,
31
+ ...snapshot.git,
32
+ },
33
+ { spaces: 2 }
34
+ );
35
+
36
+ await fs.writeJson(
37
+ getOutputPath(projectRoot, OUTPUT_FILES.bugsReportJson),
38
+ {
39
+ generatedAt: snapshot.generatedAt,
40
+ ...snapshot.bugs,
41
+ },
42
+ { spaces: 2 }
43
+ );
44
+ }
@@ -0,0 +1,72 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { getContextForgeRoot } from '../core/paths.js';
4
+
5
+ /**
6
+ * Write standalone CHANGES.md for change tracking over time.
7
+ */
8
+ export async function writeChangesMarkdown(projectRoot, changelog, history) {
9
+ const outPath = path.join(getContextForgeRoot(projectRoot), 'CHANGES.md');
10
+
11
+ let md = `# ContextForge — Change Log
12
+
13
+ > Auto-updated when you run \`generate\` or \`watch\`.
14
+ > Last run: **${changelog.runAt}**
15
+
16
+ ---
17
+
18
+ ## Latest run
19
+
20
+ **Status:** ${changelog.hasChanges ? 'Changes detected' : 'No changes'}
21
+
22
+ ${changelog.summary}
23
+
24
+ `;
25
+
26
+ if (changelog.previousRunAt) {
27
+ md += `**Previous run:** ${changelog.previousRunAt}\n\n`;
28
+ }
29
+
30
+ if (changelog.addedFiles?.length) {
31
+ md += `### New files (${changelog.addedFiles.length})\n\n`;
32
+ md += changelog.addedFiles.slice(0, 30).map((f) => `- \`${f}\``).join('\n');
33
+ if (changelog.addedFiles.length > 30) md += `\n- _...and ${changelog.addedFiles.length - 30} more_`;
34
+ md += '\n\n';
35
+ }
36
+
37
+ if (changelog.modifiedFiles?.length) {
38
+ md += `### Modified files (${changelog.modifiedFiles.length})\n\n`;
39
+ md += changelog.modifiedFiles.slice(0, 30).map((f) => `- \`${f}\``).join('\n');
40
+ if (changelog.modifiedFiles.length > 30) md += `\n- _...and ${changelog.modifiedFiles.length - 30} more_`;
41
+ md += '\n\n';
42
+ }
43
+
44
+ if (changelog.removedFiles?.length) {
45
+ md += `### Removed files (${changelog.removedFiles.length})\n\n`;
46
+ md += changelog.removedFiles.slice(0, 20).map((f) => `- \`${f}\``).join('\n');
47
+ md += '\n\n';
48
+ }
49
+
50
+ if (changelog.techStackChanged?.length) {
51
+ md += `### Tech stack\n\n${changelog.techStackChanged.map((t) => `- ${t}`).join('\n')}\n\n`;
52
+ }
53
+
54
+ if (changelog.architectureChanged) {
55
+ md += `### Architecture\n\n- ${changelog.architectureChanged}\n\n`;
56
+ }
57
+
58
+ if (history?.runs?.length > 1) {
59
+ md += `## Run history\n\n| When | Changed? | Summary |\n|------|----------|----------|\n`;
60
+ for (const run of history.runs.slice(0, 15)) {
61
+ const when = run.runAt?.slice(0, 19).replace('T', ' ') || '';
62
+ const flag = run.hasChanges ? 'Yes' : 'No';
63
+ md += `| ${when} | ${flag} | ${(run.summary || '').replace(/\|/g, '/').slice(0, 60)} |\n`;
64
+ }
65
+ md += '\n';
66
+ }
67
+
68
+ md += `---\n\nRegenerate: \`npx contextforge generate\` · Watch: \`npx contextforge watch\`\n`;
69
+
70
+ await fs.writeFile(outPath, md, 'utf8');
71
+ return outPath;
72
+ }
@@ -0,0 +1,91 @@
1
+ const VERSION = '1.0.0';
2
+
3
+ /**
4
+ * @param {object} data
5
+ */
6
+ export function buildContextSnapshot(data) {
7
+ const {
8
+ projectRoot,
9
+ config,
10
+ scanResult,
11
+ configFiles,
12
+ techStack,
13
+ architecture,
14
+ database,
15
+ authDeploy,
16
+ folderTree,
17
+ git,
18
+ dependencies,
19
+ codeInsights,
20
+ bugs,
21
+ aiEnrichment,
22
+ ast,
23
+ changelog,
24
+ confidence,
25
+ projectMeta,
26
+ astFull,
27
+ } = data;
28
+
29
+ return {
30
+ version: VERSION,
31
+ generatedAt: new Date().toISOString(),
32
+ projectRoot,
33
+ projectName: techStack.projectName || pathBasename(projectRoot),
34
+ description: techStack.description || null,
35
+ techStack: {
36
+ frontend: techStack.frontend,
37
+ backend: techStack.backend,
38
+ database: techStack.database,
39
+ orm: techStack.orm,
40
+ styling: techStack.styling,
41
+ state: techStack.state,
42
+ auth: techStack.auth,
43
+ deploy: techStack.deploy,
44
+ http: techStack.http,
45
+ testing: techStack.testing || [],
46
+ confidence: techStack.confidence,
47
+ signals: techStack.signals,
48
+ packageJsonCount: techStack.packageJsonCount || 1,
49
+ },
50
+ architecture: {
51
+ primary: architecture.primary,
52
+ allMatches: architecture.allMatches,
53
+ folderHierarchy: architecture.folderHierarchy,
54
+ apiFlow: architecture.apiFlow,
55
+ dependencyFlow: architecture.dependencyFlow,
56
+ signals: architecture.signals,
57
+ },
58
+ folderTree,
59
+ database,
60
+ authDeploy,
61
+ git,
62
+ dependencies,
63
+ codeInsights,
64
+ bugs,
65
+ aiEnrichment: aiEnrichment || null,
66
+ ast: astFull || (ast ? { available: ast.available, routesCount: ast.routes?.length, filesParsed: ast.filesParsed, routes: ast.routes?.slice(0, 50) } : null),
67
+ projectMeta: projectMeta || null,
68
+ changelog: changelog || null,
69
+ confidence: confidence || null,
70
+ detectionQuality: data.detectionQuality || null,
71
+ configFiles: configFiles.paths,
72
+ filesScanned: scanResult.files.length,
73
+ filesSignature: data.filesSignature || null,
74
+ projectDirs: scanResult.projectDirs || [],
75
+ config: {
76
+ watch: config.watch,
77
+ detectBugs: config.detectBugs,
78
+ includeGitHistory: config.includeGitHistory,
79
+ ai: {
80
+ enabled: config.ai?.enabled,
81
+ provider: config.ai?.provider,
82
+ background: config.ai?.background,
83
+ },
84
+ },
85
+ };
86
+ }
87
+
88
+ function pathBasename(p) {
89
+ const parts = p.replace(/\\/g, '/').split('/');
90
+ return parts[parts.length - 1] || 'project';
91
+ }