ripp-cli 1.0.0 → 1.2.1

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.
@@ -0,0 +1,224 @@
1
+ const yaml = require('js-yaml');
2
+
3
+ /**
4
+ * RIPP Checklist Parser
5
+ * Parses markdown checklist files generated by `ripp confirm --checklist`
6
+ * and extracts checked candidates with their YAML content.
7
+ *
8
+ * Handles edge cases:
9
+ * - Missing or empty files
10
+ * - Malformed YAML blocks
11
+ * - Partial/truncated blocks
12
+ * - Windows line endings
13
+ * - No items checked
14
+ * - Duplicate entries
15
+ */
16
+
17
+ /**
18
+ * Parse a checklist markdown file and extract checked candidates
19
+ *
20
+ * @param {string} checklistContent - Raw markdown content
21
+ * @returns {Object} - { candidates: Array, errors: Array, warnings: Array }
22
+ */
23
+ function parseChecklist(checklistContent) {
24
+ if (!checklistContent || checklistContent.trim().length === 0) {
25
+ return {
26
+ candidates: [],
27
+ errors: ['Checklist file is empty'],
28
+ warnings: []
29
+ };
30
+ }
31
+
32
+ // Normalize line endings (handle Windows CRLF)
33
+ const normalizedContent = checklistContent.replace(/\r\n/g, '\n');
34
+
35
+ const candidates = [];
36
+ const errors = [];
37
+ const warnings = [];
38
+ const seenSections = new Set(); // Track duplicates
39
+
40
+ // Split by candidate sections (## Candidate N: section_name)
41
+ const candidatePattern = /^## Candidate (\d+): (.+)$/gm;
42
+ const matches = [...normalizedContent.matchAll(candidatePattern)];
43
+
44
+ if (matches.length === 0) {
45
+ errors.push('No candidate sections found in checklist');
46
+ return { candidates, errors, warnings };
47
+ }
48
+
49
+ for (let i = 0; i < matches.length; i++) {
50
+ const match = matches[i];
51
+ const candidateNum = match[1];
52
+ const section = match[2].trim();
53
+ const startIndex = match.index;
54
+ const endIndex = i < matches.length - 1 ? matches[i + 1].index : normalizedContent.length;
55
+
56
+ // Extract the content between this candidate and the next
57
+ const candidateBlock = normalizedContent.substring(startIndex, endIndex);
58
+
59
+ // Check if this candidate is accepted (has [x] checkbox)
60
+ const acceptPattern = /^- \[x\] Accept this candidate$/im;
61
+ const isAccepted = acceptPattern.test(candidateBlock);
62
+
63
+ if (!isAccepted) {
64
+ continue; // Skip unchecked candidates
65
+ }
66
+
67
+ // Extract confidence (optional, for metadata)
68
+ const confidenceMatch = candidateBlock.match(/\*\*Confidence\*\*: ([\d.]+)%/);
69
+ const confidence = confidenceMatch ? parseFloat(confidenceMatch[1]) / 100 : 0.8;
70
+
71
+ // Extract evidence count (optional, for metadata)
72
+ const evidenceMatch = candidateBlock.match(/\*\*Evidence\*\*: (\d+) reference/);
73
+ const evidenceCount = evidenceMatch ? parseInt(evidenceMatch[1], 10) : 0;
74
+
75
+ // Extract YAML content from code block
76
+ const yamlPattern = /```yaml\n([\s\S]*?)\n```/;
77
+ const yamlMatch = candidateBlock.match(yamlPattern);
78
+
79
+ if (!yamlMatch) {
80
+ errors.push(`Candidate ${candidateNum} (${section}): No YAML content block found`);
81
+ continue;
82
+ }
83
+
84
+ const yamlContent = yamlMatch[1];
85
+
86
+ // Validate YAML can be parsed
87
+ let parsedContent;
88
+ try {
89
+ parsedContent = yaml.load(yamlContent);
90
+ } catch (yamlError) {
91
+ errors.push(`Candidate ${candidateNum} (${section}): Invalid YAML - ${yamlError.message}`);
92
+ continue;
93
+ }
94
+
95
+ // Check for duplicate sections
96
+ if (seenSections.has(section)) {
97
+ warnings.push(
98
+ `Candidate ${candidateNum} (${section}): Duplicate section detected, using first occurrence`
99
+ );
100
+ continue;
101
+ }
102
+
103
+ seenSections.add(section);
104
+
105
+ // Build candidate object
106
+ candidates.push({
107
+ candidateNum,
108
+ section,
109
+ confidence,
110
+ evidenceCount,
111
+ content: parsedContent,
112
+ rawYaml: yamlContent
113
+ });
114
+ }
115
+
116
+ return {
117
+ candidates,
118
+ errors,
119
+ warnings
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Convert parsed checklist candidates into confirmed intent format
125
+ *
126
+ * @param {Array} candidates - Parsed candidates from parseChecklist
127
+ * @param {Object} metadata - Optional metadata (user, timestamp)
128
+ * @returns {Object} - Confirmed intent data structure
129
+ */
130
+ function buildConfirmedIntent(candidates, metadata = {}) {
131
+ const confirmed = candidates.map(candidate => ({
132
+ section: candidate.section,
133
+ source: 'confirmed',
134
+ confirmed_at: metadata.timestamp || new Date().toISOString(),
135
+ confirmed_by: metadata.user || 'checklist',
136
+ original_confidence: candidate.confidence,
137
+ evidence: [], // Evidence references not preserved in checklist format
138
+ content: candidate.content
139
+ }));
140
+
141
+ return {
142
+ version: '1.0',
143
+ confirmed
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Validate confirmed intent blocks against quality rules
149
+ *
150
+ * @param {Array} confirmed - Array of confirmed intent blocks
151
+ * @returns {Object} - { accepted: Array, rejected: Array, reasons: Object }
152
+ */
153
+ function validateConfirmedBlocks(confirmed) {
154
+ const accepted = [];
155
+ const rejected = [];
156
+ const reasons = {};
157
+
158
+ for (const block of confirmed) {
159
+ const blockErrors = [];
160
+ const section = block.section;
161
+
162
+ // Rule 1: Section must be a known RIPP section or full-packet
163
+ const knownSections = [
164
+ 'purpose',
165
+ 'ux_flow',
166
+ 'data_contracts',
167
+ 'api_contracts',
168
+ 'permissions',
169
+ 'failure_modes',
170
+ 'audit_events',
171
+ 'nfrs',
172
+ 'acceptance_tests',
173
+ 'design_philosophy',
174
+ 'design_decisions',
175
+ 'constraints',
176
+ 'success_criteria',
177
+ 'full-packet' // Allow full packet candidates
178
+ ];
179
+
180
+ if (!knownSections.includes(section)) {
181
+ blockErrors.push(`Unknown section type: ${section}`);
182
+ }
183
+
184
+ // Rule 2: Content must not be empty
185
+ if (!block.content || Object.keys(block.content).length === 0) {
186
+ blockErrors.push('Content is empty');
187
+ }
188
+
189
+ // Rule 3: Check for placeholder values (based on linter rules)
190
+ const contentStr = JSON.stringify(block.content).toLowerCase();
191
+ const placeholders = ['unknown', 'todo', 'tbd', 'fixme', 'placeholder', 'xxx'];
192
+
193
+ for (const placeholder of placeholders) {
194
+ if (contentStr.includes(placeholder)) {
195
+ blockErrors.push(`Contains placeholder value: ${placeholder}`);
196
+ break; // Only report once per block
197
+ }
198
+ }
199
+
200
+ // Rule 4: Confidence threshold (if available and low)
201
+ if (block.original_confidence && block.original_confidence < 0.5) {
202
+ blockErrors.push(`Low confidence: ${(block.original_confidence * 100).toFixed(1)}%`);
203
+ }
204
+
205
+ if (blockErrors.length > 0) {
206
+ rejected.push(block);
207
+ reasons[section] = blockErrors;
208
+ } else {
209
+ accepted.push(block);
210
+ }
211
+ }
212
+
213
+ return {
214
+ accepted,
215
+ rejected,
216
+ reasons
217
+ };
218
+ }
219
+
220
+ module.exports = {
221
+ parseChecklist,
222
+ buildConfirmedIntent,
223
+ validateConfirmedBlocks
224
+ };
package/lib/config.js CHANGED
@@ -58,9 +58,8 @@ function loadConfig(cwd = process.cwd()) {
58
58
  config = mergeConfig(config, repoConfig);
59
59
 
60
60
  // Validate against schema
61
- // Resolve schema path from project root (3 levels up from lib/)
62
- const projectRoot = path.join(__dirname, '../../..');
63
- const schemaPath = path.join(projectRoot, 'schema/ripp-config.schema.json');
61
+ // Resolve schema path from bundled schema directory
62
+ const schemaPath = path.join(__dirname, '../schema/ripp-config.schema.json');
64
63
 
65
64
  if (!fs.existsSync(schemaPath)) {
66
65
  throw new Error(`Schema file not found at: ${schemaPath}`);
@@ -53,23 +53,69 @@ async function interactiveConfirm(cwd, candidates) {
53
53
  const candidate = candidates.candidates[i];
54
54
 
55
55
  console.log(`\n--- Candidate ${i + 1}/${candidates.candidates.length} ---`);
56
- console.log(`Section: ${candidate.section || 'unknown'}`);
56
+ const sectionName = candidate.purpose?.problem ? 'purpose' : 'full-packet';
57
+ console.log(`Section: ${sectionName}`);
57
58
  console.log(`Confidence: ${(candidate.confidence * 100).toFixed(1)}%`);
58
59
  console.log(`Evidence: ${candidate.evidence.length} reference(s)`);
59
60
  console.log('\nContent:');
60
- console.log(yaml.dump(candidate.content, { indent: 2 }));
61
+ // Build content object from candidate fields
62
+ const content = {};
63
+ const contentFields = [
64
+ 'purpose',
65
+ 'ux_flow',
66
+ 'data_contracts',
67
+ 'api_contracts',
68
+ 'permissions',
69
+ 'failure_modes',
70
+ 'audit_events',
71
+ 'nfrs',
72
+ 'acceptance_tests',
73
+ 'design_philosophy',
74
+ 'design_decisions',
75
+ 'constraints',
76
+ 'success_criteria'
77
+ ];
78
+ contentFields.forEach(field => {
79
+ if (candidate[field]) {
80
+ content[field] = candidate[field];
81
+ }
82
+ });
83
+ console.log(yaml.dump(content, { indent: 2 }));
61
84
 
62
85
  const answer = await question(rl, '\nAccept this candidate? (y/n/e to edit/s to skip): ');
63
86
 
64
87
  if (answer.toLowerCase() === 'y') {
88
+ // Build content object from candidate fields
89
+ const content = {};
90
+ const contentFields = [
91
+ 'purpose',
92
+ 'ux_flow',
93
+ 'data_contracts',
94
+ 'api_contracts',
95
+ 'permissions',
96
+ 'failure_modes',
97
+ 'audit_events',
98
+ 'nfrs',
99
+ 'acceptance_tests',
100
+ 'design_philosophy',
101
+ 'design_decisions',
102
+ 'constraints',
103
+ 'success_criteria'
104
+ ];
105
+ contentFields.forEach(field => {
106
+ if (candidate[field]) {
107
+ content[field] = candidate[field];
108
+ }
109
+ });
110
+
65
111
  confirmed.push({
66
- section: candidate.section,
112
+ section: candidate.purpose?.problem ? 'purpose' : 'full-packet',
67
113
  source: 'confirmed',
68
114
  confirmed_at: new Date().toISOString(),
69
115
  confirmed_by: options.user || 'unknown',
70
116
  original_confidence: candidate.confidence,
71
117
  evidence: candidate.evidence,
72
- content: candidate.content
118
+ content: content
73
119
  });
74
120
  console.log('✓ Accepted');
75
121
  } else if (answer.toLowerCase() === 'e') {
@@ -136,7 +182,10 @@ async function generateChecklistConfirm(cwd, candidates) {
136
182
  markdown += '---\n\n';
137
183
 
138
184
  candidates.candidates.forEach((candidate, index) => {
139
- markdown += `## Candidate ${index + 1}: ${candidate.section || 'unknown'}\n\n`;
185
+ // Extract section name from purpose or use generic identifier
186
+ const sectionName = candidate.purpose?.problem ? 'purpose' : 'full-packet';
187
+
188
+ markdown += `## Candidate ${index + 1}: ${sectionName}\n\n`;
140
189
  markdown += `- **Confidence**: ${(candidate.confidence * 100).toFixed(1)}%\n`;
141
190
  markdown += `- **Evidence**: ${candidate.evidence.length} reference(s)\n\n`;
142
191
 
@@ -145,7 +194,29 @@ async function generateChecklistConfirm(cwd, candidates) {
145
194
 
146
195
  markdown += '### Content\n\n';
147
196
  markdown += '```yaml\n';
148
- markdown += yaml.dump(candidate.content, { indent: 2 });
197
+ // Build content object from candidate fields (purpose, ux_flow, data_contracts, etc.)
198
+ const content = {};
199
+ const contentFields = [
200
+ 'purpose',
201
+ 'ux_flow',
202
+ 'data_contracts',
203
+ 'api_contracts',
204
+ 'permissions',
205
+ 'failure_modes',
206
+ 'audit_events',
207
+ 'nfrs',
208
+ 'acceptance_tests',
209
+ 'design_philosophy',
210
+ 'design_decisions',
211
+ 'constraints',
212
+ 'success_criteria'
213
+ ];
214
+ contentFields.forEach(field => {
215
+ if (candidate[field]) {
216
+ content[field] = candidate[field];
217
+ }
218
+ });
219
+ markdown += yaml.dump(content, { indent: 2 });
149
220
  markdown += '```\n\n';
150
221
 
151
222
  markdown += '### Evidence References\n\n';