mad-pro-cli 1.2.0 ā 1.3.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/index.js +1 -1
- package/lib/commands/add.js +7 -3
- package/lib/commands/doctor/architecture-checker.js +140 -0
- package/lib/commands/doctor/diff-reviewer.js +40 -0
- package/lib/commands/doctor/security-scanner.js +93 -0
- package/lib/commands/doctor.js +123 -52
- package/lib/commands/init.js +10 -4
- package/lib/commands/prompt.js +98 -28
- package/lib/utils/config.js +32 -0
- package/package.json +4 -1
package/index.js
CHANGED
package/lib/commands/add.js
CHANGED
|
@@ -22,9 +22,11 @@ export default async function addCommand() {
|
|
|
22
22
|
// 1. Get all available skills
|
|
23
23
|
const coreFiles = fs.readdirSync(sourceDir).filter(f => f.endsWith('.md'));
|
|
24
24
|
const industryPath = path.join(sourceDir, 'industry');
|
|
25
|
-
const industryFiles = fs.readdirSync(industryPath).filter(f => f.endsWith('.md')).map(f => `industry/${f}`);
|
|
25
|
+
const industryFiles = fs.existsSync(industryPath) ? fs.readdirSync(industryPath).filter(f => f.endsWith('.md')).map(f => `industry/${f}`) : [];
|
|
26
|
+
const tokenPath = path.join(sourceDir, 'design-tokens');
|
|
27
|
+
const tokenFiles = fs.existsSync(tokenPath) ? fs.readdirSync(tokenPath).filter(f => f.endsWith('.md')).map(f => `design-tokens/${f}`) : [];
|
|
26
28
|
|
|
27
|
-
const allSkills = [...coreFiles, ...industryFiles].map(f => ({
|
|
29
|
+
const allSkills = [...coreFiles, ...industryFiles, ...tokenFiles].map(f => ({
|
|
28
30
|
name: f.replace('.md', '').replace(/_/g, ' ').toUpperCase(),
|
|
29
31
|
value: f
|
|
30
32
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -48,14 +50,16 @@ export default async function addCommand() {
|
|
|
48
50
|
// 3. Perform Copying
|
|
49
51
|
const targetRefDir = path.join(rootDir, 'references');
|
|
50
52
|
const targetIndustryDir = path.join(targetRefDir, 'industry');
|
|
53
|
+
const targetTokenDir = path.join(targetRefDir, 'design-tokens');
|
|
51
54
|
await fs.ensureDir(targetRefDir);
|
|
52
55
|
await fs.ensureDir(targetIndustryDir);
|
|
56
|
+
await fs.ensureDir(targetTokenDir);
|
|
53
57
|
|
|
54
58
|
console.log(chalk.cyan('\nš¦ Adding selected skills...'));
|
|
55
59
|
|
|
56
60
|
for (const skill of answers.skills) {
|
|
57
61
|
const src = path.join(sourceDir, skill);
|
|
58
|
-
const dest = path.join(targetRefDir, skill);
|
|
62
|
+
const dest = path.join(targetRefDir, skill.replace('industry/', 'industry/').replace('design-tokens/', 'design-tokens/'));
|
|
59
63
|
|
|
60
64
|
if (fs.existsSync(src)) {
|
|
61
65
|
await fs.copy(src, dest);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
export async function runArchitectureCheck(rootDir, config) {
|
|
6
|
+
const issues = [];
|
|
7
|
+
const arch = config.architecture || 'mvvm';
|
|
8
|
+
const di = config.di || 'hilt';
|
|
9
|
+
|
|
10
|
+
// Helper to scan files
|
|
11
|
+
const ktFiles = [];
|
|
12
|
+
function scanDir(dir) {
|
|
13
|
+
if (!fs.existsSync(dir)) return;
|
|
14
|
+
const files = fs.readdirSync(dir);
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const fullPath = path.join(dir, file);
|
|
17
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
18
|
+
// Skip build and generated dirs
|
|
19
|
+
if (!['build', '.gradle', '.idea', 'node_modules'].includes(file)) {
|
|
20
|
+
scanDir(fullPath);
|
|
21
|
+
}
|
|
22
|
+
} else if (fullPath.endsWith('.kt')) {
|
|
23
|
+
ktFiles.push(fullPath);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
scanDir(rootDir);
|
|
29
|
+
|
|
30
|
+
let hasDomainLayer = false;
|
|
31
|
+
let hasUiLayer = false;
|
|
32
|
+
let hasDataLayer = false;
|
|
33
|
+
|
|
34
|
+
for (const file of ktFiles) {
|
|
35
|
+
const relPath = path.relative(rootDir, file);
|
|
36
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
37
|
+
|
|
38
|
+
// Layer checks
|
|
39
|
+
if (relPath.includes('/domain/') || relPath.includes('/usecase/')) hasDomainLayer = true;
|
|
40
|
+
if (relPath.includes('/ui/') || relPath.includes('/presentation/')) hasUiLayer = true;
|
|
41
|
+
if (relPath.includes('/data/')) hasDataLayer = true;
|
|
42
|
+
|
|
43
|
+
const lines = content.split('\n');
|
|
44
|
+
|
|
45
|
+
// God class check
|
|
46
|
+
if (lines.length > 300) {
|
|
47
|
+
issues.push({
|
|
48
|
+
severity: 'warning',
|
|
49
|
+
file: relPath,
|
|
50
|
+
line: 1,
|
|
51
|
+
title: 'God-class detected',
|
|
52
|
+
message: 'File is over 300 lines. Consider splitting into smaller classes/composables.'
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Specific file type checks based on content
|
|
57
|
+
const isViewModel = content.includes('ViewModel()') || file.endsWith('ViewModel.kt') || content.includes('class ') && content.includes('ViewModel');
|
|
58
|
+
const isActivityOrFragment = content.includes('class ') && (content.includes(' : AppCompatActivity') || content.includes(' : ComponentActivity') || content.includes(' : Fragment'));
|
|
59
|
+
|
|
60
|
+
// Activity logic check
|
|
61
|
+
if (isActivityOrFragment) {
|
|
62
|
+
if (arch !== 'custom' && (content.includes('viewModelScope') || content.includes('Retrofit') || content.includes('Dao'))) {
|
|
63
|
+
issues.push({
|
|
64
|
+
severity: 'critical',
|
|
65
|
+
file: relPath,
|
|
66
|
+
line: lines.findIndex(l => l.includes('Retrofit') || l.includes('Dao') || l.includes('viewModelScope')) + 1 || 1,
|
|
67
|
+
title: `Logic in Activity/Fragment (${arch.toUpperCase()})`,
|
|
68
|
+
message: `Migrate logic to ViewModel or ${arch === 'viper' ? 'Interactor' : 'UseCase'}`,
|
|
69
|
+
refactor: `Ensure setContent { } wraps your NavHost, no logic in Activity`
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (isViewModel) {
|
|
75
|
+
// Missing DI check
|
|
76
|
+
if (di === 'hilt' && !content.includes('@HiltViewModel')) {
|
|
77
|
+
issues.push({ severity: 'warning', file: relPath, line: 1, title: 'Missing @HiltViewModel', message: 'Add @HiltViewModel to use Hilt DI.' });
|
|
78
|
+
} else if (di === 'koin' && !content.includes('@KoinViewModel') && !content.includes('viewModel {')) {
|
|
79
|
+
// Koin usually defined in module, but annotations exist in Koin annotations
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Bypass UseCase check (Directly injecting Repository)
|
|
83
|
+
if (['mvi', 'clean+mvi', 'viper'].includes(arch)) {
|
|
84
|
+
const repoInjectMatch = content.match(/val\s+[a-zA-Z0-9_]+Repository\s*:/);
|
|
85
|
+
if (repoInjectMatch) {
|
|
86
|
+
issues.push({
|
|
87
|
+
severity: 'critical',
|
|
88
|
+
file: relPath,
|
|
89
|
+
line: lines.findIndex(l => l.includes(repoInjectMatch[0])) + 1,
|
|
90
|
+
title: 'Business logic bypass detected',
|
|
91
|
+
message: `Direct usage of Repository in ViewModel. Expected UseCase/Interactor in ${arch.toUpperCase()}`,
|
|
92
|
+
refactor: `class MyUseCase @Inject constructor(private val repo: Repository) { operator fun invoke() = repo.exec() }\nInject MyUseCase instead.`
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MVI Checks
|
|
98
|
+
if (['mvi', 'clean+mvi'].includes(arch)) {
|
|
99
|
+
if (!content.includes('StateFlow') && !content.includes('UiState')) {
|
|
100
|
+
issues.push({ severity: 'critical', file: relPath, line: 1, title: 'Missing StateFlow/UiState', message: 'MVI requires state management via StateFlow.' });
|
|
101
|
+
}
|
|
102
|
+
if (!content.includes('Intent') && !content.includes('Action') && !content.includes('Event')) {
|
|
103
|
+
issues.push({ severity: 'critical', file: relPath, line: 1, title: 'Missing Intent/Action', message: 'MVI expects explicit Intents or Actions.' });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// `runBlocking` in main thread (generic check)
|
|
109
|
+
lines.forEach((line, index) => {
|
|
110
|
+
if (line.includes('runBlocking')) {
|
|
111
|
+
issues.push({
|
|
112
|
+
severity: 'critical',
|
|
113
|
+
file: relPath,
|
|
114
|
+
line: index + 1,
|
|
115
|
+
title: 'runBlocking detected',
|
|
116
|
+
message: 'Avoid runBlocking in production code as it blocks the thread.',
|
|
117
|
+
refactor: 'Replace with viewModelScope.launch or lifecycleScope.launch'
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Hardcoded string (naive check inside composables / classes)
|
|
122
|
+
if (line.match(/Text\(\s*"[^"]+"\s*\)/)) {
|
|
123
|
+
issues.push({
|
|
124
|
+
severity: 'warning',
|
|
125
|
+
file: relPath,
|
|
126
|
+
line: index + 1,
|
|
127
|
+
title: 'Hardcoded string in UI',
|
|
128
|
+
message: 'Found hardcoded string in Text().',
|
|
129
|
+
refactor: 'Move to res/values/strings.xml and use stringResource(R.string.id)'
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!hasDomainLayer && ['clean+mvi', 'viper'].includes(arch)) {
|
|
136
|
+
issues.push({ severity: 'warning', file: 'Project Structure', line: 0, title: 'Missing Domain Layer', message: 'Create domain/usecase/ layer.' });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return issues;
|
|
140
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { runArchitectureCheck } from './architecture-checker.js';
|
|
5
|
+
import { runSecurityScan } from './security-scanner.js';
|
|
6
|
+
|
|
7
|
+
export async function runDiffReview(rootDir, targetBranch, config, doSecurity) {
|
|
8
|
+
let changedFiles = [];
|
|
9
|
+
try {
|
|
10
|
+
const output = execSync(`git diff ${targetBranch}...HEAD --name-only`, { cwd: rootDir, encoding: 'utf8' });
|
|
11
|
+
changedFiles = output.split('\n').map(l => l.trim()).filter(l => l.length > 0 && l.endsWith('.kt'));
|
|
12
|
+
} catch (e) {
|
|
13
|
+
throw new Error(`Failed to get git diff vs ${targetBranch}. Ensure it exists locally and project is a git repo.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (changedFiles.length === 0) return { files: 0, issues: [] };
|
|
17
|
+
|
|
18
|
+
// Collect all issues from architecture check and security scan
|
|
19
|
+
// Usually they scan the whole dir, but for this we might need to filter.
|
|
20
|
+
// Wait, the arch checker scans the dir recursively.
|
|
21
|
+
// For diff review, we can just run the scanners recursively and then filter the output by the changed files list.
|
|
22
|
+
|
|
23
|
+
const archIssues = await runArchitectureCheck(rootDir, config);
|
|
24
|
+
const securityIssues = doSecurity ? await runSecurityScan(rootDir) : [];
|
|
25
|
+
|
|
26
|
+
const allIssues = [...archIssues, ...securityIssues];
|
|
27
|
+
|
|
28
|
+
// Filter issues to only include changed files
|
|
29
|
+
// arch/security checkers use relative paths from rootDir
|
|
30
|
+
const filteredIssues = allIssues.filter(issue => {
|
|
31
|
+
// Changed files paths are usually relative to git root.
|
|
32
|
+
// Assuming rootDir is git root.
|
|
33
|
+
return changedFiles.includes(issue.file);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
files: changedFiles.length,
|
|
38
|
+
issues: filteredIssues
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export async function runSecurityScan(rootDir) {
|
|
5
|
+
const issues = [];
|
|
6
|
+
|
|
7
|
+
const ktFiles = [];
|
|
8
|
+
const buildFiles = [];
|
|
9
|
+
|
|
10
|
+
function scanDir(dir) {
|
|
11
|
+
if (!fs.existsSync(dir)) return;
|
|
12
|
+
const files = fs.readdirSync(dir);
|
|
13
|
+
for (const file of files) {
|
|
14
|
+
const fullPath = path.join(dir, file);
|
|
15
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
16
|
+
if (!['build', '.gradle', '.idea', 'node_modules'].includes(file)) {
|
|
17
|
+
scanDir(fullPath);
|
|
18
|
+
}
|
|
19
|
+
} else if (fullPath.endsWith('.kt')) {
|
|
20
|
+
ktFiles.push(fullPath);
|
|
21
|
+
} else if (fullPath.endsWith('.gradle.kts') || fullPath.endsWith('.gradle')) {
|
|
22
|
+
buildFiles.push(fullPath);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
scanDir(rootDir);
|
|
28
|
+
|
|
29
|
+
for (const file of buildFiles) {
|
|
30
|
+
const relPath = path.relative(rootDir, file);
|
|
31
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
32
|
+
if (content.includes('debuggable true') && content.includes('release {')) {
|
|
33
|
+
issues.push({ severity: 'critical', file: relPath, line: 0, title: 'Debug flag in Release', message: 'Release buildType has debuggable true.', refactor: 'Remove debuggable true or set to false.' });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const file of ktFiles) {
|
|
38
|
+
const relPath = path.relative(rootDir, file);
|
|
39
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
40
|
+
const lines = content.split('\n');
|
|
41
|
+
|
|
42
|
+
lines.forEach((line, index) => {
|
|
43
|
+
// Hardcoded API Key/Token
|
|
44
|
+
if (line.match(/(val|const val)\s+[A-Z_]+_KEY\s*=\s*"[^"]+"/) || line.match(/=\s*"Bearer\s+[^"]+"/)) {
|
|
45
|
+
issues.push({
|
|
46
|
+
severity: 'critical', file: relPath, line: index + 1,
|
|
47
|
+
title: 'Hardcoded API Key / Secret',
|
|
48
|
+
message: 'Hardcoded secret detected.',
|
|
49
|
+
refactor: 'Use BuildConfig or BuildConfig field from local.properties/CI environment.'
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// HTTP endpoint
|
|
54
|
+
if (line.match(/baseUrl\s*=\s*"http:\/\//)) {
|
|
55
|
+
issues.push({ severity: 'critical', file: relPath, line: index + 1, title: 'HTTP endpoint (bukan HTTPS)', message: 'Insecure HTTP URL in base URL.', refactor: 'Use https:// instead.' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Unencrypted SharedPreferences
|
|
59
|
+
if (line.includes('getSharedPreferences') && (line.toLowerCase().includes('token') || line.toLowerCase().includes('password'))) {
|
|
60
|
+
issues.push({ severity: 'critical', file: relPath, line: index + 1, title: 'Sensitive Data in SharedPreferences', message: 'Storing sensitive data in regular SharedPreferences.', refactor: 'Use EncryptedSharedPreferences (Security Crypto library).' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Log expose
|
|
64
|
+
if (line.match(/Log\.[deiwv]\([^,]+,\s*[a-zA-Z]*(token|password|secret)[a-zA-Z]*\)/i)) {
|
|
65
|
+
issues.push({ severity: 'warning', file: relPath, line: index + 1, title: 'Log exposes PII/Token', message: 'Logging sensitive variables.', refactor: 'Remove log statement or mask the value.' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// GlobalScope warning
|
|
69
|
+
if (line.includes('GlobalScope.launch')) {
|
|
70
|
+
issues.push({ severity: 'warning', file: relPath, line: index + 1, title: 'GlobalScope leak', message: 'GlobalScope coroutine might outlive the component.', refactor: 'Use viewModelScope, lifecycleScope or custom CoroutineScope.' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// World Readable
|
|
74
|
+
if (line.includes('MODE_WORLD_READABLE')) {
|
|
75
|
+
issues.push({ severity: 'critical', file: relPath, line: index + 1, title: 'World readable file permission', message: 'MODE_WORLD_READABLE is deprecated and insecure.', refactor: 'Use MODE_PRIVATE' });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Check BroadcastReceiver
|
|
80
|
+
if (content.includes('registerReceiver(')) {
|
|
81
|
+
if (!content.includes('unregisterReceiver(')) {
|
|
82
|
+
issues.push({ severity: 'critical', file: relPath, line: 0, title: 'BroadcastReceiver leak', message: 'registerReceiver called but no unregisterReceiver found in the same file.', refactor: 'Call unregisterReceiver in onDestroy() / onStop() or use DisposableEffect in Compose.' });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Bitmap recycle warning
|
|
87
|
+
if (content.includes('Bitmap.create') && !content.includes('.recycle()')) {
|
|
88
|
+
issues.push({ severity: 'warning', file: relPath, line: 0, title: 'Bitmap leak possible', message: 'Bitmap created but no recycle() call found.', refactor: 'Ensure bitmaps are recycled when no longer needed.' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return issues;
|
|
93
|
+
}
|
package/lib/commands/doctor.js
CHANGED
|
@@ -1,69 +1,140 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import { program } from 'commander';
|
|
6
|
+
import { loadConfig, updateConfig } from '../utils/config.js';
|
|
7
|
+
import { runArchitectureCheck } from './doctor/architecture-checker.js';
|
|
8
|
+
import { runSecurityScan } from './doctor/security-scanner.js';
|
|
9
|
+
import { runDiffReview } from './doctor/diff-reviewer.js';
|
|
10
|
+
|
|
11
|
+
export default async function doctorCommand(cmdObj) {
|
|
12
|
+
// Setup flags natively (if called directly or parsed from args)
|
|
13
|
+
// In a robust setup this would be registered in index.js for the commander instance,
|
|
14
|
+
// but we can check raw arguments here if not passed.
|
|
15
|
+
const isSecurity = cmdObj?.security || process.argv.includes('--security') || process.argv.includes('-s');
|
|
16
|
+
const isFix = cmdObj?.fix || process.argv.includes('--fix');
|
|
17
|
+
const reviewBranch = cmdObj?.review || getArgValue('--review');
|
|
18
|
+
const outputFile = cmdObj?.output || getArgValue('--output');
|
|
4
19
|
|
|
5
|
-
export default async function doctorCommand() {
|
|
6
20
|
console.log(chalk.bold.magenta('\n𩺠MAD Pro Doctor - Project Audit'));
|
|
7
|
-
console.log(chalk.gray('Checking your project for architectural health...\n'));
|
|
8
21
|
|
|
9
22
|
const rootDir = process.cwd();
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
}
|
|
23
|
+
let config = loadConfig(rootDir) || {};
|
|
24
|
+
|
|
25
|
+
// Interactive Architecture Template Selection on first run
|
|
26
|
+
if (!config.architecture) {
|
|
27
|
+
console.log(chalk.cyan('It looks like your project does not have an architecture defined yet.'));
|
|
28
|
+
const archRes = await inquirer.prompt([{
|
|
29
|
+
type: 'list',
|
|
30
|
+
name: 'architecture',
|
|
31
|
+
message: 'Select your project architecture (for accurate anti-pattern scanning):',
|
|
32
|
+
choices: [
|
|
33
|
+
{ name: 'MVVM (ViewModel + StateFlow/LiveData)', value: 'mvvm' },
|
|
34
|
+
{ name: 'MVI (Intent ā State ā Effect)', value: 'mvi' },
|
|
35
|
+
{ name: 'Clean + MVI (Use Case + MVI in ViewModel)', value: 'clean+mvi' },
|
|
36
|
+
{ name: 'MVP (Presenter + View interface)', value: 'mvp' },
|
|
37
|
+
{ name: 'VIPER (View, Interactor, Presenter, Entity, Router)', value: 'viper' },
|
|
38
|
+
{ name: 'Custom (Skip strict architecture checks)', value: 'custom' }
|
|
39
|
+
]
|
|
40
|
+
}]);
|
|
41
|
+
|
|
42
|
+
const diRes = await inquirer.prompt([{
|
|
43
|
+
type: 'list',
|
|
44
|
+
name: 'di',
|
|
45
|
+
message: 'Select your Dependency Injection framework:',
|
|
46
|
+
choices: [
|
|
47
|
+
{ name: 'Hilt', value: 'hilt' },
|
|
48
|
+
{ name: 'Koin', value: 'koin' },
|
|
49
|
+
{ name: 'None/Manual', value: 'none' }
|
|
50
|
+
]
|
|
51
|
+
}]);
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
config = updateConfig({ architecture: archRes.architecture, di: diRes.di }, rootDir);
|
|
54
|
+
console.log(chalk.green(`\nā Configured for ${archRes.architecture.toUpperCase()} + ${diRes.di}. (Saved in .mad-pro.json)\n`));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log(chalk.gray(`Running checks for Architecture: [${config.architecture.toUpperCase()}] and DI: [${config.di}]...\n`));
|
|
58
|
+
|
|
59
|
+
let issues = [];
|
|
60
|
+
let fileCount = 0;
|
|
61
|
+
|
|
62
|
+
if (reviewBranch) {
|
|
63
|
+
console.log(chalk.cyan(`š Diff Code Review against branch: ${reviewBranch}`));
|
|
64
|
+
try {
|
|
65
|
+
const reviewResult = await runDiffReview(rootDir, reviewBranch, config, isSecurity);
|
|
66
|
+
issues = reviewResult.issues;
|
|
67
|
+
fileCount = reviewResult.files;
|
|
68
|
+
console.log(chalk.gray(`Scanned ${fileCount} changed files.\n`));
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error(chalk.red(e.message));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
issues = await runArchitectureCheck(rootDir, config);
|
|
75
|
+
if (isSecurity) {
|
|
76
|
+
console.log(chalk.cyan(`š Running Security & Memory Leak Scanners...`));
|
|
77
|
+
const secIssues = await runSecurityScan(rootDir);
|
|
78
|
+
issues = [...issues, ...secIssues];
|
|
49
79
|
}
|
|
50
80
|
}
|
|
51
81
|
|
|
52
|
-
|
|
82
|
+
// Formatting and outputting issues
|
|
83
|
+
if (issues.length === 0) {
|
|
53
84
|
console.log(chalk.bold.green('\nš Your project looks 100% MAD-compliant!'));
|
|
54
85
|
} else {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
86
|
+
let criticals = 0;
|
|
87
|
+
let warnings = 0;
|
|
88
|
+
|
|
89
|
+
issues.forEach(issue => {
|
|
90
|
+
const prefix = issue.severity === 'critical' ? chalk.red('ā [CRITICAL]') : chalk.yellow('ā ļø [WARNING]');
|
|
91
|
+
if (issue.severity === 'critical') criticals++; else warnings++;
|
|
92
|
+
|
|
93
|
+
console.log(`${prefix} ${issue.title}`);
|
|
94
|
+
console.log(chalk.gray(` š File: ${issue.file}:${issue.line}`));
|
|
95
|
+
if (issue.message) console.log(chalk.white(` ā¹ļø ${issue.message}`));
|
|
96
|
+
if (issue.refactor) {
|
|
97
|
+
console.log(chalk.cyan(` š” Refactor:`));
|
|
98
|
+
const refactorLines = issue.refactor.split('\\n');
|
|
99
|
+
refactorLines.forEach(l => console.log(chalk.cyan(` ${l}`)));
|
|
100
|
+
}
|
|
101
|
+
console.log('');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
console.log(chalk.bold('āāāāāāāāāāāāāāāāāāāāāā'));
|
|
105
|
+
console.log(`${chalk.red(`ā ${criticals} Critical`)} | ${chalk.yellow(`ā ļø ${warnings} Warning`)}`);
|
|
106
|
+
console.log(chalk.bold.yellow(`\nā ļø Found ${issues.length} items to improve.`));
|
|
107
|
+
if (!isFix) console.log(chalk.white(`Run with --fix to scaffold recommended fixes (experimental).`));
|
|
58
108
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
109
|
+
// Simple auto-fix demo based on the flag
|
|
110
|
+
if (isFix) {
|
|
111
|
+
console.log(chalk.green('\nš ļø Scaffolding fixes (Simulated)...'));
|
|
112
|
+
issues.filter(i => i.severity === 'critical' && i.title.includes('Business logic bypass')).forEach(issue => {
|
|
113
|
+
const expectedFile = 'app/src/main/java/com/example/domain/usecase/GeneratedUseCase.kt';
|
|
114
|
+
console.log(` ${chalk.green('ā')} Scaffolded: ${expectedFile}`);
|
|
115
|
+
// In real implementation, this would actually generate the file based on the refactor string.
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (outputFile) {
|
|
120
|
+
let mdOutput = `# MAD Pro Code Review\n\n`;
|
|
121
|
+
if (reviewBranch) mdOutput += `**Target Branch:** \`${reviewBranch}\`\n**Changed Files:** ${fileCount}\n\n`;
|
|
122
|
+
|
|
123
|
+
mdOutput += `## Issues Found: ${issues.length}\n`;
|
|
124
|
+
issues.forEach(issue => {
|
|
125
|
+
mdOutput += `### ${issue.severity === 'critical' ? 'ā' : 'ā ļø'} ${issue.title}\n`;
|
|
126
|
+
mdOutput += `- **File:** \`${issue.file}:${issue.line}\`\n`;
|
|
127
|
+
mdOutput += `- **Message:** ${issue.message}\n`;
|
|
128
|
+
if (issue.refactor) mdOutput += `- **Refactor:** \n\`\`\`kotlin\n${issue.refactor}\n\`\`\`\n\n`;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
fs.writeFileSync(path.join(rootDir, outputFile), mdOutput);
|
|
132
|
+
console.log(chalk.green(`\nReport saved to ${outputFile}`));
|
|
66
133
|
}
|
|
67
134
|
}
|
|
68
|
-
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getArgValue(flag) {
|
|
138
|
+
const index = process.argv.indexOf(flag);
|
|
139
|
+
return index !== -1 ? process.argv[index + 1] : null;
|
|
69
140
|
}
|
package/lib/commands/init.js
CHANGED
|
@@ -8,7 +8,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
8
8
|
const __dirname = path.dirname(__filename);
|
|
9
9
|
|
|
10
10
|
export default async function initCommand(options) {
|
|
11
|
-
console.log(chalk.bold.magenta('\nš MAD Pro CLI - Skill Wizard v1.
|
|
11
|
+
console.log(chalk.bold.magenta('\nš MAD Pro CLI - Skill Wizard v1.3.0'));
|
|
12
12
|
console.log(chalk.gray('Setting up your Android project with architecture excellence...\n'));
|
|
13
13
|
|
|
14
14
|
// 1. Identify Source & Target
|
|
@@ -47,7 +47,8 @@ export default async function initCommand(options) {
|
|
|
47
47
|
{ name: 'AI & Emerging (Gemini, LLM UI, ARCore)', value: 'ai', checked: false },
|
|
48
48
|
{ name: 'Industry Verticals (Banking, E-commerce, etc.)', value: 'industry', checked: false },
|
|
49
49
|
{ name: 'Monetization & Play (Billing, Subs)', value: 'monetization', checked: false },
|
|
50
|
-
{ name: 'Engineering Excellence (Modularization, CI/CD)', value: 'engineering', checked: false }
|
|
50
|
+
{ name: 'Engineering Excellence (Modularization, CI/CD)', value: 'engineering', checked: false },
|
|
51
|
+
{ name: 'Design Tokens (Colors, Typography, Spacing)', value: 'tokens', checked: true }
|
|
51
52
|
]
|
|
52
53
|
}
|
|
53
54
|
]);
|
|
@@ -72,10 +73,14 @@ export default async function initCommand(options) {
|
|
|
72
73
|
// 4. Create Target Directories
|
|
73
74
|
const targetRefDir = path.join(rootDir, 'references');
|
|
74
75
|
const targetIndustryDir = path.join(targetRefDir, 'industry');
|
|
76
|
+
const targetTokenDir = path.join(targetRefDir, 'design-tokens');
|
|
75
77
|
await fs.ensureDir(targetRefDir);
|
|
76
78
|
if (selectedIndustries.length > 0 || answers.categories.includes('monetization')) {
|
|
77
79
|
await fs.ensureDir(targetIndustryDir);
|
|
78
80
|
}
|
|
81
|
+
if (answers.categories.includes('tokens')) {
|
|
82
|
+
await fs.ensureDir(targetTokenDir);
|
|
83
|
+
}
|
|
79
84
|
|
|
80
85
|
// 5. Build Mapping & Copy Files
|
|
81
86
|
const skillMapping = {
|
|
@@ -92,7 +97,8 @@ export default async function initCommand(options) {
|
|
|
92
97
|
'engineering': [
|
|
93
98
|
'modularization.md', 'architecture_di.md', 'concurrency.md', 'performance.md', 'testing.md',
|
|
94
99
|
'design_systems.md', 'observability.md', 'ci_cd.md'
|
|
95
|
-
]
|
|
100
|
+
],
|
|
101
|
+
'tokens': ['design-tokens/color-tokens.md', 'design-tokens/typography-tokens.md', 'design-tokens/spacing-tokens.md']
|
|
96
102
|
};
|
|
97
103
|
|
|
98
104
|
let filesToCopy = [];
|
|
@@ -115,7 +121,7 @@ export default async function initCommand(options) {
|
|
|
115
121
|
|
|
116
122
|
for (const file of filesToCopy) {
|
|
117
123
|
const src = path.join(sourceDir, file);
|
|
118
|
-
const destName = file.includes('industry/') ? file.replace('industry/', 'industry/') : file;
|
|
124
|
+
const destName = file.includes('industry/') ? file.replace('industry/', 'industry/') : (file.includes('design-tokens/') ? file.replace('design-tokens/', 'design-tokens/') : file);
|
|
119
125
|
const dest = path.join(targetRefDir, destName);
|
|
120
126
|
|
|
121
127
|
if (fs.existsSync(src)) {
|
package/lib/commands/prompt.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import { loadConfig } from '../utils/config.js';
|
|
4
6
|
|
|
5
|
-
export default async function promptCommand() {
|
|
7
|
+
export default async function promptCommand(cmdObj) {
|
|
6
8
|
const rootDir = process.cwd();
|
|
7
9
|
const targetRefDir = path.join(rootDir, 'references');
|
|
10
|
+
const config = loadConfig(rootDir) || { architecture: 'mvvm', di: 'hilt' };
|
|
8
11
|
|
|
9
|
-
console.log(chalk.bold.magenta('\nš¤ MAD Pro - Agent Instruction Generator'));
|
|
12
|
+
console.log(chalk.bold.magenta('\nš¤ MAD Pro - Contextual Agent Instruction Generator'));
|
|
10
13
|
|
|
11
14
|
if (!fs.existsSync(targetRefDir)) {
|
|
12
15
|
console.log(chalk.yellow('\nNo skills found. Run `mad-pro init` first to generate a tailored prompt.'));
|
|
@@ -16,43 +19,110 @@ export default async function promptCommand() {
|
|
|
16
19
|
// Identify installed skills
|
|
17
20
|
const getFiles = (dir) => {
|
|
18
21
|
let results = [];
|
|
22
|
+
if (!fs.existsSync(dir)) return results;
|
|
19
23
|
const list = fs.readdirSync(dir);
|
|
20
24
|
list.forEach(file => {
|
|
21
25
|
const fullPath = path.join(dir, file);
|
|
22
26
|
if (fs.statSync(fullPath).isDirectory()) {
|
|
23
|
-
|
|
27
|
+
if (file !== 'design-tokens') {
|
|
28
|
+
results = results.concat(getFiles(fullPath));
|
|
29
|
+
}
|
|
24
30
|
} else if (file.endsWith('.md')) {
|
|
25
|
-
|
|
31
|
+
if (!fullPath.includes('design-tokens')) {
|
|
32
|
+
results.push(path.basename(file, '.md').replace(/_/g, ' ').toUpperCase());
|
|
33
|
+
}
|
|
26
34
|
}
|
|
27
35
|
});
|
|
28
36
|
return results;
|
|
29
37
|
};
|
|
30
38
|
|
|
31
39
|
const skills = getFiles(targetRefDir);
|
|
40
|
+
const industryDir = path.join(targetRefDir, 'industry');
|
|
41
|
+
const industrySkills = getFiles(industryDir);
|
|
32
42
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
43
|
+
const archRules = {
|
|
44
|
+
'mvvm': 'Follow MVVM architecture: UI -> ViewModel -> (UseCase) -> Repository -> Data Source.',
|
|
45
|
+
'mvi': 'Follow MVI architecture: UI -> Intent -> ViewModel -> StateFlow -> UI.',
|
|
46
|
+
'mvp': 'Follow MVP architecture: View <-> Presenter -> Repository.',
|
|
47
|
+
'clean+mvi': 'Follow Clean + MVI: UI -> Intent -> ViewModel -> UseCase -> Repository.',
|
|
48
|
+
'viper': 'Follow VIPER architecture: View, Interactor, Presenter, Entity, Router.',
|
|
49
|
+
'custom': 'Follow the custom architecture patterns established in the project.'
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const diRules = {
|
|
53
|
+
'hilt': 'Use Hilt for Dependency Injection (@HiltViewModel, @Inject).',
|
|
54
|
+
'koin': 'Use Koin for Dependency Injection (constructor injection, get()).',
|
|
55
|
+
'none': 'Use manual Dependency Injection.'
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const hasTokens = fs.existsSync(path.join(targetRefDir, 'design-tokens'));
|
|
59
|
+
|
|
60
|
+
const formatRes = await inquirer.prompt([{
|
|
61
|
+
type: 'list',
|
|
62
|
+
name: 'format',
|
|
63
|
+
message: 'Select target format:',
|
|
64
|
+
choices: [
|
|
65
|
+
{ name: '.cursorrules (Cursor)', value: 'cursor' },
|
|
66
|
+
{ name: '.windsurfrules (Windsurf)', value: 'windsurf' },
|
|
67
|
+
{ name: 'System Prompt (Clipboard / Generic)', value: 'generic' }
|
|
68
|
+
]
|
|
69
|
+
}]);
|
|
70
|
+
|
|
71
|
+
const promptBuilder = [];
|
|
72
|
+
|
|
73
|
+
if (formatRes.format !== 'generic') {
|
|
74
|
+
promptBuilder.push(`You are an expert Android Developer using the MAD Pro v1.3.0 framework.`);
|
|
75
|
+
promptBuilder.push(`Your goal is to build highly scalable, testable, and maintainable Android apps.\n`);
|
|
76
|
+
} else {
|
|
77
|
+
promptBuilder.push(`# AI AGENT INSTRUCTIONS (MAD PRO)`);
|
|
78
|
+
promptBuilder.push(`System Role: Expert Android Developer\n`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
promptBuilder.push(`## PROJECT CONTEXT`);
|
|
82
|
+
promptBuilder.push(`This project uses the following architecture: **${config.architecture.toUpperCase()}** with **${config.di.toUpperCase()}**.`);
|
|
83
|
+
if (skills.length > 0) {
|
|
84
|
+
promptBuilder.push(`Installed architectural patterns in /references:`);
|
|
85
|
+
skills.slice(0, 15).forEach(s => promptBuilder.push(`- ${s}`));
|
|
86
|
+
if (skills.length > 15) promptBuilder.push(`- ...and ${skills.length - 15} more.`);
|
|
87
|
+
}
|
|
88
|
+
promptBuilder.push('');
|
|
89
|
+
|
|
90
|
+
promptBuilder.push(`## CORE RULES`);
|
|
91
|
+
promptBuilder.push(`1. ALWAYS check the matching file in /references before implementing a new feature.`);
|
|
92
|
+
promptBuilder.push(`2. ${archRules[config.architecture] || archRules['mvvm']}`);
|
|
93
|
+
promptBuilder.push(`3. ${diRules[config.di] || diRules['hilt']}`);
|
|
94
|
+
promptBuilder.push(`4. Use Jetpack Compose with Unidirectional Data Flow (UDF).`);
|
|
95
|
+
|
|
96
|
+
if (industrySkills.length > 0) {
|
|
97
|
+
promptBuilder.push(`5. Adhere strictly to the industry best practices found in /references/industry/ for: ${industrySkills.join(', ')}.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
promptBuilder.push(`\n## OUTPUT STYLE`);
|
|
101
|
+
promptBuilder.push(`- Provide production-ready Kotlin code.`);
|
|
102
|
+
if (hasTokens) {
|
|
103
|
+
promptBuilder.push(`- STYLING: You MUST use the design tokens defined in /references/design-tokens/ (colors, typography, spacing). Do not hardcode dimensions or generic colors.`);
|
|
104
|
+
}
|
|
105
|
+
promptBuilder.push(`- Ensure all UI components follow the defined Material 3 theme. Avoid outdated XML layouts unless required.`);
|
|
106
|
+
|
|
107
|
+
const finalPrompt = promptBuilder.join('\n');
|
|
108
|
+
|
|
109
|
+
console.log(chalk.white(`\n--- COPY THIS TO YOUR ${formatRes.format === 'generic' ? 'SYSTEM PROMPT' : formatRes.format === 'cursor' ? '.cursorrules' : '.windsurfrules'} ---\n`));
|
|
110
|
+
console.log(chalk.cyan(finalPrompt));
|
|
57
111
|
console.log(chalk.white('------------------------------------------------------------------\n'));
|
|
112
|
+
|
|
113
|
+
// Optionally auto-write to file if it's cursor or windsurf
|
|
114
|
+
if (formatRes.format === 'cursor' || formatRes.format === 'windsurf') {
|
|
115
|
+
const fileName = formatRes.format === 'cursor' ? '.cursorrules' : '.windsurfrules';
|
|
116
|
+
const fileRes = await inquirer.prompt([{
|
|
117
|
+
type: 'confirm',
|
|
118
|
+
name: 'write',
|
|
119
|
+
message: `Do you want to write this to ${fileName} in the current directory?`,
|
|
120
|
+
default: true
|
|
121
|
+
}]);
|
|
122
|
+
|
|
123
|
+
if (fileRes.write) {
|
|
124
|
+
fs.writeFileSync(path.join(rootDir, fileName), finalPrompt);
|
|
125
|
+
console.log(chalk.green(`ā Saved to ${fileName}`));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
58
128
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILE = '.mad-pro.json';
|
|
5
|
+
|
|
6
|
+
export function getConfigPath(rootDir = process.cwd()) {
|
|
7
|
+
return path.join(rootDir, CONFIG_FILE);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadConfig(rootDir = process.cwd()) {
|
|
11
|
+
const configPath = getConfigPath(rootDir);
|
|
12
|
+
if (fs.existsSync(configPath)) {
|
|
13
|
+
try {
|
|
14
|
+
return fs.readJsonSync(configPath);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveConfig(config, rootDir = process.cwd()) {
|
|
23
|
+
const configPath = getConfigPath(rootDir);
|
|
24
|
+
fs.writeJsonSync(configPath, config, { spaces: 2 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function updateConfig(updates, rootDir = process.cwd()) {
|
|
28
|
+
const currentConfig = loadConfig(rootDir) || {};
|
|
29
|
+
const newConfig = { ...currentConfig, ...updates };
|
|
30
|
+
saveConfig(newConfig, rootDir);
|
|
31
|
+
return newConfig;
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mad-pro-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -27,5 +27,8 @@
|
|
|
27
27
|
"commander": "^14.0.3",
|
|
28
28
|
"fs-extra": "^11.3.4",
|
|
29
29
|
"inquirer": "^12.4.2"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
30
33
|
}
|
|
31
34
|
}
|