ripp-cli 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/README.md +292 -0
- package/index.js +1350 -0
- package/lib/ai-provider.js +354 -0
- package/lib/analyzer.js +394 -0
- package/lib/build.js +338 -0
- package/lib/config.js +277 -0
- package/lib/confirmation.js +183 -0
- package/lib/discovery.js +119 -0
- package/lib/evidence.js +368 -0
- package/lib/init.js +488 -0
- package/lib/linter.js +309 -0
- package/lib/migrate.js +203 -0
- package/lib/packager.js +374 -0
- package/package.json +40 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const yaml = require('js-yaml');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RIPP Intent Confirmation
|
|
8
|
+
* Human confirmation workflow for candidate intent
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Confirm candidates interactively
|
|
13
|
+
*/
|
|
14
|
+
async function confirmIntent(cwd, options = {}) {
|
|
15
|
+
const candidatesPath = path.join(cwd, '.ripp', 'intent.candidates.yaml');
|
|
16
|
+
|
|
17
|
+
if (!fs.existsSync(candidatesPath)) {
|
|
18
|
+
throw new Error('No candidate intent found. Run "ripp discover" first.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const candidatesContent = fs.readFileSync(candidatesPath, 'utf8');
|
|
22
|
+
const candidates = yaml.load(candidatesContent);
|
|
23
|
+
|
|
24
|
+
if (!candidates.candidates || candidates.candidates.length === 0) {
|
|
25
|
+
throw new Error('No candidates found in intent.candidates.yaml');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Interactive mode
|
|
29
|
+
if (options.interactive !== false) {
|
|
30
|
+
return await interactiveConfirm(cwd, candidates);
|
|
31
|
+
} else {
|
|
32
|
+
// Generate markdown checklist mode
|
|
33
|
+
return await generateChecklistConfirm(cwd, candidates);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Interactive confirmation via terminal
|
|
39
|
+
*/
|
|
40
|
+
async function interactiveConfirm(cwd, candidates) {
|
|
41
|
+
const rl = readline.createInterface({
|
|
42
|
+
input: process.stdin,
|
|
43
|
+
output: process.stdout
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const confirmed = [];
|
|
47
|
+
const rejected = [];
|
|
48
|
+
|
|
49
|
+
console.log('\n=== RIPP Intent Confirmation ===');
|
|
50
|
+
console.log(`Found ${candidates.candidates.length} candidate(s)\n`);
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < candidates.candidates.length; i++) {
|
|
53
|
+
const candidate = candidates.candidates[i];
|
|
54
|
+
|
|
55
|
+
console.log(`\n--- Candidate ${i + 1}/${candidates.candidates.length} ---`);
|
|
56
|
+
console.log(`Section: ${candidate.section || 'unknown'}`);
|
|
57
|
+
console.log(`Confidence: ${(candidate.confidence * 100).toFixed(1)}%`);
|
|
58
|
+
console.log(`Evidence: ${candidate.evidence.length} reference(s)`);
|
|
59
|
+
console.log('\nContent:');
|
|
60
|
+
console.log(yaml.dump(candidate.content, { indent: 2 }));
|
|
61
|
+
|
|
62
|
+
const answer = await question(rl, '\nAccept this candidate? (y/n/e to edit/s to skip): ');
|
|
63
|
+
|
|
64
|
+
if (answer.toLowerCase() === 'y') {
|
|
65
|
+
confirmed.push({
|
|
66
|
+
section: candidate.section,
|
|
67
|
+
source: 'confirmed',
|
|
68
|
+
confirmed_at: new Date().toISOString(),
|
|
69
|
+
confirmed_by: options.user || 'unknown',
|
|
70
|
+
original_confidence: candidate.confidence,
|
|
71
|
+
evidence: candidate.evidence,
|
|
72
|
+
content: candidate.content
|
|
73
|
+
});
|
|
74
|
+
console.log('✓ Accepted');
|
|
75
|
+
} else if (answer.toLowerCase() === 'e') {
|
|
76
|
+
console.log('Manual editing not yet supported. Skipping...');
|
|
77
|
+
// TODO: Open editor for manual changes
|
|
78
|
+
} else if (answer.toLowerCase() === 's') {
|
|
79
|
+
console.log('⊘ Skipped');
|
|
80
|
+
} else {
|
|
81
|
+
rejected.push({
|
|
82
|
+
section: candidate.section,
|
|
83
|
+
original_content: candidate.content,
|
|
84
|
+
original_confidence: candidate.confidence,
|
|
85
|
+
rejected_at: new Date().toISOString(),
|
|
86
|
+
rejected_by: options.user || 'unknown'
|
|
87
|
+
});
|
|
88
|
+
console.log('✗ Rejected');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
rl.close();
|
|
93
|
+
|
|
94
|
+
// Save confirmed intent
|
|
95
|
+
const confirmedData = {
|
|
96
|
+
version: '1.0',
|
|
97
|
+
confirmed
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const confirmedPath = path.join(cwd, '.ripp', 'intent.confirmed.yaml');
|
|
101
|
+
fs.writeFileSync(confirmedPath, yaml.dump(confirmedData, { indent: 2 }), 'utf8');
|
|
102
|
+
|
|
103
|
+
// Save rejected intent (optional)
|
|
104
|
+
if (rejected.length > 0) {
|
|
105
|
+
const rejectedData = {
|
|
106
|
+
version: '1.0',
|
|
107
|
+
rejected
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const rejectedPath = path.join(cwd, '.ripp', 'intent.rejected.yaml');
|
|
111
|
+
fs.writeFileSync(rejectedPath, yaml.dump(rejectedData, { indent: 2 }), 'utf8');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
confirmedPath,
|
|
116
|
+
confirmedCount: confirmed.length,
|
|
117
|
+
rejectedCount: rejected.length
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Generate markdown checklist for manual confirmation
|
|
123
|
+
*/
|
|
124
|
+
async function generateChecklistConfirm(cwd, candidates) {
|
|
125
|
+
const checklistPath = path.join(cwd, '.ripp', 'intent.checklist.md');
|
|
126
|
+
|
|
127
|
+
let markdown = '# RIPP Intent Confirmation Checklist\n\n';
|
|
128
|
+
markdown += `Generated: ${new Date().toISOString()}\n\n`;
|
|
129
|
+
markdown += `Total Candidates: ${candidates.candidates.length}\n\n`;
|
|
130
|
+
markdown += '## Instructions\n\n';
|
|
131
|
+
markdown += '1. Review each candidate below\n';
|
|
132
|
+
markdown += '2. Check the box [ ] if you accept the candidate (change to [x])\n';
|
|
133
|
+
markdown += '3. Edit the content as needed\n';
|
|
134
|
+
markdown +=
|
|
135
|
+
'4. Save this file and run `ripp build --from-checklist` to compile confirmed intent\n\n';
|
|
136
|
+
markdown += '---\n\n';
|
|
137
|
+
|
|
138
|
+
candidates.candidates.forEach((candidate, index) => {
|
|
139
|
+
markdown += `## Candidate ${index + 1}: ${candidate.section || 'unknown'}\n\n`;
|
|
140
|
+
markdown += `- **Confidence**: ${(candidate.confidence * 100).toFixed(1)}%\n`;
|
|
141
|
+
markdown += `- **Evidence**: ${candidate.evidence.length} reference(s)\n\n`;
|
|
142
|
+
|
|
143
|
+
markdown += '### Accept?\n\n';
|
|
144
|
+
markdown += '- [ ] Accept this candidate\n\n';
|
|
145
|
+
|
|
146
|
+
markdown += '### Content\n\n';
|
|
147
|
+
markdown += '```yaml\n';
|
|
148
|
+
markdown += yaml.dump(candidate.content, { indent: 2 });
|
|
149
|
+
markdown += '```\n\n';
|
|
150
|
+
|
|
151
|
+
markdown += '### Evidence References\n\n';
|
|
152
|
+
candidate.evidence.forEach(ev => {
|
|
153
|
+
markdown += `- \`${ev.file}:${ev.line}\`\n`;
|
|
154
|
+
if (ev.snippet) {
|
|
155
|
+
markdown += ` \`\`\`\n ${ev.snippet}\n \`\`\`\n`;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
markdown += '\n---\n\n';
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
fs.writeFileSync(checklistPath, markdown, 'utf8');
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
checklistPath,
|
|
166
|
+
totalCandidates: candidates.candidates.length
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Helper to ask questions in readline
|
|
172
|
+
*/
|
|
173
|
+
function question(rl, prompt) {
|
|
174
|
+
return new Promise(resolve => {
|
|
175
|
+
rl.question(prompt, resolve);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
confirmIntent,
|
|
181
|
+
interactiveConfirm,
|
|
182
|
+
generateChecklistConfirm
|
|
183
|
+
};
|
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const yaml = require('js-yaml');
|
|
4
|
+
const { createProvider } = require('./ai-provider');
|
|
5
|
+
const { loadConfig, checkAiEnabled } = require('./config');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* RIPP Intent Discovery
|
|
9
|
+
* AI-assisted candidate intent inference from evidence packs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Discover candidate intent from evidence pack
|
|
14
|
+
*/
|
|
15
|
+
async function discoverIntent(cwd, options = {}) {
|
|
16
|
+
// Load configuration
|
|
17
|
+
const config = loadConfig(cwd);
|
|
18
|
+
|
|
19
|
+
// Check if AI is enabled
|
|
20
|
+
const aiCheck = checkAiEnabled(config);
|
|
21
|
+
if (!aiCheck.enabled) {
|
|
22
|
+
throw new Error(`AI is not enabled: ${aiCheck.reason}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Load evidence pack
|
|
26
|
+
const evidencePath = path.join(cwd, '.ripp', 'evidence', 'evidence.index.json');
|
|
27
|
+
if (!fs.existsSync(evidencePath)) {
|
|
28
|
+
throw new Error('Evidence pack not found. Run "ripp evidence build" first.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const evidencePack = JSON.parse(fs.readFileSync(evidencePath, 'utf8'));
|
|
32
|
+
|
|
33
|
+
// Create AI provider
|
|
34
|
+
const provider = createProvider(config.ai);
|
|
35
|
+
|
|
36
|
+
if (!provider.isConfigured()) {
|
|
37
|
+
throw new Error('AI provider is not properly configured. Check environment variables.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Infer intent
|
|
41
|
+
const targetLevel = options.targetLevel || 1;
|
|
42
|
+
const candidates = await provider.inferIntent(evidencePack, {
|
|
43
|
+
targetLevel,
|
|
44
|
+
minConfidence: config.discovery.minConfidence
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Filter by minimum confidence if configured
|
|
48
|
+
if (config.discovery.minConfidence > 0) {
|
|
49
|
+
candidates.candidates = candidates.candidates.filter(
|
|
50
|
+
c => c.confidence >= config.discovery.minConfidence
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Write candidates to file
|
|
55
|
+
const candidatesPath = path.join(cwd, '.ripp', 'intent.candidates.yaml');
|
|
56
|
+
const yamlContent = yaml.dump(candidates, { indent: 2, lineWidth: 100 });
|
|
57
|
+
fs.writeFileSync(candidatesPath, yamlContent, 'utf8');
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
candidates,
|
|
61
|
+
candidatesPath,
|
|
62
|
+
totalCandidates: candidates.candidates.length
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate candidate intent structure
|
|
68
|
+
*/
|
|
69
|
+
function validateCandidates(candidates) {
|
|
70
|
+
const errors = [];
|
|
71
|
+
|
|
72
|
+
if (!candidates.version || candidates.version !== '1.0') {
|
|
73
|
+
errors.push('Missing or invalid version field');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!candidates.created) {
|
|
77
|
+
errors.push('Missing created timestamp');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!Array.isArray(candidates.candidates)) {
|
|
81
|
+
errors.push('candidates must be an array');
|
|
82
|
+
return errors; // Can't continue validation
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
candidates.candidates.forEach((candidate, index) => {
|
|
86
|
+
const prefix = `Candidate ${index + 1}`;
|
|
87
|
+
|
|
88
|
+
if (candidate.source !== 'inferred') {
|
|
89
|
+
errors.push(`${prefix}: source must be "inferred"`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
typeof candidate.confidence !== 'number' ||
|
|
94
|
+
candidate.confidence < 0 ||
|
|
95
|
+
candidate.confidence > 1
|
|
96
|
+
) {
|
|
97
|
+
errors.push(`${prefix}: confidence must be between 0.0 and 1.0`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!Array.isArray(candidate.evidence) || candidate.evidence.length === 0) {
|
|
101
|
+
errors.push(`${prefix}: must have at least one evidence reference`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (candidate.requires_human_confirmation !== true) {
|
|
105
|
+
errors.push(`${prefix}: requires_human_confirmation must be true`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!candidate.content) {
|
|
109
|
+
errors.push(`${prefix}: missing content field`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return errors;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
discoverIntent,
|
|
118
|
+
validateCandidates
|
|
119
|
+
};
|
package/lib/evidence.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { glob } = require('glob');
|
|
5
|
+
const yaml = require('js-yaml');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* RIPP Evidence Pack Builder
|
|
9
|
+
* Scans repository and extracts high-signal facts for intent discovery
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Secret patterns for redaction (best-effort)
|
|
13
|
+
// More conservative patterns to avoid false positives
|
|
14
|
+
const SECRET_PATTERNS = [
|
|
15
|
+
/api[_-]?key[s]?\s*[:=]\s*['"]([^'"]+)['"]/gi,
|
|
16
|
+
/secret[_-]?key[s]?\s*[:=]\s*['"]([^'"]+)['"]/gi,
|
|
17
|
+
/password[s]?\s*[:=]\s*['"]([^'"]+)['"]/gi,
|
|
18
|
+
/token[s]?\s*[:=]\s*['"]([^'"]+)['"]/gi,
|
|
19
|
+
/bearer\s+([a-zA-Z0-9_\-\.]{20,})/gi, // Bearer tokens
|
|
20
|
+
/sk-[a-zA-Z0-9]{32,}/gi, // OpenAI-style keys
|
|
21
|
+
/ghp_[a-zA-Z0-9]{36,}/gi // GitHub tokens
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build evidence pack from repository
|
|
26
|
+
*/
|
|
27
|
+
async function buildEvidencePack(cwd, config) {
|
|
28
|
+
const evidenceDir = path.join(cwd, '.ripp', 'evidence');
|
|
29
|
+
|
|
30
|
+
// Create evidence directory
|
|
31
|
+
if (!fs.existsSync(evidenceDir)) {
|
|
32
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Scan files
|
|
36
|
+
const { files, excludedCount } = await scanFiles(cwd, config.evidencePack);
|
|
37
|
+
|
|
38
|
+
// Extract evidence from files
|
|
39
|
+
const evidence = await extractEvidence(files, cwd);
|
|
40
|
+
|
|
41
|
+
// Build index
|
|
42
|
+
const index = {
|
|
43
|
+
version: '1.0',
|
|
44
|
+
created: new Date().toISOString(),
|
|
45
|
+
stats: {
|
|
46
|
+
totalFiles: files.length + excludedCount,
|
|
47
|
+
totalSize: files.reduce((sum, f) => sum + f.size, 0),
|
|
48
|
+
includedFiles: files.length,
|
|
49
|
+
excludedFiles: excludedCount
|
|
50
|
+
},
|
|
51
|
+
includePatterns: config.evidencePack.includeGlobs,
|
|
52
|
+
excludePatterns: config.evidencePack.excludeGlobs,
|
|
53
|
+
files: files.map(f => ({
|
|
54
|
+
path: f.path,
|
|
55
|
+
hash: f.hash,
|
|
56
|
+
size: f.size,
|
|
57
|
+
type: f.type
|
|
58
|
+
})),
|
|
59
|
+
evidence
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Write index
|
|
63
|
+
const indexPath = path.join(evidenceDir, 'evidence.index.json');
|
|
64
|
+
fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf8');
|
|
65
|
+
|
|
66
|
+
// Copy relevant files to evidence directory (optional, for provenance)
|
|
67
|
+
// For now, we just keep the index with hashes
|
|
68
|
+
|
|
69
|
+
return { index, evidenceDir, indexPath };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Scan files matching include/exclude patterns
|
|
74
|
+
*/
|
|
75
|
+
async function scanFiles(cwd, evidenceConfig) {
|
|
76
|
+
const files = [];
|
|
77
|
+
const { includeGlobs, excludeGlobs, maxFileSize } = evidenceConfig;
|
|
78
|
+
|
|
79
|
+
// Build combined pattern
|
|
80
|
+
const patterns = includeGlobs.map(pattern => path.join(cwd, pattern));
|
|
81
|
+
|
|
82
|
+
for (const pattern of patterns) {
|
|
83
|
+
const matches = await glob(pattern, {
|
|
84
|
+
ignore: excludeGlobs.map(ex => path.join(cwd, ex)),
|
|
85
|
+
nodir: true,
|
|
86
|
+
absolute: true
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
for (const filePath of matches) {
|
|
90
|
+
try {
|
|
91
|
+
const stats = fs.statSync(filePath);
|
|
92
|
+
|
|
93
|
+
// Skip files that are too large
|
|
94
|
+
if (stats.size > maxFileSize) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
99
|
+
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
100
|
+
const relativePath = path.relative(cwd, filePath);
|
|
101
|
+
|
|
102
|
+
files.push({
|
|
103
|
+
path: relativePath,
|
|
104
|
+
absolutePath: filePath,
|
|
105
|
+
hash,
|
|
106
|
+
size: stats.size,
|
|
107
|
+
type: detectFileType(relativePath),
|
|
108
|
+
content
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Skip files we can't read
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return files;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Detect file type based on path and extension
|
|
122
|
+
*/
|
|
123
|
+
function detectFileType(filePath) {
|
|
124
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
125
|
+
|
|
126
|
+
if (filePath.includes('.github/workflows/')) {
|
|
127
|
+
return 'workflow';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (ext === '.json' && (filePath.includes('package.json') || filePath.includes('schema'))) {
|
|
131
|
+
return 'config';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (ext === '.sql' || filePath.includes('migration') || filePath.includes('schema')) {
|
|
135
|
+
return 'schema';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (['.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.java', '.cs'].includes(ext)) {
|
|
139
|
+
return 'source';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return 'other';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract high-signal evidence from files
|
|
147
|
+
*/
|
|
148
|
+
async function extractEvidence(files, cwd) {
|
|
149
|
+
const evidence = {
|
|
150
|
+
dependencies: [],
|
|
151
|
+
routes: [],
|
|
152
|
+
schemas: [],
|
|
153
|
+
auth: [],
|
|
154
|
+
workflows: []
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
try {
|
|
159
|
+
// Extract dependencies
|
|
160
|
+
if (file.path.includes('package.json')) {
|
|
161
|
+
const deps = extractDependencies(file);
|
|
162
|
+
evidence.dependencies.push(...deps);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Extract routes (best-effort pattern matching)
|
|
166
|
+
if (file.type === 'source') {
|
|
167
|
+
const routes = extractRoutes(file);
|
|
168
|
+
evidence.routes.push(...routes);
|
|
169
|
+
|
|
170
|
+
// Extract auth signals
|
|
171
|
+
const auth = extractAuthSignals(file);
|
|
172
|
+
evidence.auth.push(...auth);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Extract schemas
|
|
176
|
+
if (file.type === 'schema' || file.path.includes('model')) {
|
|
177
|
+
const schemas = extractSchemas(file);
|
|
178
|
+
evidence.schemas.push(...schemas);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Extract workflows
|
|
182
|
+
if (file.type === 'workflow') {
|
|
183
|
+
const workflow = extractWorkflow(file);
|
|
184
|
+
if (workflow) {
|
|
185
|
+
evidence.workflows.push(workflow);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// Skip files with extraction errors
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return evidence;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Extract dependencies from package.json
|
|
199
|
+
*/
|
|
200
|
+
function extractDependencies(file) {
|
|
201
|
+
const deps = [];
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const pkg = JSON.parse(file.content);
|
|
205
|
+
|
|
206
|
+
if (pkg.dependencies) {
|
|
207
|
+
for (const [name, version] of Object.entries(pkg.dependencies)) {
|
|
208
|
+
deps.push({
|
|
209
|
+
name,
|
|
210
|
+
version,
|
|
211
|
+
type: 'runtime',
|
|
212
|
+
source: file.path
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (pkg.devDependencies) {
|
|
218
|
+
for (const [name, version] of Object.entries(pkg.devDependencies)) {
|
|
219
|
+
deps.push({
|
|
220
|
+
name,
|
|
221
|
+
version,
|
|
222
|
+
type: 'dev',
|
|
223
|
+
source: file.path
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// Invalid JSON, skip
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return deps;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Extract API routes (best-effort pattern matching)
|
|
236
|
+
*/
|
|
237
|
+
function extractRoutes(file) {
|
|
238
|
+
const routes = [];
|
|
239
|
+
const lines = file.content.split('\n');
|
|
240
|
+
|
|
241
|
+
// Common patterns for Express, FastAPI, etc.
|
|
242
|
+
const routePatterns = [
|
|
243
|
+
/\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
244
|
+
/@(Get|Post|Put|Delete|Patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
245
|
+
/router\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
246
|
+
/app\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
lines.forEach((line, index) => {
|
|
250
|
+
for (const pattern of routePatterns) {
|
|
251
|
+
pattern.lastIndex = 0; // Reset regex
|
|
252
|
+
const match = pattern.exec(line);
|
|
253
|
+
if (match) {
|
|
254
|
+
const method = match[1].toUpperCase();
|
|
255
|
+
const routePath = match[2];
|
|
256
|
+
|
|
257
|
+
routes.push({
|
|
258
|
+
method,
|
|
259
|
+
path: routePath,
|
|
260
|
+
source: `${file.path}:${index + 1}`,
|
|
261
|
+
snippet: redactSecrets(line.trim())
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return routes;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Extract auth/permission signals
|
|
272
|
+
*/
|
|
273
|
+
function extractAuthSignals(file) {
|
|
274
|
+
const signals = [];
|
|
275
|
+
const lines = file.content.split('\n');
|
|
276
|
+
|
|
277
|
+
const authPatterns = [
|
|
278
|
+
{ pattern: /(authenticate|auth|requireAuth|isAuthenticated)/i, type: 'middleware' },
|
|
279
|
+
{ pattern: /(authorize|checkPermission|requireRole|hasRole)/i, type: 'guard' },
|
|
280
|
+
{ pattern: /(jwt|bearer|oauth|session)/i, type: 'config' }
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
lines.forEach((line, index) => {
|
|
284
|
+
for (const { pattern, type } of authPatterns) {
|
|
285
|
+
if (pattern.test(line)) {
|
|
286
|
+
signals.push({
|
|
287
|
+
type,
|
|
288
|
+
source: `${file.path}:${index + 1}`,
|
|
289
|
+
snippet: redactSecrets(line.trim())
|
|
290
|
+
});
|
|
291
|
+
break; // Only one signal per line
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return signals;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Extract schema definitions (best-effort)
|
|
301
|
+
*/
|
|
302
|
+
function extractSchemas(file) {
|
|
303
|
+
const schemas = [];
|
|
304
|
+
|
|
305
|
+
// Try to detect schema type
|
|
306
|
+
if (file.path.includes('migration')) {
|
|
307
|
+
schemas.push({
|
|
308
|
+
name: path.basename(file.path, path.extname(file.path)),
|
|
309
|
+
type: 'migration',
|
|
310
|
+
source: file.path,
|
|
311
|
+
snippet: redactSecrets(file.content.substring(0, 500))
|
|
312
|
+
});
|
|
313
|
+
} else if (file.path.includes('model')) {
|
|
314
|
+
schemas.push({
|
|
315
|
+
name: path.basename(file.path, path.extname(file.path)),
|
|
316
|
+
type: 'model',
|
|
317
|
+
source: file.path,
|
|
318
|
+
snippet: redactSecrets(file.content.substring(0, 500))
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return schemas;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Extract workflow configuration
|
|
327
|
+
*/
|
|
328
|
+
function extractWorkflow(file) {
|
|
329
|
+
try {
|
|
330
|
+
const workflow = yaml.load(file.content);
|
|
331
|
+
|
|
332
|
+
if (workflow && workflow.name) {
|
|
333
|
+
return {
|
|
334
|
+
name: workflow.name,
|
|
335
|
+
triggers: Object.keys(workflow.on || {}),
|
|
336
|
+
source: file.path
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
} catch (error) {
|
|
340
|
+
// Invalid YAML, skip
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Redact potential secrets from text (best-effort)
|
|
348
|
+
*/
|
|
349
|
+
function redactSecrets(text) {
|
|
350
|
+
let redacted = text;
|
|
351
|
+
|
|
352
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
353
|
+
pattern.lastIndex = 0; // Reset regex
|
|
354
|
+
redacted = redacted.replace(pattern, (match, p1) => {
|
|
355
|
+
if (p1 && p1.length > 8) {
|
|
356
|
+
return match.replace(p1, '[REDACTED]');
|
|
357
|
+
}
|
|
358
|
+
return match;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return redacted;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = {
|
|
366
|
+
buildEvidencePack,
|
|
367
|
+
redactSecrets
|
|
368
|
+
};
|